Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/browser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,29 @@ jobs:
with:
test_service_port: 8000
token: ${{ secrets.GITHUB_TOKEN }}
stop_service: 'false'
version: v3
extra_params: '--skip-from=${{ github.workspace }}/packages/sdk/browser/contract-tests/suppressions_datamode_changes.txt'

# Reopen the entity with the fetch EventSource toggle. The adapter recreates its REST
# server when the new page connects over the WebSocket.
- name: Restart browser app with the fetch EventSource
run: |
kill $(cat /tmp/playwright.pid) || true
node packages/sdk/browser/contract-tests/entity/open-browser.mjs 'http://localhost:5173/?eventsource=fetch' > /tmp/playwright-fetch.log 2>&1 &
echo $! > /tmp/playwright.pid
sleep 5 # Give the browser time to initialize and connect via WebSocket

# This run proves the fetch-based EventSource against the full suite. It uses its own
# skip list, which does not contain the native EventSource transport limits.
- name: Run contract tests (FDv2, fetch EventSource)
uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1
with:
test_service_port: 8000
token: ${{ secrets.GITHUB_TOKEN }}
version: v3
extra_params: '--skip-from=${{ github.workspace }}/packages/sdk/browser/contract-tests/suppressions_custom_eventsource.txt'

- name: Print logs on failure
if: failure()
run: |
Expand All @@ -127,6 +147,8 @@ jobs:
cat /tmp/http-server.log || echo "No http-server log"
echo "=== Playwright Log ==="
cat /tmp/playwright.log || echo "No playwright log"
echo "=== Playwright Log (fetch EventSource) ==="
cat /tmp/playwright-fetch.log || echo "No fetch playwright log"

- name: Cleanup contract test services
if: always()
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/react.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
# Skip Electron binary download to avoid flaky race condition during concurrent installs.
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
run: |
yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/react-sdk-contract-tests' install
yarn workspaces focus @launchdarkly/react-sdk-contract-tests
yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/react-sdk-contract-tests' run build

- name: Install Playwright browsers
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"packages/sdk/shopify-oxygen/example",
"packages/sdk/browser/example",
"packages/sdk/browser/example-fdv2",
"packages/sdk/browser/examples/features/custom-eventsource",
"packages/sdk/openfeature-node-server",
"packages/sdk/openfeature-node-server/examples/getting-started",
"packages/sdk/openfeature-cloudflare-server",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { AutoEnvAttributes, LDLogger } from '@launchdarkly/js-client-sdk-common';

import { makeClient } from '../src/BrowserClient';

const mockPlatformConstructor = jest.fn();

jest.mock('../src/platform/BrowserPlatform', () => ({
__esModule: true,
default: jest.fn().mockImplementation((logger, options, storage, eventSourceFactory) => {
mockPlatformConstructor(logger, options, storage, eventSourceFactory);
const { makeBasicPlatform } = require('./BrowserClient.mocks');
const platform = makeBasicPlatform(options);
return platform;
}),
}));

let logger: LDLogger;

beforeEach(() => {
jest.clearAllMocks();
logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() };
});

function getWiredEventSource() {
return mockPlatformConstructor.mock.calls[0][3];
}

