Merge #732: feat(settings): sync case options and selection across devices

This commit is contained in:
Codeman maintainer committed 2026-11-09 08:01:43 +01:00
commit 4f29b0187a
23 files changed
+877 -37

No files matched your search

+5 -1
View File
@@ -173,7 +173,11 @@ describe('createConfigBackup', () => {
'hostId',
],
['users.json', (n: number) => JSON.stringify({ users: [{ name: 'a', role: 'admin', lastLoginAt: n }] }), 'role'],
['settings.json', (n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}` }), 'displayName'],
[
'settings.json',
(n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}`, lastPickedCase: `case-${n}` }),
'displayName',
],
[
'intents.json',
(n: number) => JSON.stringify({ profiles: [{ key: 'k', goals: 'g', updatedAt: n, recentPrompts: [`p${n}`] }] }),
+34 -7
View File
@@ -302,7 +302,13 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
const seeded = {
caseOrder: ['a', 'proj', 'b'],
lastUsedCase: 'proj',
lastPickedCase: 'proj',
caseSettings: { proj: { agentTeams: true, opusContext1m: false } },
};
writeFileSync(SETTINGS_PATH, JSON.stringify(seeded));
// the registry reads fine but cannot be written: the rename must undo the folder move
const spy = failRegistryWrite();
let res;
@@ -316,7 +322,7 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
expect(linked()).toEqual({ proj: real });
expect(existsSync(join(work, 'renamed'))).toBe(false);
// settings.json is written first, so it must be put back too
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' });
expect(settings()).toEqual(seeded);
});
it('puts back only the keys it wrote, keeping a settings write made in between', async () => {
@@ -324,17 +330,38 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
// Another writer (a settings save) lands after the rename wrote settings.json and before the
// registry write fails.
const spy = failRegistryWrite(() => mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true })));
const entry = { agentTeams: true, opusContext1m: false };
writeFileSync(
SETTINGS_PATH,
JSON.stringify({
caseOrder: ['a', 'proj', 'b'],
lastUsedCase: 'proj',
lastPickedCase: 'proj',
caseSettings: { proj: entry },
})
);
// Another writer (a settings save, and a per-case save from another device) lands after the rename wrote
// settings.json and before the registry write fails.
const spy = failRegistryWrite(() =>
mutateSettingsFile((current) => ({
...current,
agentSkillEnabled: true,
caseSettings: { ...(current.caseSettings as object), other: { agentTeams: false, opusContext1m: true } },
}))
);
try {
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
} finally {
spy.mockRestore();
}
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj', agentSkillEnabled: true });
expect(settings()).toEqual({
caseOrder: ['a', 'proj', 'b'],
lastUsedCase: 'proj',
lastPickedCase: 'proj',
caseSettings: { proj: entry, other: { agentTeams: false, opusContext1m: true } },
agentSkillEnabled: true,
});
});
describe('when the registry changes after the first check', () => {
+4
View File
@@ -526,6 +526,10 @@ describe('the new toasts in zh-CN', () => {
'Saved on this device, not on the server: the server could not be reached',
'已保存到本设备,但未保存到服务器:无法连接到服务器',
],
[
'Options for "demo" (until the next sync) saved on this device, not on the server: HTTP 500',
'案例“demo”的选项已保存到本设备(同步前有效),但未保存到服务器:HTTP 500',
],
[`Tunnel not stopped: ${TORN}`, `隧道未停止:${TORN}`],
];
for (const [english, chinese] of cases) {
+138
View File
@@ -0,0 +1,138 @@
/**
* @fileoverview Opt-in case sync, server side (live server, ephemeral port, real settings.json): the shared
* per-case options merge inside the serialised settings writer, a non-admin never reads them, and a case
* rename or delete carries them along (CASE-SYNC-001, CASE-SYNC-002).
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
import { getCasesDir } from '../src/config/cases-dir.js';
import { settingsForNonAdmin } from '../src/web/routes/system-routes.js';
import {
dropCaseSettings,
MAX_SHARED_CASES,
mergeCaseSettings,
renameCaseSettings,
} from '../src/web/case-state-sync.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
const on = { agentTeams: true, opusContext1m: false };
const off = { agentTeams: false, opusContext1m: true };
let server: WebServer;
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
const seed = (value: unknown) => writeFileSync(SETTINGS_PATH, JSON.stringify(value, null, 2));
const readSettings = () => JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')) as Record<string, any>;
async function send(method: string, path: string, body?: unknown) {
const res = await fetch(url(path), {
method,
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: res.status, body: (await res.json()) as Record<string, any> };
}
let n = 0;
const caseName = () => `case-sync-${process.pid}-${++n}`;
beforeAll(async () => {
mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true });
seed({});
server = new WebServer(0, false, true);
await server.start();
}, 30000);
afterAll(async () => {
await server?.stop();
vi.restoreAllMocks();
}, 30000);
describe('shared case options', () => {
beforeEach(() => seed({ syncCaseState: true }));
it('merges concurrent saves of different cases instead of letting the second discard the first', async () => {
const [a, b, c] = await Promise.all([
send('PUT', '/api/settings', { caseSettings: { alpha: on } }),
send('PUT', '/api/settings', { caseSettings: { beta: off } }),
send('PUT', '/api/settings', { caseSettings: { gamma: on } }),
]);
expect([a.status, b.status, c.status]).toEqual([200, 200, 200]);
expect(readSettings().caseSettings).toEqual({ alpha: on, beta: off, gamma: on });
});
it('ignores a caseSettings PUT while syncCaseState is off, and takes one that turns it on', async () => {
seed({ syncCaseState: false, caseSettings: { kept: off } });
expect((await send('PUT', '/api/settings', { caseSettings: { alpha: on } })).status).toBe(200);
expect(readSettings().caseSettings).toEqual({ kept: off });
seed({});
await send('PUT', '/api/settings', { caseSettings: { alpha: on } });
expect(readSettings().caseSettings).toBeUndefined();
await send('PUT', '/api/settings', { syncCaseState: true, caseSettings: { alpha: on } });
expect(readSettings().caseSettings).toEqual({ alpha: on });
});
it('keeps the other cases when one is saved again, and every other setting', async () => {
seed({ syncCaseState: true, lastUsedCase: 'x', caseSettings: { alpha: on, beta: off } });
await send('PUT', '/api/settings', { caseSettings: { alpha: off } });
expect(readSettings()).toEqual({ syncCaseState: true, lastUsedCase: 'x', caseSettings: { alpha: off, beta: off } });
});
it('refuses a merge that takes the file past the limit and leaves it as it was', async () => {
const full = Object.fromEntries(Array.from({ length: MAX_SHARED_CASES }, (_, i) => [`c${i}`, on]));
seed({ syncCaseState: true, caseSettings: full });
const res = await send('PUT', '/api/settings', { caseSettings: { overflow: on } });
expect(res.status).toBe(400);
expect(Object.keys(readSettings().caseSettings)).toHaveLength(MAX_SHARED_CASES);
expect((await send('PUT', '/api/settings', { caseSettings: { c1: off } })).status).toBe(200);
});
it('moves the options with a renamed case, and drops them when the case is deleted', async () => {
const oldName = caseName();
const newName = caseName();
mkdirSync(join(getCasesDir(), oldName), { recursive: true });
seed({
syncCaseState: true,
caseOrder: [oldName],
lastPickedCase: oldName,
caseSettings: { [oldName]: on, keep: off },
});
const renamed = await send('POST', `/api/cases/${oldName}/rename`, { newName });
expect(renamed.body.success).toBe(true);
expect(readSettings().caseSettings).toEqual({ [newName]: on, keep: off });
expect(readSettings().lastPickedCase).toBe(newName);
const deleted = await send('DELETE', `/api/cases/${newName}`);
expect(deleted.body.success).toBe(true);
expect(readSettings().caseSettings).toEqual({ keep: off });
});
});
describe('what a non-admin is given', () => {
it('has the shared map removed, and keeps the switch the page reads', () => {
const out = settingsForNonAdmin({
syncCaseState: true,
caseOrder: ['a'],
lastUsedCase: 'a',
lastPickedCase: 'a',
caseSettings: { a: on },
agentSkillEnabled: true,
});
expect(out).toEqual({ syncCaseState: true, agentSkillEnabled: true });
});
});
describe('case map helpers', () => {
it('merge, rename and drop leave the input alone and report nothing to do as null', () => {
const map = { a: on, b: off };
expect(mergeCaseSettings(map, { c: on })).toEqual({ a: on, b: off, c: on });
expect(renameCaseSettings(map, 'a', 'z')).toEqual({ b: off, z: on });
expect(renameCaseSettings(map, 'nope', 'z')).toBeNull();
expect(dropCaseSettings(map, 'a')).toEqual({ b: off });
expect(dropCaseSettings(map, 'nope')).toBeNull();
expect(mergeCaseSettings(undefined, { a: on })).toEqual({ a: on });
expect(map).toEqual({ a: on, b: off });
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* @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({ lastPickedCase: two });
await returnToTab(a);
expect(await a.evaluate(() => (document.getElementById('quickStartCase') as HTMLSelectElement).value)).toBe(two);
await put({ syncCaseState: false, lastPickedCase: 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);
});
});
+331
View File
@@ -0,0 +1,331 @@
/**
* @fileoverview Opt-in case sync, client side: the schema accepts the setting and a per-case map, and the
* session-ui helpers only touch the server when the setting is on, send one case at a time, report a failed
* save, and obey the server's answer about whether sync is on. Evaluates the real session-ui.js.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const SRC = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
// The real write-result handling the case sync reuses (a toast for a failed save, read-only after a 403).
const SETTINGS_SRC = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf-8');
type Reply = { status?: number; body?: unknown } | 'throw';
type Handler = (url: string, init?: { method?: string; body?: string }) => Reply | Promise<Reply>;
function makeApp(settings: Record<string, unknown>, onFetch: Handler = () => ({ body: { success: true, data: {} } })) {
const store = new Map<string, string>();
const calls: { method: string; body: unknown }[] = [];
const toasts: string[] = [];
const select = { value: 'alpha' };
const hidden = new Set<string>();
const popover = (id: string) => ({ classList: { add: (c: string) => c === 'hidden' && void hidden.add(id) } });
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: {
getElementById: (id: string) => (id.startsWith('caseSettingsPopover') ? popover(id) : select),
addEventListener: () => {},
},
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
},
fetch: async (url: string, init?: { method?: string; body?: string }) => {
calls.push({ method: init?.method ?? 'GET', body: init?.body ? JSON.parse(init.body) : undefined });
const r = await onFetch(url, init);
if (r === 'throw') throw new Error('offline');
const status = r.status ?? 200;
const res = { ok: status < 400, status, json: async () => r.body ?? {}, clone: () => res };
return res;
},
console,
});
vm.runInContext(SRC, ctx);
vm.runInContext(SETTINGS_SRC, ctx);
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, any>;
app.loadAppSettingsFromStorage = () => settings;
app.saveAppSettingsToStorage = (s: Record<string, unknown>) => Object.assign(settings, s);
app.showToast = (m: string) => void toasts.push(m);
app.cases = [{ name: 'alpha' }, { name: 'beta' }];
app.updateDirDisplayForCase = () => {};
app.updateMobileCaseLabel = () => {};
app.updateCasePickerInput = () => {};
app.closeCasePicker = () => {};
return { app, store, calls, toasts, select, settings, hidden };
}
const settle = () => new Promise((r) => setTimeout(r, 0));
const entry = { agentTeams: true, opusContext1m: false };
describe('syncCaseState schema', () => {
it('accepts the setting with a per-case map, and rejects junk', () => {
expect(SettingsUpdateSchema.safeParse({ syncCaseState: true, caseSettings: { demo: entry } }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({ caseSettings: { demo: { agentTeams: 'yes' } } }).success).toBe(false);
expect(
SettingsUpdateSchema.safeParse({ caseSettings: { demo: { agentTeams: true, opusContext1m: true, x: 1 } } })
.success
).toBe(false);
});
});
describe('saving a case', () => {
it('stays on this browser while the setting is off', async () => {
const { app, store, calls } = makeApp({});
app.saveCaseSettings('demo', entry);
await settle();
expect(store.get('caseSettings_demo')).toContain('agentTeams');
expect(calls).toEqual([]);
});
it('sends only that case (no read of the server map first), and nothing when read-only', async () => {
const on = makeApp({ syncCaseState: true });
on.app.saveCaseSettings('demo', entry);
await settle();
expect(on.calls).toEqual([{ method: 'PUT', body: { caseSettings: { demo: entry } } }]);
const ro = makeApp({ syncCaseState: true });
ro.app._settingsReadOnly = () => true;
ro.app.saveCaseSettings('demo', entry);
await settle();
expect(ro.calls).toEqual([]);
});
it('sends rapid saves one after another, in the order they were made', async () => {
let release!: () => void;
const first = new Promise<void>((r) => (release = r));
let n = 0;
const { app, calls } = makeApp({ syncCaseState: true }, async () => {
if (++n === 1) await first;
return { body: { success: true, data: {} } };
});
app.saveCaseSettings('demo', { agentTeams: true, opusContext1m: true });
app.saveCaseSettings('demo', { agentTeams: false, opusContext1m: false });
await settle();
expect(calls).toHaveLength(1); // the second waits for the first
release();
await app._caseSyncChain;
expect(calls.map((c) => (c.body as any).caseSettings.demo.agentTeams)).toEqual([true, false]);
});
it.each([
['an HTTP 500', { status: 500, body: { success: false, error: 'boom' } }],
['a dropped connection', 'throw' as const],
])('keeps the local choice and says it was not synced after %s', async (_label, reply) => {
const { app, store, toasts } = makeApp({ syncCaseState: true }, () => reply as Reply);
app.saveCaseSettings('demo', entry);
await app._caseSyncChain;
expect(JSON.parse(store.get('caseSettings_demo')!)).toEqual(entry);
expect(toasts).toHaveLength(1);
expect(toasts[0]).toContain('not on the server');
expect(toasts[0]).toContain('until the next sync');
});
it('hands an admin-only refusal to the read-only handling, without a toast', async () => {
const forbidden = { status: 403, body: { success: false, errorCode: 'FORBIDDEN' } };
const { app, toasts } = makeApp({ syncCaseState: true }, () => forbidden);
app._noteSettingsWriteRefusal = async (res: { status: number }) => {
if (res.status !== 403) return false;
app._settingsWriteForbidden = true;
return true;
};
app._settingsReadOnly = () => app._settingsWriteForbidden === true;
app.saveCaseSettings('demo', entry);
await app._caseSyncChain;
expect(toasts).toEqual([]);
expect(app._caseStateSyncOn()).toBe(false); // nothing more is sent from this page
});
});
describe('recording the picked case', () => {
it('writes lastPickedCase with lastUsedCase only while sync is on', async () => {
const on = makeApp({ syncCaseState: true });
await on.app.saveLastUsedCase('beta');
expect(on.calls[0].body).toEqual({ lastUsedCase: 'beta', lastPickedCase: 'beta' });
const off = makeApp({});
await off.app.saveLastUsedCase('beta');
expect(off.calls[0].body).toEqual({ lastUsedCase: 'beta' });
});
});
describe('a pick that did not reach the server', () => {
it('is not undone by the next tab return, and following resumes once a pick is stored', async () => {
let putOk = false;
const { app, select } = makeApp({ syncCaseState: true }, (_url, init) =>
init?.method === 'PUT'
? putOk
? { body: { success: true } }
: { status: 500, body: { error: 'x' } }
: { body: { success: true, data: { syncCaseState: true, lastPickedCase: 'alpha' } } }
);
select.value = 'beta';
await app.saveLastUsedCase('beta'); // the pick fails
await app._syncCaseStateFromServer(); // the server still says alpha
expect(select.value).toBe('beta');
putOk = true;
await app.saveLastUsedCase('beta');
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
});
it('lands before the copy a tab return reads', async () => {
const order: string[] = [];
const { app } = makeApp({ syncCaseState: true }, async (_url, init) => {
order.push(init?.method ?? 'GET');
return { body: { success: true, data: { syncCaseState: true } } };
});
void app.saveLastUsedCase('beta');
await app._syncCaseStateFromServer();
expect(order).toEqual(['PUT', 'GET']);
});
});
describe('taking the server copy', () => {
const server = { syncCaseState: true, caseSettings: { demo: entry } };
it('writes the options into the local keys only while the server says sync is on', () => {
const a = makeApp({});
a.app._applyServerCaseSettings(server);
expect(JSON.parse(a.store.get('caseSettings_demo')!)).toEqual(entry);
const b = makeApp({});
b.app._applyServerCaseSettings({ ...server, syncCaseState: false });
expect(b.store.size).toBe(0);
});
it('never applies a map to a read-only identity, even one the server handed over', () => {
const { app, store } = makeApp({ syncCaseState: true });
app._settingsReadOnly = () => true;
app._applyServerCaseSettings(server);
expect(store.size).toBe(0);
});
it('leaves a case alone while this device is still saving it', async () => {
let release!: () => void;
const hold = new Promise<void>((r) => (release = r));
const { app, store } = makeApp({ syncCaseState: true }, async () => {
await hold;
return { body: { success: true, data: {} } };
});
app.saveCaseSettings('demo', { agentTeams: false, opusContext1m: false });
app._applyServerCaseSettings({ syncCaseState: true, caseSettings: { demo: entry, other: entry } });
expect(JSON.parse(store.get('caseSettings_demo')!)).toEqual({ agentTeams: false, opusContext1m: false });
expect(JSON.parse(store.get('caseSettings_other')!)).toEqual(entry);
release();
await app._caseSyncChain;
});
});
describe('returning to the tab', () => {
const env = (data: Record<string, unknown>) => ({ body: { success: true, data } });
it('follows the selected case and the options while sync is on', async () => {
const { app, select, store } = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: true, lastPickedCase: 'beta', caseSettings: { beta: entry } })
);
await app._syncCaseStateFromServer();
expect(select.value).toBe('beta');
expect(JSON.parse(store.get('caseSettings_beta')!)).toEqual(entry);
});
it('stops following when another device turned sync off, and the cached switch follows the server', async () => {
const { app, select, store, settings } = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: false, lastPickedCase: 'beta', caseSettings: { beta: entry } })
);
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
expect(store.size).toBe(0);
expect(settings.syncCaseState).toBe(false);
expect(app._caseStateSyncOn()).toBe(false);
});
it('does not follow a case a quick-start or an agent started, only one picked in a picker', async () => {
const { app, select } = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: true, lastUsedCase: 'beta', lastPickedCase: 'alpha' })
);
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
});
it('closes an open case-options popover when the follow or an import changes what it shows', async () => {
const followed = makeApp({ syncCaseState: true }, () => env({ syncCaseState: true, lastPickedCase: 'beta' }));
await followed.app._syncCaseStateFromServer();
expect([...followed.hidden].sort()).toEqual(['caseSettingsPopover', 'caseSettingsPopoverMobile']);
const imported = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: true, caseSettings: { alpha: entry } })
);
await imported.app._syncCaseStateFromServer();
expect(imported.hidden.size).toBe(2);
const unchanged = makeApp({ syncCaseState: true }, () => env({ syncCaseState: true, lastPickedCase: 'alpha' }));
await unchanged.app._syncCaseStateFromServer();
expect(unchanged.hidden.size).toBe(0);
});
it('does not undo a pick or a case save made while its read was out', async () => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const { app, select, store } = makeApp({ syncCaseState: true }, async (_url, init) => {
if (init?.method === 'PUT') return { body: { success: true, data: {} } };
await gate; // the server read this copy before the pick and the save below reached it
return env({
syncCaseState: true,
lastPickedCase: 'alpha',
caseSettings: { beta: { agentTeams: false, opusContext1m: true } },
});
});
const refresh = app._syncCaseStateFromServer();
await settle();
select.value = 'beta';
await app.saveLastUsedCase('beta');
app.saveCaseSettings('beta', entry);
await app._caseSyncChain;
release();
await refresh;
expect(select.value).toBe('beta');
expect(JSON.parse(store.get('caseSettings_beta')!)).toEqual(entry);
});
it('asks for nothing on a tab return while this device has sync off', async () => {
const { app, select, calls } = makeApp({}, () => env({ syncCaseState: true, lastUsedCase: 'beta' }));
await app._syncCaseStateFromServer();
expect(calls).toEqual([]);
expect(select.value).toBe('alpha');
});
it('does nothing for a read-only identity', async () => {
const { app, calls } = makeApp({ syncCaseState: true });
app._settingsReadOnly = () => true;
await app._syncCaseStateFromServer();
expect(calls).toEqual([]);
});
it('keeps what it has when the request fails', async () => {
const { app, select } = makeApp({ syncCaseState: true }, () => ({ status: 500, body: { error: 'x' } }));
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
});
it('discards an older refresh that answers after a newer one', async () => {
const releases: (() => void)[] = [];
const answers = [
env({ syncCaseState: true, lastPickedCase: 'beta' }), // the older request
env({ syncCaseState: true, lastPickedCase: 'alpha' }), // the newer request
];
let i = 0;
const { app, select } = makeApp({ syncCaseState: true }, async () => {
const mine = answers[i++];
await new Promise<void>((r) => releases.push(r));
return mine;
});
const older = app._syncCaseStateFromServer();
await settle();
const newer = app._syncCaseStateFromServer();
await settle();
releases[1](); // the newer answers first
await newer;
releases[0](); // then the older one arrives late
await older;
expect(select.value).toBe('alpha');
});
});