pcbjam/tests/web/presence-ghost-peer.spec.ts
Gergő Törcsvári 7a9aeb5181
presence: fix stale peer selections after delete, select-all lag, ghost-peer removal (findings group Y)
- wasm core: CORE::onDocChanged() from both collab listeners (local commit AND
  remote apply) repaints peers' shapes from the live document + re-checks the
  local selection post-settle; PresenceStart registers the bridge listener
- wasm core: cursors on their own overlay trio; shapes repaint only when the
  non-cursor snapshot changes; new kicadCollabSetRemoteCursors (cursor-only
  update, PEER.id) in both TUs + merged editor + JSPI mutator allowlist
- presence.ts: size-aware trailing throttle for cursor/viewport publishes
  (128 KB/s budget) + parsed-peers memo; presence-kicad.ts: cursor-only push
  when the shape signature is unchanged
- gateway.ts: honor the `gone` control (removeAwarenessStates)
- specs: kicad stale-after-delete gate, web ghost-peer timing (+diag), unit
  select-all budget (pcbjam-shared → 4573a7c)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AScTR39aqyrY5i3ZFHmnMn
2026-08-28 16:14:44 +02:00

124 lines
5.2 KiB
TypeScript

import { test, expect, type Page, type BrowserContext } from '@playwright/test';
import { openOverlayMenu } from './overlay-menu';
/**
* REPRO (findings: "when user A disconnects, user B still sees them minutes
* later"). Two users (separate browser contexts) on demo.kicad_wks over the
* real sync stack (VITE_YJS_PROVIDER=partykit → apps/sync gateway). alice's
* websocket is then broken in two ways and bob's roster is timed:
*
* - HALF-OPEN: alice's outbound frames are dropped but the socket stays up
* (laptop sleep / wifi drop before TCP notices). The gateway sees no close
* → no tombstone; bob is left with the 30s y-protocols awareness timeout.
* - UNCLEAN CLOSE: outbound dropped, then the socket is closed (no null-state
* broadcast reaches the server). Locally (wrangler, no hibernation) the
* gateway's in-memory clientID table tombstones alice; on a hibernated DO
* that table is gone (see gateway-hub-tombstone-hibernation.test.ts) and
* this degenerates into the half-open case.
*
* Requires the partykit/gateway stack (apps/sync :3055) — skipped on the BC
* provider, where there is no server to lose the departure.
*
* MEASURED 2026-08-28 (local wrangler, serial): half-open → 30s (y-protocols
* awareness timeout, the only fallback); unclean close → tombstone within
* ~10ms, roster clears immediately. The "minutes" ghost was NOT reproduced
* locally; see docs/features/findings/groups/Y-multiplayer-presence.md.
*/
// Both tests use the SAME demo project and the same user slugs, so they must
// never overlap: a parallel worker's alice keeps this worker's roster alive
// (that is exactly how the first run of this spec "reproduced" a 180s ghost).
test.describe.configure({ mode: 'serial' });
const SCOPE = 'default';
const ROUTE = 'demo.kicad_wks';
const TITLE = /demo — Drawing Sheet Editor/i;
const WS_HOOK = `
(() => {
const Orig = window.WebSocket;
const list = [];
window.__wsList = list;
window.__cutAll = false;
window.WebSocket = new Proxy(Orig, {
construct(target, args) {
const ws = new target(...args);
list.push({ ws, url: String(args[0]), t: Date.now() });
// Once cut, the "network" stays down: any reconnect attempt dies too.
if (window.__cutAll) { ws.send = () => {}; setTimeout(() => ws.close(), 0); }
return ws;
},
});
window.__wsCut = (close) => {
window.__cutAll = true;
let n = 0;
for (const { ws } of list) {
if (ws.readyState !== 1) continue;
ws.send = () => {};
n++;
if (close) ws.close();
}
return n;
};
window.__wsReport = () => list.map((w) => ({ url: w.url.slice(0, 50), state: w.ws.readyState, t: w.t }));
})();
`;
async function bootAs(context: BrowserContext, user: string): Promise<Page> {
const page = await context.newPage();
await page.addInitScript(WS_HOOK);
await page.goto(`/${SCOPE}/projects/demo/${ROUTE}?user=${user}`);
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
await expect
.poll(() => page.title(), { timeout: 120000, intervals: [1000] })
.toMatch(TITLE);
return page;
}
async function timeUntilGone(page: Page, user: string, budgetMs: number): Promise<number> {
const t0 = Date.now();
await expect(page.locator(`[data-presence-user="${user}"]`)).toHaveCount(0, {
timeout: budgetMs,
});
return Date.now() - t0;
}
for (const mode of ['half-open', 'unclean-close'] as const) {
test(`alice ${mode}: bob's roster drops alice promptly`, async ({ browser }) => {
test.setTimeout(420000);
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const alice = await bootAs(ctxA, 'alice');
const bob = await bootAs(ctxB, 'bob');
await openOverlayMenu(bob);
await expect(bob.locator('[data-presence-user="alice"]')).toBeVisible({ timeout: 30000 });
const wsCount = await alice.evaluate(
(close) => (window as unknown as { __wsCut(c: boolean): number }).__wsCut(close),
mode === 'unclean-close',
);
test.skip(wsCount === 0, 'no live websocket on alice — BC stack?');
const t0 = Date.now();
const seen: string[] = [];
let ms = -1;
while (Date.now() - t0 < 180000) {
const present = await bob.locator('[data-presence-user="alice"]').count();
seen.push(`+${Math.round((Date.now() - t0) / 1000)}s:${present}`);
if (present === 0) { ms = Date.now() - t0; break; }
await bob.waitForTimeout(5000);
}
const sockets = await alice.evaluate(() => (window as unknown as { __wsReport(): unknown }).__wsReport());
// eslint-disable-next-line no-console
console.log(`[ghost-peer] ${mode}: roster timeline ${seen.join(' ')} | alice sockets ${JSON.stringify(sockets)}`);
if (ms < 0) ms = Date.now() - t0;
test.info().annotations.push({ type: 'ghost-ms', description: `${mode}: ${ms}ms` });
// eslint-disable-next-line no-console
console.log(`[ghost-peer] ${mode}: bob dropped alice after ${ms}ms (ws cut: ${wsCount})`);
// "promptly" = inside the awareness timeout with slack; the roster spec
// budgets 20s for a clean leave.
expect(ms, `${mode}: alice lingered on bob's roster for ${ms}ms`).toBeLessThan(45000);
await ctxA.close();
await ctxB.close();
});
}