mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge #732: feat(settings): sync case options and selection across devices
This commit is contained in:
commit
4f29b0187a
23 files changed
+877
-37
No files matched your search
@@ -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}`] }] }),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user