Files
open-design/apps/web/tests/collab-client.test.ts
014bffbf77 fix(collab): synchronize owner devices with overwrite consent (#8683)
* fix(collab): synchronize owner devices with overwrite consent

* docs(collab): add owner device synchronization QA handoff

* chore(collab): move delivery reports and screenshots to Plane

* fix(collab): preserve owner pull fallback and ghost heal

Generated-By: looper 0.16.1 (runner=fixer, agent=codex)

* fix(collab): probe null owner heads for retractions

Generated-By: looper 0.16.1 (runner=fixer, agent=codex)

* fix(collab): preserve manual checks across coalescing

Generated-By: looper 0.16.1 (runner=fixer, agent=codex)

* feat(collab): hide update check behind a dismissed-version badge

Show the header entry only when the owner skipped a cloud version, as a
"Cloud update available" badge that re-opens the overwrite dialog.
Rewrite the dialog description in plain product language.

* fix(collab): clarify device sync copy and place entry before present

* fix(collab): silence update reminders without automatic overwrite

---------

Co-authored-by: Looper <looper@noreply.github.com>
Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com>
2026-10-13 11:14:14 +00:00

1698 lines
55 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
CollabClient,
type CollabPresenceMember,
type CollabSnapshot,
} from '../src/collab/collab-client.js';
import { workspaceContextFixture } from './helpers/workspace-context';
const TEAM_CONTEXT = workspaceContextFixture({
workspaceId: 'workspace-team',
workspaceMemberId: 'member-viewer',
});
interface RecordedCall {
url: string;
method: string;
body: unknown;
headers: Headers;
}
interface FakeFetchOptions {
present?: CollabPresenceMember[];
publishedVersion?: number | null;
syncState?: string | null;
failPath?: string;
}
const PRESENCE_TEST_NOW = Date.parse('2026-08-05T00:00:00.000Z');
const presenceTime = (offsetMs = 0) =>
new Date(PRESENCE_TEST_NOW + offsetMs).toISOString();
function makeFetch(options: FakeFetchOptions = {}) {
const calls: RecordedCall[] = [];
const state = {
present: options.present ?? [{ memberId: 'm1', name: 'Author' }],
publishedVersion: options.publishedVersion ?? null,
syncState: options.syncState ?? 'synced',
};
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method ?? 'GET';
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
calls.push({ url, method, body, headers: new Headers(init?.headers) });
const pathname = new URL(url, 'http://daemon.local').pathname;
if (options.failPath && pathname.endsWith(options.failPath)) {
return { ok: false, status: 500, json: async () => ({}) } as unknown as Response;
}
let payload: unknown = { ok: true };
if (
pathname.endsWith('/presence')
|| pathname.endsWith('/presence/heartbeat')
) {
payload = { present: state.present };
}
else if (pathname.endsWith('/collab/status')) {
payload = { publishedVersion: state.publishedVersion, syncState: state.syncState };
}
return { ok: true, status: 200, json: async () => payload } as unknown as Response;
}) as typeof fetch;
return { fetchImpl, calls, state };
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(PRESENCE_TEST_NOW);
});
afterEach(() => {
vi.useRealTimers();
});
describe('CollabClient', () => {
it('keeps an explicit silenced offer open across status reads, but never shares it with a fresh client', async () => {
let latest = 4;
const makeUpdate = (state: string) => ({ state, latestVersion: latest, localVersion: 1, promptSuppressedUntil: PRESENCE_TEST_NOW + 86_400_000, updatedAt: PRESENCE_TEST_NOW });
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const manual = init?.method === 'POST';
const payload = manual ? { update: makeUpdate('confirmation_required') }
: { syncState: 'synced', ownedContentUpdate: makeUpdate('suppressed') };
return { ok: true, status: 200, json: async () => payload } as Response;
}) as typeof fetch;
const options = { projectId: 'p1', member: null, fetch: fetchImpl, workspaceContext: TEAM_CONTEXT };
const client = new CollabClient(options);
await client.requestContentUpdate({ action: 'check', manual: true });
await client.pollStatus();
expect(client.getSnapshot().ownedContentUpdate?.state).toBe('confirmation_required');
const reopened = new CollabClient(options);
await reopened.pollStatus();
expect(reopened.getSnapshot().ownedContentUpdate?.state).toBe('suppressed');
latest = 5;
await client.pollStatus();
expect(client.getSnapshot().ownedContentUpdate).toMatchObject({ state: 'suppressed', latestVersion: 5 });
});
it('binds status and pull to the captured workspace identity', async () => {
const { fetchImpl, calls } = makeFetch();
const client = new CollabClient({
projectId: 'p1',
member: null,
fetch: fetchImpl,
workspaceContext: TEAM_CONTEXT,
});
await client.pollStatus();
await client.pull();
const status = calls.find((call) => call.url.endsWith('/collab/status'));
const pull = calls.find((call) => call.url.endsWith('/collab/pull'));
for (const call of [status, pull]) {
expect(call?.headers.get('x-od-workspace-id')).toBe(
TEAM_CONTEXT.workspaceId,
);
expect(call?.headers.get('x-od-workspace-member-id')).toBe(
TEAM_CONTEXT.workspaceMemberId,
);
}
});
it('applies content-transfer SSE state in timestamp order', () => {
const { fetchImpl } = makeFetch();
const client = new CollabClient({
projectId: 'p1',
member: null,
fetch: fetchImpl,
});
client.applyContentTransferState({
status: 'downloading',
version: 8,
startedAt: 100,
updatedAt: 100,
});
client.applyContentTransferState({
status: 'idle',
version: 8,
startedAt: 100,
updatedAt: 200,
});
client.applyContentTransferState({
status: 'downloading',
version: 8,
startedAt: 100,
updatedAt: 150,
});
expect(client.getSnapshot().contentTransferState).toMatchObject({
status: 'idle',
updatedAt: 200,
});
});
it('clears a stale downloading snapshot when the current daemon reports no transfer', async () => {
const fetchImpl = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
publishedVersion: 8,
materializedVersion: 8,
contentTransferState: null,
syncState: 'synced',
}),
})) as unknown as typeof fetch;
const client = new CollabClient({
projectId: 'p1',
member: null,
fetch: fetchImpl,
});
client.applyContentTransferState({
status: 'downloading',
version: 8,
startedAt: 100,
updatedAt: 100,
});
await client.pollStatus();
expect(client.getSnapshot().contentTransferState).toBeNull();
});
it('does not let an older null status response clear a newer SSE transfer', async () => {
let resolveStatus!: (response: Response) => void;
const statusResponse = new Promise<Response>((resolve) => {
resolveStatus = resolve;
});
const fetchImpl = vi.fn(async () => statusResponse) as unknown as typeof fetch;
const client = new CollabClient({
projectId: 'p1',
member: null,
fetch: fetchImpl,
});
const polling = client.pollStatus();
client.applyContentTransferState({
status: 'downloading',
version: 9,
startedAt: 200,
updatedAt: 200,
});
resolveStatus({
ok: true,
status: 200,
json: async () => ({
publishedVersion: 8,
materializedVersion: 8,
contentTransferState: null,
syncState: 'synced',
}),
} as Response);
await polling;
expect(client.getSnapshot().contentTransferState).toMatchObject({
status: 'downloading',
version: 9,
});
});
it('does not let an older concrete poll overwrite a restart null and newer SSE transfer', async () => {
let resolveOldStatus!: (response: Response) => void;
const oldStatusResponse = new Promise<Response>((resolve) => {
resolveOldStatus = resolve;
});
let statusCall = 0;
const fetchImpl = vi.fn(async () => {
statusCall += 1;
if (statusCall === 1) return oldStatusResponse;
return {
ok: true,
status: 200,
json: async () => ({
publishedVersion: 8,
materializedVersion: 8,
contentTransferState: null,
syncState: 'synced',
}),
} as Response;
}) as unknown as typeof fetch;
const client = new CollabClient({
projectId: 'p1',
member: null,
fetch: fetchImpl,
});
const oldPolling = client.pollStatus();
await client.pollStatus();
client.applyContentTransferState({
status: 'downloading',
version: 9,
startedAt: 1,
updatedAt: 1,
});
resolveOldStatus({
ok: true,
status: 200,
json: async () => ({
publishedVersion: 8,
materializedVersion: 8,
contentTransferState: {
status: 'downloading',
version: 8,
startedAt: 10_000,
updatedAt: 10_000,
},
syncState: 'synced',
}),
} as Response);
await oldPolling;
expect(client.getSnapshot().contentTransferState).toMatchObject({
status: 'downloading',
version: 9,
});
});
it('polls status on start, then heartbeats once the project is shared', async () => {
const { fetchImpl, calls, state } = makeFetch({
present: [{ memberId: 'm1', name: 'Author' }],
publishedVersion: 4,
});
const updates: CollabSnapshot[] = [];
const client = new CollabClient({
projectId: 'p1',
member: { memberId: 'm1', name: 'Author', role: 'owner' },
fetch: fetchImpl,
onUpdate: (snapshot) => updates.push(snapshot),
});
client.start();
await vi.advanceTimersByTimeAsync(0);
const heartbeat = calls.find((c) => c.url.endsWith('/presence/heartbeat'));
expect(heartbeat?.method).toBe('POST');
expect(heartbeat?.body).toMatchObject({ memberId: 'm1', name: 'Author', role: 'owner' });
expect(calls.some((c) => c.method === 'GET' && c.url.endsWith('/collab/status'))).toBe(true);
const snapshot = client.getSnapshot();
expect(snapshot.present).toEqual(state.present);
expect(snapshot.publishedVersion).toBe(4);
expect(updates.length).toBeGreaterThanOrEqual(2);
client.stop();
});
it('does not launch a stopped lifecycle\'s queued cold status read after restart', async () => {
const { fetchImpl, calls } = makeFetch({ publishedVersion: 4 });
const client = new CollabClient({
projectId: 'p1',
member: null,
fetch: fetchImpl,
});
client.start();
client.stop();
client.start();
await vi.advanceTimersByTimeAsync(0);
expect(
calls.filter(
({ method, url }) =>
method === 'GET' && url.endsWith('/collab/status'),
),
).toHaveLength(1);
client.stop();
});
it('does not duplicate the queued cold read when the same client is polled explicitly', async () => {
const { fetchImpl, calls } = makeFetch({ publishedVersion: 4 });
const client = new CollabClient({
projectId: 'p1',
member: null,
fetch: fetchImpl,
});
client.start();
await client.pollStatus();
await vi.advanceTimersByTimeAsync(0);
expect(
calls.filter(
({ method, url }) =>
method === 'GET' && url.endsWith('/collab/status'),
),
).toHaveLength(1);
client.stop();
});
it('starts a scoped Team heartbeat without waiting for a slow status response', async () => {
let resolveStatus!: (response: Response) => void;
const fetchMock = vi.fn(
(input: RequestInfo | URL): Promise<Response> => {
const pathname = new URL(String(input), 'http://daemon.local').pathname;
if (pathname.endsWith('/collab/status')) {
return new Promise<Response>((resolve) => {
resolveStatus = resolve;
});
}
if (pathname.endsWith('/presence/heartbeat')) {
return Promise.resolve(
new Response(
JSON.stringify({
present: [
{ memberId: 'member-viewer' },
{ memberId: 'member-peer' },
],
}),
{ status: 200 },
),
);
}
throw new Error(`unexpected request: ${pathname}`);
},
);
const fetchImpl = fetchMock as unknown as typeof fetch;
const client = new CollabClient({
projectId: 'p-slow-status',
member: { memberId: 'member-viewer' },
workspaceContext: TEAM_CONTEXT,
fetch: fetchImpl,
heartbeatMs: 10_000,
});
client.start();
await vi.advanceTimersByTimeAsync(0);
expect(resolveStatus).toBeTypeOf('function');
expect(fetchMock.mock.calls.some(([input]) =>
String(input).endsWith('/presence/heartbeat'))).toBe(true);
expect(client.getSnapshot().present).toEqual([
{ memberId: 'member-viewer' },
{ memberId: 'member-peer' },
]);
client.stop();
resolveStatus(
new Response(
JSON.stringify({ publishedVersion: 1, syncState: 'synced' }),
{ status: 200 },
),
);
await vi.advanceTimersByTimeAsync(0);
});
it('shows the daemon cached roster before a slow Team heartbeat settles', async () => {
let resolveStatus!: (response: Response) => void;
let resolveHeartbeat!: (response: Response) => void;
const requestPaths: string[] = [];
const fetchImpl = vi.fn(
(input: RequestInfo | URL): Promise<Response> => {
const pathname = new URL(String(input), 'http://daemon.local').pathname;
requestPaths.push(pathname);
if (pathname.endsWith('/collab/status')) {
return new Promise<Response>((resolve) => {
resolveStatus = resolve;
});
}
if (pathname.endsWith('/presence/heartbeat')) {
return new Promise<Response>((resolve) => {
resolveHeartbeat = resolve;
});
}
if (pathname.endsWith('/presence')) {
return Promise.resolve(
new Response(
JSON.stringify({
present: [
{ memberId: 'member-viewer' },
{ memberId: 'member-peer' },
],
}),
{ status: 200 },
),
);
}
throw new Error(`unexpected request: ${pathname}`);
},
) as unknown as typeof fetch;
const client = new CollabClient({
projectId: 'p-cached-presence',
member: { memberId: 'member-viewer' },
workspaceContext: TEAM_CONTEXT,
fetch: fetchImpl,
});
client.start();
await vi.advanceTimersByTimeAsync(0);
expect(requestPaths.indexOf('/api/projects/p-cached-presence/presence'))
.toBeGreaterThanOrEqual(0);
expect(requestPaths.indexOf('/api/projects/p-cached-presence/presence'))
.toBeLessThan(
requestPaths.indexOf('/api/projects/p-cached-presence/presence/heartbeat'),
);
expect(client.getSnapshot().present).toEqual([
{ memberId: 'member-viewer' },
{ memberId: 'member-peer' },
]);
client.stop();
resolveHeartbeat(
new Response(JSON.stringify({ present: [] }), { status: 200 }),
);
resolveStatus(
new Response(
JSON.stringify({ publishedVersion: 1, syncState: 'synced' }),
{ status: 200 },
),
);
await vi.advanceTimersByTimeAsync(0);
});
it('does not let a slow cached start read overwrite the heartbeat roster', async () => {
let resolveStatus!: (response: Response) => void;
let resolveCachedPresence!: (response: Response) => void;
const fetchImpl = vi.fn(
(input: RequestInfo | URL): Promise<Response> => {
const pathname = new URL(String(input), 'http://daemon.local').pathname;
if (pathname.endsWith('/collab/status')) {
return new Promise<Response>((resolve) => {
resolveStatus = resolve;
});
}
if (pathname.endsWith('/presence/heartbeat')) {
return Promise.resolve(
new Response(
JSON.stringify({
present: [
{ memberId: 'member-viewer' },
{ memberId: 'authoritative-peer' },
],
}),
{ status: 200 },
),
);
}
if (pathname.endsWith('/presence')) {
return new Promise<Response>((resolve) => {
resolveCachedPresence = resolve;
});
}
throw new Error(`unexpected request: ${pathname}`);
},
) as unknown as typeof fetch;
const client = new CollabClient({
projectId: 'p-ordered-presence',
member: { memberId: 'member-viewer' },
workspaceContext: TEAM_CONTEXT,
fetch: fetchImpl,
});
client.start();
await vi.advanceTimersByTimeAsync(0);
expect(client.getSnapshot().present).toEqual([
{ memberId: 'member-viewer' },
{ memberId: 'authoritative-peer' },
]);
resolveCachedPresence(
new Response(
JSON.stringify({
present: [
{ memberId: 'member-viewer' },
{ memberId: 'stale-peer' },
],
}),
{ status: 200 },
),
);
await vi.advanceTimersByTimeAsync(0);
expect(client.getSnapshot().present).toEqual([
{ memberId: 'member-viewer' },
{ memberId: 'authoritative-peer' },
]);
client.stop();
resolveStatus(
new Response(
JSON.stringify({ publishedVersion: 1, syncState: 'synced' }),
{ status: 200 },
),
);
await vi.advanceTimersByTimeAsync(0);
});
it('does not optimistically heartbeat a Personal or unscoped session', async () => {
const pendingStatus = new Promise<Response>(() => {});
const fetchMock = vi.fn(
(input: RequestInfo | URL): Promise<Response> => {
const pathname = new URL(String(input), 'http://daemon.local').pathname;
if (pathname.endsWith('/collab/status')) return pendingStatus;
throw new Error(`unexpected request: ${pathname}`);
},
);
const fetchImpl = fetchMock as unknown as typeof fetch;
const personalContext = {
...TEAM_CONTEXT,
workspaceType: 'personal' as const,
teamId: undefined,
};
const personal = new CollabClient({
projectId: 'p-personal',
member: { memberId: 'member-viewer' },
workspaceContext: personalContext,
fetch: fetchImpl,
heartbeatMs: 10_000,
});
const unscoped = new CollabClient({
projectId: 'p-unscoped',
member: { memberId: 'member-viewer' },
fetch: fetchImpl,
heartbeatMs: 10_000,
});
personal.start();
unscoped.start();
await vi.advanceTimersByTimeAsync(10_000);
expect(fetchMock.mock.calls.filter(([input]) =>
String(input).endsWith('/presence/heartbeat'))).toHaveLength(0);
personal.stop();
unscoped.stop();
});
it('keeps the local roster empty when optimistic Team heartbeat authority is rejected', async () => {
const errors: unknown[] = [];
const fetchImpl = vi.fn(
(input: RequestInfo | URL): Promise<Response> => {
const pathname = new URL(String(input), 'http://daemon.local').pathname;
if (pathname.endsWith('/collab/status')) {
return new Promise<Response>(() => {});
}
if (pathname.endsWith('/presence/heartbeat')) {
return Promise.resolve(
new Response(
JSON.stringify({ error: 'WORKSPACE_ACCESS_DENIED' }),
{ status: 403 },
),
);
}
throw new Error(`unexpected request: ${pathname}`);
},
) as unknown as typeof fetch;
const client = new CollabClient({
projectId: 'p-denied',
member: { memberId: 'member-viewer' },
workspaceContext: TEAM_CONTEXT,
fetch: fetchImpl,
onError: (error) => errors.push(error),
});
client.start();
await vi.advanceTimersByTimeAsync(0);
expect(errors).toHaveLength(1);
expect(client.getSnapshot().present).toEqual([]);
client.stop();
});
it('does not heartbeat for a local-only project', async () => {
const { fetchImpl, calls } = makeFetch({ syncState: 'local_only' });
const client = new CollabClient({
projectId: 'p1',
member: { memberId: 'm1', name: 'Author', role: 'owner' },
fetch: fetchImpl,
});
client.start();
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(10_000);
expect(calls.some((c) => c.url.endsWith('/collab/status'))).toBe(true);
expect(calls.some((c) => c.url.endsWith('/presence/heartbeat'))).toBe(false);
client.stop();
});
it('re-heartbeats and re-polls on their own intervals', async () => {
const { fetchImpl, calls } = makeFetch();
const client = new CollabClient({
projectId: 'p1',
member: { memberId: 'm1' },
fetch: fetchImpl,
heartbeatMs: 10_000,
statusPollMs: 5_000,
});
client.start();
await vi.advanceTimersByTimeAsync(0);
const initialHeartbeats = calls.filter((c) => c.url.endsWith('/presence/heartbeat')).length;
const initialStatus = calls.filter((c) => c.url.endsWith('/collab/status')).length;
// One full heartbeat window: status polls twice more (5s each), heartbeat once more.
await vi.advanceTimersByTimeAsync(10_000);
expect(calls.filter((c) => c.url.endsWith('/presence/heartbeat')).length).toBe(initialHeartbeats + 1);
expect(calls.filter((c) => c.url.endsWith('/collab/status')).length).toBe(initialStatus + 2);
client.stop();
});
it('retains a peer for the upstream lease window when self-bearing rosters briefly omit it', async () => {
const { fetchImpl, state } = makeFetch({
present: [
{ memberId: 'viewer', name: 'Viewer', heartbeatAt: presenceTime() },
{ memberId: 'peer', name: 'Peer', heartbeatAt: presenceTime() },
],
});
const client = new CollabClient({
projectId: 'p-transient-roster',
member: { memberId: 'viewer', name: 'Viewer' },
fetch: fetchImpl,
heartbeatMs: 10_000,
statusPollMs: 5_000,
});
client.start();
await vi.advanceTimersByTimeAsync(0);
expect(client.getSnapshot().present).toHaveLength(2);
state.present = [{
memberId: 'viewer',
name: 'Viewer',
heartbeatAt: presenceTime(10_000),
}];
await vi.advanceTimersByTimeAsync(10_000);
expect(client.getSnapshot().present.map(({ memberId }) => memberId))
.toEqual(['viewer', 'peer']);
// The Vela presence lease is authoritative for 30 seconds from the last
// roster that actually contained the peer. A delayed heartbeat can make
// the peer absent from the 10s and 20s reads without making that last-good
// evidence stale. Dropping it after one interval makes the non-owner side
// flicker while the owner's local self witness hides the same upstream gap.
await vi.advanceTimersByTimeAsync(19_999);
expect(client.getSnapshot().present.map(({ memberId }) => memberId))
.toEqual(['viewer', 'peer']);
// At the exact upstream TTL boundary the peer is no longer retained.
await vi.advanceTimersByTimeAsync(1);
expect(client.getSnapshot().present.map(({ memberId }) => memberId))
.toEqual(['viewer']);
client.stop();
});
it('never flashes a known peer when it returns before the upstream lease window ends', async () => {
const { fetchImpl, state } = makeFetch({
present: [
{ memberId: 'viewer', name: 'Viewer', heartbeatAt: presenceTime() },
{ memberId: 'owner', name: 'Owner', heartbeatAt: presenceTime() },
],
});
const snapshots: CollabPresenceMember[][] = [];
const client = new CollabClient({
projectId: 'p-owner-transient-gap',
member: { memberId: 'viewer', name: 'Viewer' },
fetch: fetchImpl,
heartbeatMs: 10_000,
onUpdate: (snapshot) => snapshots.push(snapshot.present),
});
client.start();
await vi.advanceTimersByTimeAsync(0);
expect(client.getSnapshot().present.map(({ memberId }) => memberId))
.toEqual(['viewer', 'owner']);
snapshots.length = 0;
state.present = [{
memberId: 'viewer',
name: 'Viewer',
heartbeatAt: presenceTime(10_000),
}];
await vi.advanceTimersByTimeAsync(20_000);
state.present = [
{ memberId: 'viewer', name: 'Viewer', heartbeatAt: presenceTime(30_000) },
{ memberId: 'owner', name: 'Owner', heartbeatAt: presenceTime(30_000) },
];
await vi.advanceTimersByTimeAsync(10_000);
expect(snapshots.every((present) =>
present.some(({ memberId }) => memberId === 'owner'))).toBe(true);
expect(client.getSnapshot().present).toHaveLength(2);
client.stop();
});
it('does not extend a nearly expired backend lease from the local observation time', async () => {
vi.setSystemTime(PRESENCE_TEST_NOW + 29_000);
const { fetchImpl, state } = makeFetch({
present: [
{ memberId: 'viewer', heartbeatAt: presenceTime(29_000) },
{ memberId: 'owner', heartbeatAt: presenceTime() },
],
});
const client = new CollabClient({
projectId: 'p-nearly-expired-owner',
member: { memberId: 'viewer' },
fetch: fetchImpl,
heartbeatMs: 1_000,
});
client.start();
await vi.advanceTimersByTimeAsync(0);
state.present = [{
memberId: 'viewer',
heartbeatAt: presenceTime(30_000),
}];
await vi.advanceTimersByTimeAsync(999);
expect(client.getSnapshot().present.map(({ memberId }) => memberId))
.toEqual(['viewer', 'owner']);
await vi.advanceTimersByTimeAsync(1);
expect(client.getSnapshot().present.map(({ memberId }) => memberId))
.toEqual(['viewer']);
client.stop();
});
it.each([
['missing', undefined],
['invalid', 'not-a-date'],
])('retains a peer with %s heartbeatAt for only one fallback heartbeat window', async (_label, heartbeatAt) => {
const { fetchImpl, state } = makeFetch({
present: [
{ memberId: 'viewer' },
{ memberId: 'legacy-peer', heartbeatAt },
],
});
const client = new CollabClient({
projectId: 'p-legacy-presence',
member: { memberId: 'viewer' },
fetch: fetchImpl,
heartbeatMs: 10_000,
});
client.start();
await vi.advanceTimersByTimeAsync(0);
state.present = [{ memberId: 'viewer' }];
await vi.advanceTimersByTimeAsync(19_999);
expect(client.getSnapshot().present.map(({ memberId }) => memberId))
.toEqual(['viewer', 'legacy-peer']);
await vi.advanceTimersByTimeAsync(1);
expect(client.getSnapshot().present.map(({ memberId }) => memberId))
.toEqual(['viewer']);
client.stop();
});
it('applies an event-driven fresh roster exactly so explicit leaves stay immediate', async () => {
const { fetchImpl, state } = makeFetch({
present: [
{ memberId: 'viewer', name: 'Viewer' },
{ memberId: 'peer', name: 'Peer' },
],
});
const client = new CollabClient({
projectId: 'p-explicit-leave',
member: { memberId: 'viewer', name: 'Viewer' },
fetch: fetchImpl,
heartbeatMs: 10_000,
});
client.start();
await vi.advanceTimersByTimeAsync(0);
state.present = [{ memberId: 'viewer', name: 'Viewer' }];
await client.refreshPresence();
expect(client.getSnapshot().present).toEqual([
{ memberId: 'viewer', name: 'Viewer' },
]);
client.stop();
});
it.each([
['an empty roster', []],
['a caller-less roster', [{ memberId: 'other-peer' }]],
])('clears retained peers immediately for %s', async (_label, present) => {
const { fetchImpl, state } = makeFetch({
present: [{ memberId: 'viewer' }, { memberId: 'peer' }],
});
const client = new CollabClient({
projectId: 'p-fail-closed-roster',
member: { memberId: 'viewer' },
fetch: fetchImpl,
heartbeatMs: 10_000,
});
client.start();
await vi.advanceTimersByTimeAsync(0);
state.present = [{ memberId: 'viewer' }];
await vi.advanceTimersByTimeAsync(10_000);
expect(client.getSnapshot().present).toEqual([
{ memberId: 'viewer' },
{ memberId: 'peer' },
]);
state.present = present;
await client.refreshPresence();
expect(client.getSnapshot().present).toEqual(present);
client.stop();
});
it('clears the roster immediately when heartbeat authority is revoked', async () => {
let heartbeatCount = 0;
const errors: unknown[] = [];
const fetchImpl = vi.fn(
(input: RequestInfo | URL): Promise<Response> => {
const pathname = new URL(String(input), 'http://daemon.local').pathname;
if (pathname.endsWith('/collab/status')) {
return Promise.resolve(Response.json({ syncState: 'synced' }));
}
if (pathname.endsWith('/presence/heartbeat')) {
heartbeatCount += 1;
if (heartbeatCount === 1) {
return Promise.resolve(Response.json({
present: [{ memberId: 'viewer' }, { memberId: 'peer' }],
}));
}
return Promise.resolve(Response.json(
{ error: 'WORKSPACE_ACCESS_DENIED' },
{ status: 403 },
));
}
if (pathname.endsWith('/presence/leave')) {
return Promise.resolve(Response.json({ ok: true }));
}
throw new Error(`unexpected request: ${pathname}`);
},
) as unknown as typeof fetch;
const client = new CollabClient({
projectId: 'p-revoked-roster',
member: { memberId: 'viewer' },
fetch: fetchImpl,
heartbeatMs: 10_000,
onError: (error) => errors.push(error),
});
client.start();
await vi.advanceTimersByTimeAsync(0);
expect(client.getSnapshot().present).toHaveLength(2);
await vi.advanceTimersByTimeAsync(10_000);
expect(client.getSnapshot().present).toEqual([]);
expect(errors).toHaveLength(1);
client.stop();
});
it('does not carry a retained roster across member identities or projects', async () => {
const projectA = makeFetch({
present: [{ memberId: 'member-a' }, { memberId: 'peer-a' }],
});
const projectB = makeFetch({
present: [{ memberId: 'member-b' }, { memberId: 'peer-b' }],
});
const clientA = new CollabClient({
projectId: 'project-a',
member: { memberId: 'member-a' },
fetch: projectA.fetchImpl,
heartbeatMs: 10_000,
});
const clientB = new CollabClient({
projectId: 'project-b',
member: { memberId: 'member-b' },
fetch: projectB.fetchImpl,
heartbeatMs: 10_000,
});
clientA.start();
clientB.start();
await vi.advanceTimersByTimeAsync(0);
projectA.state.present = [{ memberId: 'member-a' }];
projectB.state.present = [{ memberId: 'member-b' }];
await vi.advanceTimersByTimeAsync(10_000);
expect(clientA.getSnapshot().present.map(({ memberId }) => memberId))
.toEqual(['member-a', 'peer-a']);
expect(clientB.getSnapshot().present.map(({ memberId }) => memberId))
.toEqual(['member-b', 'peer-b']);
projectA.state.present = [{ memberId: 'member-a-2' }];
clientA.setMember({ memberId: 'member-a-2' });
expect(clientA.getSnapshot().present).toEqual([]);
await vi.advanceTimersByTimeAsync(0);
expect(clientA.getSnapshot().present).toEqual([
{ memberId: 'member-a-2' },
]);
expect(clientB.getSnapshot().present.map(({ memberId }) => memberId))
.toEqual(['member-b', 'peer-b']);
clientA.stop();
clientB.stop();
});
it('keeps heartbeats and roster convergence alive while hidden, then refreshes fresh on visibility', async () => {
const visibilityListeners = new Set<() => void>();
const fakeDocument = {
visibilityState: 'hidden',
addEventListener: vi.fn((type: string, listener: () => void) => {
if (type === 'visibilitychange') visibilityListeners.add(listener);
}),
removeEventListener: vi.fn((type: string, listener: () => void) => {
if (type === 'visibilitychange') visibilityListeners.delete(listener);
}),
};
vi.stubGlobal('document', fakeDocument);
const { fetchImpl, calls, state } = makeFetch({
present: [{ memberId: 'm1' }],
});
const client = new CollabClient({
projectId: 'p1',
member: { memberId: 'm1' },
fetch: fetchImpl,
heartbeatMs: 10_000,
statusPollMs: 5_000,
});
try {
client.start();
await vi.advanceTimersByTimeAsync(0);
state.present = [{ memberId: 'm1' }, { memberId: 'joined' }];
await vi.advanceTimersByTimeAsync(10_000);
expect(client.getSnapshot().present).toEqual([
{ memberId: 'm1' },
{ memberId: 'joined' },
]);
state.present = [{ memberId: 'm1' }];
await vi.advanceTimersByTimeAsync(20_000);
expect(client.getSnapshot().present).toEqual([{ memberId: 'm1' }]);
expect(calls.filter((call) =>
call.url.endsWith('/presence/heartbeat'))).toHaveLength(4);
expect(calls.filter((call) =>
call.url.endsWith('/collab/status'))).toHaveLength(1);
fakeDocument.visibilityState = 'visible';
for (const listener of visibilityListeners) listener();
await vi.advanceTimersByTimeAsync(0);
// Presence has the same semantics in foreground and background. The
// visibility transition adds a fresh read only as a convergence
// accelerator; it does not clear the last roster first.
expect(calls.filter((call) =>
call.url.endsWith('/presence/heartbeat'))).toHaveLength(4);
expect(calls.filter((call) =>
call.url.endsWith('/presence?fresh=1'))).toHaveLength(1);
expect(calls.filter((call) =>
call.url.endsWith('/collab/status'))).toHaveLength(2);
expect(client.getSnapshot().present).toEqual([{ memberId: 'm1' }]);
client.stop();
const afterStop = calls.length;
await vi.advanceTimersByTimeAsync(30_000);
expect(calls).toHaveLength(afterStop);
expect(visibilityListeners.size).toBe(0);
} finally {
client.stop();
vi.unstubAllGlobals();
}
});
it('applies a correct slow heartbeat while a newer request is still pending', async () => {
const pendingHeartbeats: Array<(response: Response) => void> = [];
const fetchImpl = vi.fn(
(input: RequestInfo | URL): Promise<Response> => {
const pathname = new URL(String(input), 'http://daemon.local').pathname;
if (pathname.endsWith('/collab/status')) {
return Promise.resolve(
new Response(
JSON.stringify({ publishedVersion: 1, syncState: 'synced' }),
{ status: 200 },
),
);
}
if (pathname.endsWith('/presence/heartbeat')) {
return new Promise<Response>((resolve) => {
pendingHeartbeats.push(resolve);
});
}
throw new Error(`unexpected request: ${pathname}`);
},
) as unknown as typeof fetch;
const client = new CollabClient({
projectId: 'p1',
member: { memberId: 'viewer' },
fetch: fetchImpl,
heartbeatMs: 10_000,
});
client.start();
await vi.advanceTimersByTimeAsync(10_000);
expect(pendingHeartbeats).toHaveLength(2);
pendingHeartbeats[0]!(
new Response(
JSON.stringify({
present: [{ memberId: 'viewer' }, { memberId: 'teammate' }],
}),
{ status: 200 },
),
);
await vi.advanceTimersByTimeAsync(0);
// Merely issuing heartbeat #2 must not suppress heartbeat #1's valid
// response on a 10s+ network. The newer response can still supersede it
// once that newer request actually settles.
expect(client.getSnapshot().present).toEqual([
{ memberId: 'viewer' },
{ memberId: 'teammate' },
]);
pendingHeartbeats[1]!(
new Response(
JSON.stringify({ present: [{ memberId: 'viewer' }] }),
{ status: 200 },
),
);
await vi.advanceTimersByTimeAsync(0);
expect(client.getSnapshot().present).toEqual([
{ memberId: 'viewer' },
{ memberId: 'teammate' },
]);
await vi.advanceTimersByTimeAsync(10_000);
expect(pendingHeartbeats).toHaveLength(3);
pendingHeartbeats[2]!(
new Response(
JSON.stringify({ present: [{ memberId: 'viewer' }] }),
{ status: 200 },
),
);
await vi.advanceTimersByTimeAsync(0);
expect(client.getSnapshot().present).toEqual([{ memberId: 'viewer' }]);
client.stop();
});
it('refreshes presence with a read instead of emitting another heartbeat', async () => {
const { fetchImpl, calls, state } = makeFetch({
present: [{ memberId: 'm2', name: 'Teammate' }],
});
const client = new CollabClient({
projectId: 'p1',
member: { memberId: 'm1' },
fetch: fetchImpl,
workspaceContext: TEAM_CONTEXT,
});
await client.refreshPresence();
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
url: '/api/projects/p1/presence?fresh=1',
method: 'GET',
});
expect(calls[0]!.headers.get('x-od-workspace-id')).toBe(
TEAM_CONTEXT.workspaceId,
);
expect(client.getSnapshot().present).toEqual(state.present);
expect(calls.some((call) => call.url.endsWith('/presence/heartbeat'))).toBe(false);
});
it('converges when an event refresh races a correct heartbeat with a stale SWR roster', async () => {
let resolveHeartbeat!: (response: Response) => void;
const fetchImpl = vi.fn(
(input: RequestInfo | URL): Promise<Response> => {
const url = new URL(String(input), 'http://daemon.local');
if (url.pathname.endsWith('/collab/status')) {
return Promise.resolve(
new Response(
JSON.stringify({ publishedVersion: 1, syncState: 'synced' }),
{ status: 200 },
),
);
}
if (url.pathname.endsWith('/presence/heartbeat')) {
return new Promise<Response>((resolve) => {
resolveHeartbeat = resolve;
});
}
expect(url.pathname).toBe('/api/projects/p1/presence');
const present = url.searchParams.get('fresh') === '1'
? [{ memberId: 'viewer' }, { memberId: 'teammate' }]
: [{ memberId: 'viewer' }];
return Promise.resolve(
new Response(JSON.stringify({ present }), { status: 200 }),
);
},
) as unknown as typeof fetch;
const client = new CollabClient({
projectId: 'p1',
member: { memberId: 'viewer' },
fetch: fetchImpl,
});
client.start();
await vi.advanceTimersByTimeAsync(0);
expect(resolveHeartbeat).toBeTypeOf('function');
// The hub event arrives while the authoritative heartbeat is in flight.
// An ordinary daemon GET would return its stale-while-revalidate cache and
// the shared generation fence would then discard the heartbeat response.
await client.refreshPresence();
resolveHeartbeat(
new Response(
JSON.stringify({
present: [{ memberId: 'viewer' }, { memberId: 'teammate' }],
}),
{ status: 200 },
),
);
await vi.advanceTimersByTimeAsync(0);
expect(client.getSnapshot().present).toEqual([
{ memberId: 'viewer' },
{ memberId: 'teammate' },
]);
client.stop();
});
it('keeps known display metadata only for members retained by a sparse roster', async () => {
const rosters = [
[
{
memberId: 'viewer',
name: 'Viewer',
role: 'admin',
avatarUrl: 'https://example.test/viewer.png',
},
{
memberId: 'teammate',
name: 'Teammate',
role: 'member',
avatarUrl: 'https://example.test/teammate.png',
},
{
memberId: 'departed',
name: 'Departed',
role: 'member',
},
],
[{ memberId: 'viewer' }, { memberId: 'teammate' }],
];
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ present: rosters.shift() }), {
status: 200,
})) as unknown as typeof fetch;
const client = new CollabClient({
projectId: 'p1',
member: null,
fetch: fetchImpl,
});
await client.refreshPresence();
await client.refreshPresence();
expect(client.getSnapshot().present).toEqual([
{
memberId: 'viewer',
name: 'Viewer',
role: 'admin',
avatarUrl: 'https://example.test/viewer.png',
},
{
memberId: 'teammate',
name: 'Teammate',
role: 'member',
avatarUrl: 'https://example.test/teammate.png',
},
]);
});
it('does not let an older presence read overwrite a newer roster', async () => {
const presenceReads: Array<(response: Response) => void> = [];
const fetchImpl = vi.fn(
(input: RequestInfo | URL): Promise<Response> => {
const pathname = new URL(String(input), 'http://daemon.local').pathname;
expect(pathname).toBe('/api/projects/p1/presence');
return new Promise<Response>((resolve) => {
presenceReads.push(resolve);
});
},
) as unknown as typeof fetch;
const client = new CollabClient({
projectId: 'p1',
member: null,
fetch: fetchImpl,
});
const olderRefresh = client.refreshPresence();
const newerRefresh = client.refreshPresence();
expect(presenceReads).toHaveLength(2);
presenceReads[1]!(
new Response(
JSON.stringify({ present: [{ memberId: 'new-member' }] }),
{ status: 200 },
),
);
await newerRefresh;
expect(client.getSnapshot().present).toEqual([
{ memberId: 'new-member' },
]);
presenceReads[0]!(
new Response(
JSON.stringify({ present: [{ memberId: 'stale-member' }] }),
{ status: 200 },
),
);
await olderRefresh;
expect(client.getSnapshot().present).toEqual([
{ memberId: 'new-member' },
]);
});
it('does not let an older heartbeat response overwrite a newer presence read', async () => {
let resolveHeartbeat!: (response: Response) => void;
const fetchImpl = vi.fn(
(input: RequestInfo | URL): Promise<Response> => {
const pathname = new URL(String(input), 'http://daemon.local').pathname;
if (pathname.endsWith('/collab/status')) {
return Promise.resolve(
new Response(
JSON.stringify({ publishedVersion: 1, syncState: 'synced' }),
{ status: 200 },
),
);
}
if (pathname.endsWith('/presence/heartbeat')) {
return new Promise<Response>((resolve) => {
resolveHeartbeat = resolve;
});
}
expect(pathname).toBe('/api/projects/p1/presence');
return Promise.resolve(
new Response(
JSON.stringify({ present: [{ memberId: 'new-member' }] }),
{ status: 200 },
),
);
},
) as unknown as typeof fetch;
const client = new CollabClient({
projectId: 'p1',
member: { memberId: 'm1' },
fetch: fetchImpl,
});
client.start();
await vi.advanceTimersByTimeAsync(0);
expect(resolveHeartbeat).toBeTypeOf('function');
await client.refreshPresence();
expect(client.getSnapshot().present).toEqual([
{ memberId: 'new-member' },
]);
resolveHeartbeat(
new Response(
JSON.stringify({ present: [{ memberId: 'stale-member' }] }),
{ status: 200 },
),
);
await vi.advanceTimersByTimeAsync(0);
expect(client.getSnapshot().present).toEqual([
{ memberId: 'new-member' },
]);
client.stop();
});
it('does not apply a presence read that resolves after stop', async () => {
let resolvePresence!: (response: Response) => void;
const fetchImpl = vi.fn(
(input: RequestInfo | URL): Promise<Response> => {
const pathname = new URL(String(input), 'http://daemon.local').pathname;
if (pathname.endsWith('/collab/status')) {
return Promise.resolve(
new Response(
JSON.stringify({ publishedVersion: 1, syncState: 'synced' }),
{ status: 200 },
),
);
}
expect(pathname).toBe('/api/projects/p1/presence');
return new Promise<Response>((resolve) => {
resolvePresence = resolve;
});
},
) as unknown as typeof fetch;
const client = new CollabClient({
projectId: 'p1',
member: null,
fetch: fetchImpl,
});
client.start();
await vi.advanceTimersByTimeAsync(0);
const refresh = client.refreshPresence();
client.stop();
resolvePresence(
new Response(
JSON.stringify({ present: [{ memberId: 'late-member' }] }),
{ status: 200 },
),
);
await refresh;
expect(client.getSnapshot().present).toEqual([]);
});
it('does not let a stopped session leave remove a replacement session for the same member', async () => {
const leases = new Map<string, string>();
const heartbeatBodies: Array<Record<string, unknown>> = [];
const pendingLeaves: Array<() => void> = [];
const fetchImpl = vi.fn(
(input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const pathname = new URL(String(input), 'http://daemon.local').pathname;
if (pathname.endsWith('/collab/status')) {
return Promise.resolve(
new Response(
JSON.stringify({ publishedVersion: 1, syncState: 'synced' }),
{ status: 200 },
),
);
}
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
const memberId = String(body.memberId);
const clientId =
typeof body.clientId === 'string' ? body.clientId : memberId;
if (pathname.endsWith('/presence/heartbeat')) {
heartbeatBodies.push(body);
leases.set(clientId, memberId);
return Promise.resolve(
new Response(JSON.stringify({ present: [{ memberId }] }), {
status: 200,
}),
);
}
expect(pathname).toBe('/api/projects/p1/presence/leave');
return new Promise<Response>((resolve) => {
pendingLeaves.push(() => {
leases.delete(clientId);
resolve(
new Response(JSON.stringify({ ok: true, present: [] }), {
status: 200,
}),
);
});
});
},
) as unknown as typeof fetch;
const member = { memberId: 'member-1' };
const oldClient = new CollabClient({
projectId: 'p1',
member,
fetch: fetchImpl,
});
const replacementClient = new CollabClient({
projectId: 'p1',
member,
fetch: fetchImpl,
});
oldClient.start();
await vi.advanceTimersByTimeAsync(0);
expect(heartbeatBodies).toHaveLength(1);
oldClient.stop();
expect(pendingLeaves).toHaveLength(1);
replacementClient.start();
await vi.advanceTimersByTimeAsync(0);
expect(heartbeatBodies).toHaveLength(2);
pendingLeaves.shift()!();
await vi.advanceTimersByTimeAsync(0);
expect(heartbeatBodies[0]!.clientId).not.toBe(
heartbeatBodies[1]!.clientId,
);
expect(leases.size).toBe(1);
replacementClient.stop();
pendingLeaves.shift()!();
await vi.advanceTimersByTimeAsync(0);
});
it('reports author changes and requests a publish through the sync routes', async () => {
const { fetchImpl, calls } = makeFetch();
const client = new CollabClient({ projectId: 'p9', member: { memberId: 'm1' }, fetch: fetchImpl });
await client.reportChange();
await client.requestPublish();
expect(calls.some((c) => c.method === 'POST' && c.url.endsWith('/p9/collab/changed'))).toBe(true);
expect(calls.some((c) => c.method === 'POST' && c.url.endsWith('/p9/collab/publish'))).toBe(true);
});
it('sends leave and stops polling on stop', async () => {
const { fetchImpl, calls } = makeFetch();
const client = new CollabClient({
projectId: 'p1',
member: { memberId: 'm1' },
fetch: fetchImpl,
heartbeatMs: 10_000,
});
client.start();
await vi.advanceTimersByTimeAsync(0);
client.stop();
await vi.advanceTimersByTimeAsync(0);
const heartbeat = calls.find((call) =>
call.url.endsWith('/presence/heartbeat'),
);
const leave = calls.find((call) => call.url.endsWith('/presence/leave'));
expect(leave?.method).toBe('POST');
expect(leave?.body).toMatchObject({
memberId: 'm1',
clientId: expect.any(String),
sequence: 2,
});
expect((leave?.body as { clientId: string }).clientId).toBe(
(heartbeat?.body as { clientId: string }).clientId,
);
expect(heartbeat?.body).toMatchObject({ sequence: 1 });
const afterStop = calls.length;
await vi.advanceTimersByTimeAsync(30_000);
expect(calls.length).toBe(afterStop); // timers cleared — no further polling
});
it('does not send leave when stop happens before any heartbeat attempt', async () => {
const paths: string[] = [];
const fetchImpl = vi.fn(
(input: RequestInfo | URL): Promise<Response> => {
const pathname = new URL(String(input), 'http://daemon.local').pathname;
paths.push(pathname);
return Promise.resolve(
new Response(
JSON.stringify({ publishedVersion: 1, syncState: 'synced' }),
{ status: 200 },
),
);
},
) as unknown as typeof fetch;
const client = new CollabClient({
projectId: 'p1',
member: { memberId: 'm1' },
fetch: fetchImpl,
});
client.start();
client.stop();
await vi.advanceTimersByTimeAsync(0);
expect(paths.some((path) => path.endsWith('/presence/heartbeat'))).toBe(false);
expect(paths.some((path) => path.endsWith('/presence/leave'))).toBe(false);
});
it('leaveBeacon delivers the same session lease via sendBeacon so it survives page unload', async () => {
const { fetchImpl, calls } = makeFetch();
const beacons: Array<{ url: string; body: Promise<string> }> = [];
const sendBeacon = vi.fn((url: string, blob: Blob) => {
beacons.push({ url, body: blob.text() });
return true;
});
vi.stubGlobal('navigator', { sendBeacon });
const client = new CollabClient({ projectId: 'p1', member: { memberId: 'm1' }, fetch: fetchImpl });
client.start();
await vi.advanceTimersByTimeAsync(0);
client.leaveBeacon();
expect(sendBeacon).toHaveBeenCalledTimes(1);
expect(beacons[0]!.url).toBe('/api/projects/p1/presence/leave');
const heartbeat = calls.find((call) =>
call.url.endsWith('/presence/heartbeat'),
);
expect(JSON.parse(await beacons[0]!.body)).toEqual({
memberId: 'm1',
clientId: (heartbeat?.body as { clientId: string }).clientId,
sequence: 2,
});
// Beacon path used — no keepalive fetch fallback.
expect(calls.some((c) => c.url.endsWith('/presence/leave'))).toBe(false);
client.stop();
await vi.advanceTimersByTimeAsync(0);
vi.unstubAllGlobals();
});
it('leaveBeacon falls back to a keepalive fetch when sendBeacon is unavailable', async () => {
const { fetchImpl, calls } = makeFetch();
vi.stubGlobal('navigator', {});
const client = new CollabClient({ projectId: 'p1', member: { memberId: 'm-x' }, fetch: fetchImpl });
client.start();
await vi.advanceTimersByTimeAsync(0);
client.leaveBeacon();
const heartbeat = calls.find((c) => c.url.endsWith('/presence/heartbeat'));
const leave = calls.find((c) => c.url.endsWith('/presence/leave'));
expect(leave?.method).toBe('POST');
expect(leave?.body).toMatchObject({
memberId: 'm-x',
clientId: (heartbeat?.body as { clientId: string }).clientId,
sequence: 2,
});
vi.unstubAllGlobals();
});
it('surfaces status failures through onError without starting presence', async () => {
const { fetchImpl } = makeFetch({ failPath: '/collab/status' });
const errors: unknown[] = [];
const client = new CollabClient({
projectId: 'p1',
member: { memberId: 'm1' },
fetch: fetchImpl,
onError: (error) => errors.push(error),
});
client.start();
await vi.advanceTimersByTimeAsync(0);
expect(errors.length).toBeGreaterThanOrEqual(1);
// Without an authoritative shared-project status, presence must stay off.
expect(client.getSnapshot().present).toEqual([]);
client.stop();
});
// GET /collab/status is a plain project-keyed read — the daemon resolves the
// caller's own identity server-side from request headers/cookies, not from
// this payload — so a client can run status polling before it has a
// presence identity at all. Presence (heartbeat/leave) must stay off the
// whole time.
describe('member-less status polling (setMember)', () => {
it('polls status with no identity; presence starts only once setMember supplies one', async () => {
const { fetchImpl, calls } = makeFetch({ syncState: 'synced', publishedVersion: 5 });
const client = new CollabClient({ projectId: 'p1', member: null, fetch: fetchImpl });
client.start();
await vi.advanceTimersByTimeAsync(0);
expect(calls.some((c) => c.method === 'GET' && c.url.endsWith('/collab/status'))).toBe(true);
expect(calls.some((c) => c.url.endsWith('/presence/heartbeat'))).toBe(false);
expect(client.getSnapshot().publishedVersion).toBe(5);
expect(client.getSnapshot().syncState).toBe('synced');
client.setMember({ memberId: 'm1', name: 'Author' });
await vi.advanceTimersByTimeAsync(0);
const heartbeat = calls.find((c) => c.url.endsWith('/presence/heartbeat'));
expect(heartbeat?.method).toBe('POST');
expect(heartbeat?.body).toMatchObject({ memberId: 'm1', name: 'Author' });
client.stop();
});
it('does not send a leave POST on stop when no identity was ever supplied', async () => {
const { fetchImpl, calls } = makeFetch();
const client = new CollabClient({ projectId: 'p1', member: null, fetch: fetchImpl });
client.start();
await vi.advanceTimersByTimeAsync(0);
client.stop();
await vi.advanceTimersByTimeAsync(0);
expect(calls.some((c) => c.url.endsWith('/presence/leave'))).toBe(false);
});
it('leaveBeacon no-ops when no identity was ever supplied', () => {
const { fetchImpl, calls } = makeFetch();
const sendBeacon = vi.fn(() => true);
vi.stubGlobal('navigator', { sendBeacon });
const client = new CollabClient({ projectId: 'p1', member: null, fetch: fetchImpl });
client.leaveBeacon();
expect(sendBeacon).not.toHaveBeenCalled();
expect(calls.some((c) => c.url.endsWith('/presence/leave'))).toBe(false);
vi.unstubAllGlobals();
});
it('setMember(null) clears the identity and stops future heartbeats', async () => {
const { fetchImpl, calls } = makeFetch();
const client = new CollabClient({
projectId: 'p1',
member: { memberId: 'm1' },
fetch: fetchImpl,
heartbeatMs: 10_000,
});
client.start();
await vi.advanceTimersByTimeAsync(0);
expect(calls.some((c) => c.url.endsWith('/presence/heartbeat'))).toBe(true);
client.setMember(null);
const afterClear = calls.length;
await vi.advanceTimersByTimeAsync(10_000);
expect(calls.slice(afterClear).some((c) => c.url.endsWith('/presence/heartbeat'))).toBe(false);
client.stop();
});
it('does not re-heartbeat same-member metadata updates but announces a new identity', async () => {
const { fetchImpl, calls } = makeFetch({ syncState: 'synced' });
const client = new CollabClient({
projectId: 'p1',
member: { memberId: 'm1', filePath: 'index.html' },
fetch: fetchImpl,
});
client.start();
await vi.advanceTimersByTimeAsync(0);
const afterInitial = calls.filter(
(call) => call.url.endsWith('/presence/heartbeat'),
).length;
client.setMember({ memberId: 'm1', filePath: 'preview.html' });
await vi.advanceTimersByTimeAsync(0);
expect(
calls.filter((call) => call.url.endsWith('/presence/heartbeat')),
).toHaveLength(afterInitial);
client.setMember({ memberId: 'm2', filePath: 'preview.html' });
await vi.advanceTimersByTimeAsync(0);
const heartbeats = calls.filter(
(call) => call.url.endsWith('/presence/heartbeat'),
);
expect(heartbeats).toHaveLength(afterInitial + 1);
expect(heartbeats.at(-1)?.body).toMatchObject({ memberId: 'm2' });
client.stop();
});
});
});