pcbjam/tests/web/presence-ghost-peer.spec.ts
Gergő Törcsvári c4bdaad7a0
staging CI green-up: P-1 apply-slot wedge under JSPI, popup P-4 regression, ysync double-seed header, spec fixes
- P-1 (findings-p.spec P-1c red on CI + locally): a JS exception thrown from a
  window.kicadCollab wire callback does NOT reach the C++ catch(...) — under
  JSPI it REJECTS the promising coroutine entry, slot.done never flips and the
  apply slot is busy for the page's life. Three layers: libcontext flags the
  rejected record dead (kicad → d0afbc4bd3),
  drainApplies() reaps a !done && !CanResume() body, and every EM_ASM
  window.kicadCollab.onX(...) call is try/catch-guarded (collab_common.h,
  kicad_editor/eeschema/pl_editor embind).
- popup.spec 'dismisses on outside click': wx P-4 SetFocus delegation excluded
  for wxPopupWindow (wxwidgets → a7436d1052).
- ysync-two-tab concurrent seed: repairLayoutY + seedNonce retraction deleted
  both headers (pcbjam-shared → 8dabf2e).
- dark-mode.spec: stale light-mode reference (pre wx fa46f1822c DOM
  re-projection) replaced with the CI chromium render.
- items-wire-batch-loss.spec: precondition obsolete after P-5 — poisoned entry
  is now synthetic, plus a P-5 contract assertion (field blob == '').
- presence-ghost-peer.spec: skip early when no /parties/ socket (CI web e2e
  runs the BroadcastChannel provider); findings-p1.spec: polled click-select
  precondition.

Local: kicad-chromium findings/presence/ysync/collab 59/59, popup 7/7,
dark-mode 1/1, pcbjam-shared vitest 249/249.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvvARNMxQrq17vogn3REMU
2026-08-28 22:25:21 +02:00

131 lines
5.9 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');
// Gate BEFORE the roster assertion: on the BroadcastChannel provider (CI's
// standalone preview has no apps/sync) two browser CONTEXTS never see each
// other, so bob would wait 30s for an alice that cannot arrive.
const liveWs = await alice.evaluate(
() => (window as unknown as { __wsList: Array<{ ws: WebSocket; url: string }> }).__wsList.filter((w) => w.ws.readyState === 1 && /\/parties\//.test(w.url)).length,
);
test.skip(liveWs === 0, 'no live websocket on alice — BC stack (no apps/sync), nothing to lose');
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); // eslint-disable-line -- documented sampling dwell: presence-eviction probe cadence inside an explicit bounded loop
}
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();
});
}