Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0cc41c5
fix(client): modernize JWT payload decoder with base64url support
oliverlaz May 6, 2026
ca9614f
fix(client): dedupe concurrent loadToken calls
oliverlaz May 6, 2026
39daa12
feat(client): add WebSocketConnectionError and rollout/timeout options
oliverlaz May 6, 2026
37fef0c
feat(client): add EventDispatcher with per-listener error isolation
oliverlaz May 6, 2026
24ac28b
feat(client): add ConnectionIdGate replacing connectionIdPromise + se…
oliverlaz May 6, 2026
2d3d2b1
feat(client): add NetworkStatusBridge to collapse online/offline regi…
oliverlaz May 6, 2026
418bcdd
feat(client): add WebSocketTransport plus Mock/Manual test doubles
oliverlaz May 6, 2026
4e355e3
chore(client): replace em-dashes with plain ASCII punctuation
oliverlaz May 6, 2026
2e3d1fe
feat(client): add HeartbeatController with worker-aware health watchdog
oliverlaz May 6, 2026
fd345c7
feat(client): add CoordinatorSocket lifecycle (F5,F7,F8,F12,F13,F14)
oliverlaz May 6, 2026
8a43534
feat(client): add RestClient with bounded retries and shared timeout …
oliverlaz May 6, 2026
56e47ac
feat(client): add coordinator-client StreamClient facade
oliverlaz May 6, 2026
68b2669
feat(client): wire createCoordinatorClient to the new coordinator-client
oliverlaz May 6, 2026
4482ff6
test(client): add parametrized parity test across legacy and new coor…
oliverlaz May 6, 2026
1cdee2c
feat(react-dogfood): forward ?coordinator=legacy|new to useLegacyCoor…
oliverlaz May 6, 2026
7f5941b
test(client): fill in remaining CoordinatorSocket scenarios from the …
oliverlaz May 6, 2026
1392bfb
fix(client): mark handshake resolved on connection.ok (revert F13 quirk)
oliverlaz May 6, 2026
dffb3a1
fix(client): self-contained base64 decoder in signing.ts (RN 0.73+ safe)
oliverlaz May 6, 2026
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
77 changes: 77 additions & 0 deletions packages/client/src/__tests__/StreamVideoClient.parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import 'dotenv/config';
import { StreamVideoClient } from '../StreamVideoClient';
import { generateUUIDv4 } from '../coordinator/connection/utils';
import { StreamClient } from '@stream-io/node-sdk';

const apiKey = process.env.STREAM_API_KEY;
const secret = process.env.STREAM_SECRET;

const haveCredentials = Boolean(apiKey && secret);
const describeIfCreds = haveCredentials ? describe : describe.skip;

