Files
Codeman/test/sync-case-state.browser.test.ts
T
Devvyn SatieandClaude Sonnet 5.5 a50d0db283 fix(settings): harden case sync (privacy, concurrent saves, failures, enable/disable)
- Redact caseSettings for non-admins and never apply a shared map on a read-only page (CASE-SYNC-001)
- Send one case per save and merge it inside mutateSettingsFile, so devices cannot discard each other's cases; rapid saves keep their order (CASE-SYNC-002)
- Check HTTP status and the response body, keep the local choice, report an unsynced save and reuse the forbidden-write handling (CASE-SYNC-003)
- Let the server decide whether sync is on: refresh the cached switch, discard stale refreshes (CASE-SYNC-004)
- Carry shared options through case rename and drop them on delete or unlink
- Give the switch an accessible name; add server, client and two-browser tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 08:43:46 +08:00

88 lines
3.8 KiB
TypeScript

/**
* @fileoverview Case sync across two devices, end to end: a real server on an ephemeral port (isolated test
* HOME) and two separate Chromium contexts (each with its own localStorage, like two devices). Saves of
* different cases from both contexts both land, the other context takes them on returning to its tab, and
* once the server says sync is off the selected case stops following.
*/
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
import { getCasesDir } from '../src/config/cases-dir.js';
describe('Sync cases across devices in two browsers', () => {
let server: WebServer;
let browser: Browser;
let a: Page;
let b: Page;
const one = `sync-a-${process.pid}`;
const two = `sync-b-${process.pid}`;
const base = () => `http://localhost:${server.boundPort}`;
const put = (body: unknown) =>
fetch(`${base()}/api/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const serverSettings = async () => ((await (await fetch(`${base()}/api/settings`)).json()) as any).data;
const open = async () => {
const page = await (await browser.newContext({ viewport: { width: 1400, height: 900 } })).newPage();
await page.goto(base(), { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
return page;
};
const opts = (page: Page, name: string) => page.evaluate((n) => (window as any).app.getCaseSettings(n), name);
const returnToTab = (page: Page) =>
page.evaluate(async () => {
const app = (window as any).app;
await app._syncCaseStateFromServer();
});
beforeAll(async () => {
for (const name of [one, two]) mkdirSync(join(getCasesDir(), name), { recursive: true });
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
await put({ syncCaseState: true });
a = await open();
b = await open();
for (const page of [a, b]) {
await page.evaluate(async () => {
const app = (window as any).app;
await app.loadAppSettingsFromServer();
});
}
}, 60000);
afterAll(async () => {
await browser?.close();
await server?.stop();
}, 60000);
it("keeps both devices' saves and lets the other device take them", async () => {
await Promise.all([
a.evaluate((n) => (window as any).app.saveCaseSettings(n, { agentTeams: true, opusContext1m: false }), one),
b.evaluate((n) => (window as any).app.saveCaseSettings(n, { agentTeams: false, opusContext1m: false }), two),
]);
await expect
.poll(async () => Object.keys((await serverSettings()).caseSettings ?? {}).sort(), { timeout: 10000 })
.toEqual([one, two].sort());
await returnToTab(b);
await returnToTab(a);
expect(await opts(b, one)).toEqual({ agentTeams: true, opusContext1m: false });
expect(await opts(a, two)).toEqual({ agentTeams: false, opusContext1m: false });
});
it('follows the selected case while sync is on, and stops once the server says it is off', async () => {
await put({ lastUsedCase: two });
await returnToTab(a);
expect(await a.evaluate(() => (document.getElementById('quickStartCase') as HTMLSelectElement).value)).toBe(two);
await put({ syncCaseState: false, lastUsedCase: one });
await returnToTab(a);
expect(await a.evaluate(() => (document.getElementById('quickStartCase') as HTMLSelectElement).value)).toBe(two);
expect(await a.evaluate(() => (window as any).app.loadAppSettingsFromStorage().syncCaseState)).toBe(false);
});
});