it('passes a valid event source factory through to the platform unchanged', () => {
const eventSource = { createEventSource: jest.fn() };

makeClient('client-side-id', { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, {
streaming: false,
logger,
diagnosticOptOut: true,
eventSource,
});

expect(getWiredEventSource()).toBe(eventSource);
expect(logger.warn).not.toHaveBeenCalled();
});

it('does not pass an event source factory when none is configured', () => {
makeClient('client-side-id', { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, {
streaming: false,
logger,
diagnosticOptOut: true,
});

expect(getWiredEventSource()).toBeUndefined();
});

it('drops an invalid event source factory and warns', () => {
makeClient('client-side-id', { key: 'user-key', kind: 'user' }, AutoEnvAttributes.Disabled, {
streaming: false,
logger,
diagnosticOptOut: true,
// @ts-ignore
eventSource: 'not an object',
});

expect(getWiredEventSource()).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
'Config option "eventSource" should be of type object, got string, using default value',
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { createEventSource as mockedCreateEventSource } from '@launchdarkly/eventsource';
import { EventSourceInitDict } from '@launchdarkly/js-client-sdk-common';

import fetchBrowserEventSource from '../../src/fetch-eventsource/fetchBrowserEventSource';

// The fetch-based EventSource needs a streaming fetch, which jsdom does not provide. Only the
// adapter is under test here; the implementation itself is covered by the eventsource package's
// own suite.
jest.mock('@launchdarkly/eventsource', () => ({
createEventSource: jest.fn(),
}));

const mockCreateEventSource = mockedCreateEventSource as unknown as jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
});

it('declares support for headers, a custom method, and a read timeout', () => {
expect(fetchBrowserEventSource.capabilities).toEqual({
customMethod: true,
readTimeout: true,
headers: true,
});
});

it('passes the url and every option through, plus the backoff defaults', () => {
const urlBuilder = () => 'http://example.com/stream?basis=2';
const errorFilter = () => true;
const initDict: EventSourceInitDict = {
method: 'REPORT',
headers: { authorization: 'sdk-key' },
body: '{"kind":"user"}',
errorFilter,
initialRetryDelayMillis: 1000,
readTimeoutMillis: 300000,
retryResetIntervalMillis: 60000,
urlBuilder,
};

fetchBrowserEventSource.createEventSource('http://example.com/stream', initDict);

expect(mockCreateEventSource).toHaveBeenCalledTimes(1);
expect(mockCreateEventSource).toHaveBeenCalledWith('http://example.com/stream', {
method: 'REPORT',
headers: { authorization: 'sdk-key' },
body: '{"kind":"user"}',
errorFilter,
initialRetryDelayMillis: 1000,
readTimeoutMillis: 300000,
retryResetIntervalMillis: 60000,
urlBuilder,
maxBackoffMillis: 30000,
jitterRatio: 0.5,
});
});
39 changes: 39 additions & 0 deletions packages/sdk/browser/__tests__/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,42 @@ it('passes an empty dataSystem through to base options', () => {
const result = filterToBaseOptionsWithDefaults(opts);
expect((result as any).dataSystem).toEqual({});
});

it('accepts an event source factory', () => {
const eventSource = { createEventSource: jest.fn() } as unknown as NonNullable<
Parameters<typeof validateBrowserOptions>[0]['eventSource']
>;

const opts = validateBrowserOptions({ eventSource }, logger);

expect(opts.eventSource).toBe(eventSource);
expect(logger.warn).not.toHaveBeenCalled();
});

it('warns and drops an event source that is not an object', () => {
const opts = validateBrowserOptions(
{
// @ts-ignore
eventSource: 'not an object',
},
logger,
);

expect(opts.eventSource).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
'Config option "eventSource" should be of type object, got string, using default value',
);
});

it('defaults the event source factory to undefined', () => {
const opts = validateBrowserOptions({}, logger);
expect(opts.eventSource).toBeUndefined();
});

it('strips the event source factory from base options', () => {
const eventSource = { createEventSource: jest.fn() } as unknown as NonNullable<
Parameters<typeof validateBrowserOptions>[0]['eventSource']
>;
const baseOpts = filterToBaseOptionsWithDefaults({ eventSource });
expect(baseOpts).not.toHaveProperty('eventSource');
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { LDLogger, Storage } from '@launchdarkly/js-client-sdk-common';
import {
EventSourceInitDict,
LDEventSourceFactory,
LDLogger,
Storage,
} from '@launchdarkly/js-client-sdk-common';

import BrowserPlatform from '../../src/platform/BrowserPlatform';
import LocalStorage, { isLocalStorageSupported } from '../../src/platform/LocalStorage';
Expand Down Expand Up @@ -63,3 +68,38 @@ it('uses the provided storage override even when localStorage is unsupported', (
expect(platform.storage).toBe(custom);
expect(mockLocalStorage).not.toHaveBeenCalled();
});

it('reports no optional event source capabilities when no factory override is provided', () => {
const platform = new BrowserPlatform(logger, {});

expect(platform.requests.getEventSourceCapabilities()).toEqual({
customMethod: false,
readTimeout: false,
headers: false,
});
});

it('uses the provided event source factory override', () => {
const created = { close: jest.fn() };
const factory: LDEventSourceFactory = {
createEventSource: jest.fn().mockReturnValue(created),
capabilities: { customMethod: true, readTimeout: true, headers: true },
};

const platform = new BrowserPlatform(logger, {}, undefined, factory);
const initDict: EventSourceInitDict = {
headers: { authorization: 'sdk-key' },
errorFilter: () => true,
initialRetryDelayMillis: 1000,
readTimeoutMillis: 300000,
retryResetIntervalMillis: 60000,
};

expect(platform.requests.createEventSource('http://example.com/stream', initDict)).toBe(created);
expect(factory.createEventSource).toHaveBeenCalledWith('http://example.com/stream', initDict);
expect(platform.requests.getEventSourceCapabilities()).toEqual({
customMethod: true,
readTimeout: true,
headers: true,
});
});
69 changes: 69 additions & 0 deletions packages/sdk/browser/__tests__/platform/BrowserRequests.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { EventSourceInitDict, LDEventSourceFactory } from '@launchdarkly/js-client-sdk-common';

import BrowserRequests from '../../src/platform/BrowserRequests';
import DefaultBrowserEventSource from '../../src/platform/DefaultBrowserEventSource';

const initDict: EventSourceInitDict = {
headers: { authorization: 'sdk-key' },
errorFilter: () => true,
initialRetryDelayMillis: 1000,
readTimeoutMillis: 300000,
retryResetIntervalMillis: 60000,
};

beforeEach(() => {
// jsdom does not implement EventSource, and DefaultBrowserEventSource constructs one as soon as
// it is created.
// @ts-ignore
globalThis.EventSource = jest.fn(() => ({
addEventListener: jest.fn(),
close: jest.fn(),
}));
});

it('reports no optional capabilities by default', () => {
const requests = new BrowserRequests();

expect(requests.getEventSourceCapabilities()).toEqual({
customMethod: false,
readTimeout: false,
headers: false,
});
});

it('creates a native browser event source by default', () => {
const requests = new BrowserRequests();

const es = requests.createEventSource('http://example.com/stream', initDict);

expect(es).toBeInstanceOf(DefaultBrowserEventSource);
es.close();
});

it('uses an injected factory and its declared capabilities', () => {
const created = { close: jest.fn() };
const factory: LDEventSourceFactory = {
createEventSource: jest.fn().mockReturnValue(created),
capabilities: { customMethod: true, readTimeout: true, headers: true },
};
const requests = new BrowserRequests(factory);

expect(requests.createEventSource('http://example.com/stream', initDict)).toBe(created);
expect(factory.createEventSource).toHaveBeenCalledWith('http://example.com/stream', initDict);
expect(requests.getEventSourceCapabilities()).toEqual({
customMethod: true,
readTimeout: true,
headers: true,
});
});

it('reports no optional capabilities for a factory that declares none', () => {
const factory: LDEventSourceFactory = { createEventSource: jest.fn() };
const requests = new BrowserRequests(factory);

expect(requests.getEventSourceCapabilities()).toEqual({
customMethod: false,
readTimeout: false,
headers: false,
});
});
17 changes: 14 additions & 3 deletions packages/sdk/browser/contract-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,18 @@ go run . --url http://localhost:8000 \

#### Suppression files

Two skip-lists live alongside this README; pass the appropriate one to `-skip-from`:
Three skip-lists live alongside this README; pass the appropriate one to `-skip-from`:

- **`suppressions.txt`** — the standard skip list for the Browser SDK contract tests.
- **`suppressions_datamode_changes.txt`** — a broader skip list used when running against the FDv2 (data system / "data mode") client-side work. In addition to the standard skips, it excludes request tests that are not expected to pass in that configuration yet (the HTTP `REPORT` streaming/polling request variants and the `tags` streaming/polling request tests), giving a clean baseline on that branch.
- **`suppressions.txt`** — the standard skip list for the Browser SDK contract tests (harness v2). It skips tests that the native browser EventSource can never pass: `REPORT` streaming and the `tags` stream-request header assertions.
- **`suppressions_datamode_changes.txt`** — the skip list for the FDv2 (data system / "data mode") run (harness v3). It skips the `tags` request tests that fail because the SDK's FDv2 path does not send the tags header.
- **`suppressions_custom_eventsource.txt`** — the skip list for the fetch EventSource run (harness v3). In this run the entity forces the fetch-based EventSource for every client, so no native transport limits apply. Only the FDv2-path `tags` gaps remain.

#### Fetch EventSource run

Open the entity with `?eventsource=fetch` to make it use the fetch-based EventSource for every client and advertise the `client-use-post` capability:

```bash
node packages/sdk/browser/contract-tests/entity/open-browser.mjs 'http://localhost:5173/?eventsource=fetch' &
```

Then run the v3 harness with `suppressions_custom_eventsource.txt`. This proves the custom EventSource against the full suite, including `usePost` and `REPORT` streaming.
Loading
Loading