describeIfCreds('StreamVideoClient parity (legacy vs. new coordinator)', () => {
let serverClient: StreamClient;
let tokenProvider: (userId: string) => () => Promise<string>;

beforeEach(() => {
serverClient = new StreamClient(apiKey!, secret!);
tokenProvider = (userId: string) => async () =>
new Promise<string>((resolve) => {
setTimeout(() => {
resolve(serverClient.generateUserToken({ user_id: userId }));
}, 50);
});
});

describe.each([
{ mode: 'new', useLegacyCoordinator: false },
{ mode: 'legacy', useLegacyCoordinator: true },
])('mode=$mode', ({ useLegacyCoordinator }) => {
let client: StreamVideoClient;

beforeEach(() => {
client = new StreamVideoClient(apiKey!, {
browser: true,
timeout: 15000,
useLegacyCoordinator,
});
});
afterEach(async () => {
await client.disconnectUser();
});

it('private endpoint waits for connection then succeeds', async () => {
client.connectUser({ id: 'jane' }, tokenProvider('jane'));
const response = await client.queryCalls({});
expect(response).toBeDefined();
});

it('connection_id is enriched on REST requests', async () => {
client.connectUser({ id: 'jane' }, tokenProvider('jane'));
const spy = vi.spyOn(client.streamClient.axiosInstance, 'post');
await client.call('default', generateUUIDv4()).getOrCreate();
const requestConfig: { params: Record<string, unknown> } = spy.mock.calls[
spy.mock.calls.length - 1
][2] as {
params: Record<string, unknown>;
};
const params = requestConfig.params;
const connectionId = client.streamClient._getConnectionID();
expect(connectionId).toBeDefined();
expect(params.connection_id).toBe(connectionId);
});

it('private endpoint throws after disconnectUser', async () => {
await client.connectUser({ id: 'jane' }, tokenProvider('jane'));
await client.disconnectUser();
await expect(() => client.queryCalls({})).rejects.toThrowError();
});

it('public endpoint does not require connectUser', async () => {
const userId = `guest-${generateUUIDv4()}`;
const response = await client.createGuestUser({ user: { id: userId } });
expect(response.user.id).toContain(userId);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import { ConnectionIdGate } from '../internal/ConnectionIdGate';

describe('ConnectionIdGate', () => {
it('await() before arm() throws', () => {
const gate = new ConnectionIdGate();
expect(() => gate.await()).toThrow(/not armed/);
});

it('arm() is idempotent while pending; second await() returns the same promise', () => {
const gate = new ConnectionIdGate();
gate.arm();
const first = gate.await();
gate.arm();
const second = gate.await();
expect(first).toBe(second);
});

it('resolve(id) settles all pending awaiters with id', async () => {
const gate = new ConnectionIdGate();
gate.arm();
const a = gate.await();
const b = gate.await();
gate.resolve('conn-id');
await expect(a).resolves.toBe('conn-id');
await expect(b).resolves.toBe('conn-id');
});

it('reject(err) settles all pending awaiters with the error', async () => {
const gate = new ConnectionIdGate();
gate.arm();
const a = gate.await();
const err = new Error('boom');
gate.reject(err);
await expect(a).rejects.toThrow('boom');
});

it('reset() discards the gate; subsequent await() throws until next arm()', async () => {
const gate = new ConnectionIdGate();
gate.arm();
const inflight = gate.await();
gate.resolve('conn-id');
await inflight; // drain
gate.reset();
expect(() => gate.await()).toThrow(/not armed/);
gate.arm();
expect(gate.isPending()).toBe(true);
});

it('resolve()/reject() after settlement is a no-op', async () => {
const gate = new ConnectionIdGate();
gate.arm();
const inflight = gate.await();
gate.resolve('first');
gate.resolve('second');
gate.reject(new Error('ignored'));
await expect(inflight).resolves.toBe('first');
});

it('isPending / isSettled reflect state correctly across the lifecycle', () => {
const gate = new ConnectionIdGate();
expect(gate.isPending()).toBe(false);
expect(gate.isSettled()).toBe(false);
gate.arm();
expect(gate.isPending()).toBe(true);
expect(gate.isSettled()).toBe(false);
gate.resolve('id');
expect(gate.isPending()).toBe(false);
expect(gate.isSettled()).toBe(true);
gate.reset();
expect(gate.isPending()).toBe(false);
expect(gate.isSettled()).toBe(false);
});

it('F1: rejects in-flight awaiter BEFORE subsequent arm() rotates state', async () => {
const gate = new ConnectionIdGate();
gate.arm();
const awaiter = gate.await(); // captures P1
gate.reject(new Error('boom')); // settles P1
gate.arm(); // P1 already settled, so this rotates to fresh P2
const newAwaiter = gate.await();
await expect(awaiter).rejects.toThrow('boom');
expect(awaiter).not.toBe(newAwaiter);
});

it('arm() after settlement rotates to a fresh pending promise', async () => {
const gate = new ConnectionIdGate();
gate.arm();
gate.resolve('id-1');
gate.arm();
expect(gate.isPending()).toBe(true);
const fresh = gate.await();
let resolved = false;
fresh.then(() => {
resolved = true;
});
// Give microtasks a tick
await Promise.resolve();
expect(resolved).toBe(false);
gate.resolve('id-2');
await expect(fresh).resolves.toBe('id-2');
});
});
Loading
Loading