mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-14 10:56:33 +00:00
* fix(ai): persist AI sessions per record in IndexedDB instead of a 2MB localStorage blob Closes #4123. The legacy store serialized every session into one localStorage JSON key under a 2MB byte budget. Each write re-serialized the whole store, and the degradation ladder dropped whole oldest sessions when the budget was exceeded — silently destroying capped chats (~1MB each) on every write. - add aiSessionIdbStorage: db netcatty_ai_sessions, one record per session (keyPath id) plus a meta store (schemaVersion, lastWriteAt, mirrored active-session map); BroadcastChannel replaces the localStorage storage event for cross-window sync - one-time boot migration from netcatty_ai_sessions_v1; the legacy key stays untouched for one release as a read-only fallback - writeSessionsForStorage now diffs against the last persisted set and writes only changed records through a serialized queue; the 2MB budget and its degradation ladder are removed (the global re-serialization cost they defended no longer exists) - retention becomes a pure session-count cap (raised 50 -> 1000); the per-session 200-message cap is unchanged - when the cap evicts sessions, a pre-eviction backup generation is written before the records are dropped, closing the #4018 mirror blind spot; explicit deletions and cloud-authoritative removals do not trigger it - boot restore chain: IndexedDB -> legacy localStorage (migration) -> #4018 disk backup generations; a corrupt/unavailable IndexedDB falls back to legacy localStorage writes - tests: IDB roundtrip/migration/active-map mirror via a minimal fake indexedDB, per-record put/delete diffing, count-cap eviction with pre-eviction generation, explicit-delete suppression, synced-history no-prune, corrupt-IDB fallback; byte-ladder tests removed with the ladder * test(ai): update fork/undo reload tests to the IndexedDB persistence contract Issue #4123: session persistence moved to IndexedDB per-record writes; the persisted set no longer lands in the legacy localStorage key (read-only fallback for one release) and the retention cap is a session count of 1000 instead of 50. - fork/undo reload tests read the persisted set via getPersistedAiSessionsSnapshot() instead of parsing localStorage - cap-boundary assertions (49/50 newer chats) become preservation assertions (newerCount + 2 records survive; the 1000-cap boundary is covered by aiStateSnapshots.test.ts) - the reload step seeds the read-only legacy store with the persisted set, the stand-in for what the migration persists on a profile without IndexedDB * feat(ai): make the per-session message cap user-configurable * fix(ai): sequence session sync after boot hydration Cloud-sync collection (collectAiSessionsForSync) could run before the IndexedDB boot hydration settled and writeSessionsForStorage() the stale legacy localStorage list over newer per-record IndexedDB data. The collection now skips the sync round until the boot load settles; the next auto-sync picks up the hydrated store. The hydration signal lives in aiStateSnapshots (isAiSessionsHydrationComplete / markAiSessionsHydrationComplete) and is shared with useAIState's boot effect and the cross-window refresh handler. * fix(ai): make the legacy session migration one-shot via a persisted marker The migration keyed on 'IndexedDB empty + legacy populated'. Because the legacy key is intentionally kept for one release as a read-only fallback, any boot whose store was empty re-ran the migration and resurrected chats the user had deleted (P1②). A migrationCompleted marker is now persisted in the meta store after the migration data is durable. Once present, the legacy key is never consulted again: an empty store is authoritative. The marker also records a store that was already populated by a pre-marker build, and an uninitialized empty store, so 'intentionally empty' is distinguishable from 'uninitialized'. * fix(ai): harden boot persistence failure paths Four review findings around the boot load's failure modes: - P2①: when IndexedDB fails at boot and sessions come from the legacy store, the diff baseline was seeded even though nothing was persisted, so a later edit diff-wrote only the edited record and the rest were lost after recovery. The legacy set is now persisted best-effort (one re-open retry) before seeding; if that fails the baseline stays unset so the next edit rewrites every record. - P2②: the active-pointer validation effect ran against the initially-empty in-memory session array and nulled every pointer before the async load finished. It now waits for the hydration signal. - P2③: when IndexedDB stayed unopenable and the legacy store was missing/corrupt, the unconditional recheck threw into the swallowing outer catch and the #4018 disk backup was never applied. The recheck failure is now contained (one retry already happens inside open()), the newest backup generation is applied as the recovery source, and the error is surfaced via console.warn. - P2④: a cross-window empty getAll (another window deleted the last chat) hit an early return, kept the deleted session alive locally and resurrected it on the next edit. After hydration an empty store is adopted; the early-return only covers the pre-hydration window. Scenario regression tests live in useAIState.bootHydration.test.ts; fakeIndexedDb gains failOpenTimes for counted open failures. * fix(ai): gate session maintenance and migrate legacy overflow history * fix(ai): skip backup restore of empty IDB and resume failed overflow migration * ci: retrigger lint-and-test after runner shutdown The previous Actions runner received a shutdown signal during plugin-contract verification; lint had already passed and unit tests never started. --------- Co-authored-by: GOUKI9999 <GOUKI9999@users.noreply.github.com> Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
490 lines
21 KiB
TypeScript
490 lines
21 KiB
TypeScript
/**
|
|
* Boot-scenario regression tests for the IndexedDB migration review fixes
|
|
* (issue #4125 review): P1 maintenance gating and complete legacy migration,
|
|
* P2① baseline seeding after an IndexedDB failure,
|
|
* P2② pointer validation gated on hydration, P2③ disk-backup restore when
|
|
* IndexedDB stays unopenable, and P2④ adopting an intentional cross-window
|
|
* deletion.
|
|
*/
|
|
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import React, { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { JSDOM } from 'jsdom';
|
|
import type { AISession } from '../../infrastructure/ai/types';
|
|
import {
|
|
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
|
STORAGE_KEY_AI_SESSIONS,
|
|
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
|
|
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
|
|
} from '../../infrastructure/config/storageKeys';
|
|
import { useAIState } from './useAIState';
|
|
import { getAISessionsSnapshot } from './aiSessionsStore';
|
|
import {
|
|
__flushIdbWritesForTests,
|
|
__resetAiSessionsHydrationForTests,
|
|
__resetBackupMirrorForTests,
|
|
__resetIdbPersistenceForTests,
|
|
getPersistedAiSessionsSnapshot,
|
|
flushBackupMirror,
|
|
latestAISessionsSnapshot,
|
|
setLatestAIActiveSessionMapSnapshot,
|
|
setLatestAIDraftsByScopeSnapshot,
|
|
setLatestAIPanelViewByScopeSnapshot,
|
|
setLatestAISessionsSnapshot,
|
|
} from './aiStateSnapshots';
|
|
import {
|
|
idbDeleteSessionIds,
|
|
AI_SESSIONS_META_MIGRATION_KEY,
|
|
idbPutSessions,
|
|
loadOrMigrateAiSessions,
|
|
openAiSessionsIdb,
|
|
resetAiSessionsIdbForTests,
|
|
} from './aiSessionIdbStorage';
|
|
import { installFakeIndexedDb } from './testSupport/fakeIndexedDb';
|
|
|
|
function makeSession(id: string, updatedAt: number): AISession {
|
|
return {
|
|
id,
|
|
title: id,
|
|
agentId: 'catty',
|
|
scope: { type: 'global' },
|
|
messages: [],
|
|
createdAt: updatedAt,
|
|
updatedAt,
|
|
} as AISession;
|
|
}
|
|
|
|
function installDom() {
|
|
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
|
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
|
|
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
|
}
|
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
|
return dom;
|
|
}
|
|
|
|
interface Mounted {
|
|
ai: ReturnType<typeof useAIState>;
|
|
root: ReturnType<typeof createRoot>;
|
|
container: HTMLDivElement;
|
|
}
|
|
|
|
async function mountAiState(onMaintenance?: (ai: ReturnType<typeof useAIState>) => void): Promise<Mounted> {
|
|
const holder: { ai?: ReturnType<typeof useAIState> } = {};
|
|
function Maintenance({ ai }: { ai: ReturnType<typeof useAIState> }) {
|
|
const mountAi = React.useRef(ai);
|
|
React.useEffect(() => { onMaintenance?.(mountAi.current); }, []);
|
|
return null;
|
|
}
|
|
function Harness() {
|
|
holder.ai = useAIState();
|
|
return onMaintenance ? React.createElement(Maintenance, { ai: holder.ai }) : null;
|
|
}
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
await act(async () => { root.render(React.createElement(Harness)); });
|
|
return { ai: holder.ai!, root, container };
|
|
}
|
|
|
|
async function unmount(mounted: Mounted, dom: JSDOM) {
|
|
await act(async () => { mounted.root.unmount(); });
|
|
mounted.container.remove();
|
|
dom.window.close();
|
|
}
|
|
|
|
function resetBootState() {
|
|
// Post-boot defaults first, then back to pending so every test starts
|
|
// from the pre-hydration state its own hook instance controls.
|
|
__resetIdbPersistenceForTests();
|
|
__resetAiSessionsHydrationForTests();
|
|
// Module-level snapshots leak across tests (the hook's initial state and
|
|
// the boot merge path read them) — reset to the cold-start state. The
|
|
// map snapshot must be null so the hook falls back to localStorage.
|
|
setLatestAISessionsSnapshot([]);
|
|
setLatestAIActiveSessionMapSnapshot(null as unknown as Record<string, string | null>);
|
|
setLatestAIDraftsByScopeSnapshot({});
|
|
setLatestAIPanelViewByScopeSnapshot({});
|
|
}
|
|
|
|
test.beforeEach(() => {
|
|
__resetBackupMirrorForTests();
|
|
resetBootState();
|
|
});
|
|
test.afterEach(() => { __resetBackupMirrorForTests(); });
|
|
|
|
test('P1: deleting the last migrated chat with no legacy key never restores a stale disk backup', async () => {
|
|
const dom = installDom();
|
|
const idb = installFakeIndexedDb();
|
|
const deleted = makeSession('deleted-chat', 1);
|
|
await loadOrMigrateAiSessions(() => JSON.stringify([deleted]));
|
|
await idbDeleteSessionIds(await openAiSessionsIdb(), [deleted.id]);
|
|
let backupReads = 0;
|
|
const backupWrites: string[] = [];
|
|
Object.assign(dom.window, {
|
|
netcatty: {
|
|
aiSessionsBackupReadLatest: async () => {
|
|
backupReads += 1;
|
|
return { ok: true, json: JSON.stringify([deleted]) };
|
|
},
|
|
aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
|
backupWrites.push(json);
|
|
return { ok: true };
|
|
},
|
|
},
|
|
});
|
|
const mounted = await mountAiState();
|
|
try {
|
|
flushBackupMirror();
|
|
await __flushIdbWritesForTests();
|
|
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS), null);
|
|
assert.equal(backupReads, 0);
|
|
assert.deepEqual(backupWrites, []);
|
|
assert.deepEqual(getAISessionsSnapshot().sessions, []);
|
|
assert.deepEqual(idb.db.dumpStore('sessions'), []);
|
|
} finally {
|
|
await unmount(mounted, dom);
|
|
idb.restore();
|
|
resetAiSessionsIdbForTests();
|
|
}
|
|
});
|
|
|
|
test('P1: an uninitialized IndexedDB still restores the disk backup', async () => {
|
|
const dom = installDom();
|
|
const idb = installFakeIndexedDb();
|
|
const backup = [makeSession('backup-chat', 1)];
|
|
Object.assign(dom.window, {
|
|
netcatty: { aiSessionsBackupReadLatest: async () => ({ ok: true, json: JSON.stringify(backup) }) },
|
|
});
|
|
const mounted = await mountAiState();
|
|
try {
|
|
await __flushIdbWritesForTests();
|
|
assert.deepEqual(getAISessionsSnapshot().sessions, backup);
|
|
assert.deepEqual(idb.db.dumpStore('sessions'), backup);
|
|
} finally {
|
|
await unmount(mounted, dom);
|
|
idb.restore();
|
|
resetAiSessionsIdbForTests();
|
|
}
|
|
});
|
|
|
|
for (const failure of ['not-ok', 'rejection', 'missing-id'] as const) {
|
|
test(`P1: disk overflow ${failure} keeps migration pending and backups intact, then retries beside primary IDB records`, async () => {
|
|
const dom = installDom();
|
|
const idb = installFakeIndexedDb();
|
|
const primary = makeSession('primary-chat', 3);
|
|
const chunk = makeSession('chunk-chat', 2);
|
|
const diskOnly = makeSession('disk-only-chat', 1);
|
|
const unmarked = makeSession('unmarked-deleted-chat', 0);
|
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([chunk]));
|
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
|
|
let recoveryAvailable = false;
|
|
let backupReads = 0;
|
|
const backupWrites: string[] = [];
|
|
Object.assign(dom.window, {
|
|
netcatty: {
|
|
aiSessionsBackupReadLatest: async () => {
|
|
backupReads += 1;
|
|
if (recoveryAvailable) return { ok: true, json: JSON.stringify([primary, chunk, diskOnly, unmarked]) };
|
|
if (failure === 'rejection') throw new Error('disk temporarily unavailable');
|
|
if (failure === 'missing-id') return { ok: true, json: JSON.stringify([unmarked]) };
|
|
return { ok: false };
|
|
},
|
|
aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
|
backupWrites.push(json);
|
|
return { ok: true };
|
|
},
|
|
},
|
|
});
|
|
let mounted = await mountAiState();
|
|
const hasMarker = () => idb.db.dumpStore('meta').some(entry => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY);
|
|
try {
|
|
assert.equal(backupReads, 1);
|
|
assert.equal(hasMarker(), false);
|
|
assert.deepEqual(getAISessionsSnapshot().sessions, [primary, chunk]);
|
|
assert.deepEqual(idb.db.dumpStore('sessions'), [primary, chunk]);
|
|
flushBackupMirror();
|
|
assert.deepEqual(backupWrites, [], 'boot must not mirror a partial migration');
|
|
|
|
// Ordinary edits and unload must also preserve the recovery generation.
|
|
await act(async () => {
|
|
mounted.ai.addMessageToSession(primary.id, { id: 'new-message', role: 'user', content: 'New edit', timestamp: 4 });
|
|
});
|
|
await new Promise(resolve => setTimeout(resolve, 600));
|
|
await __flushIdbWritesForTests();
|
|
dom.window.dispatchEvent(new dom.window.Event('beforeunload'));
|
|
assert.deepEqual(backupWrites, []);
|
|
const updated = getAISessionsSnapshot().sessions.find(session => session.id === primary.id)!;
|
|
assert.equal(updated.messages.length, 1);
|
|
assert.equal(hasMarker(), false);
|
|
|
|
await act(async () => { mounted.root.unmount(); });
|
|
mounted.container.remove();
|
|
resetBootState();
|
|
recoveryAvailable = true;
|
|
mounted = await mountAiState();
|
|
await __flushIdbWritesForTests();
|
|
assert.equal(backupReads, 2, 'populated IDB must still retry unresolved disk IDs');
|
|
assert.equal(hasMarker(), true);
|
|
const expected = [updated, chunk, diskOnly];
|
|
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
|
|
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
|
|
flushBackupMirror();
|
|
assert.equal(backupWrites.length, 1, 'mirroring resumes only after all marked IDs are recovered');
|
|
assert.deepEqual(JSON.parse(backupWrites[0]), expected);
|
|
} finally {
|
|
await unmount(mounted, dom);
|
|
idb.restore();
|
|
resetAiSessionsIdbForTests();
|
|
}
|
|
});
|
|
}
|
|
|
|
test('P1: child mount maintenance waits for hydration and marks the IndexedDB transcript dangling', async () => {
|
|
const dom = installDom();
|
|
const idb = installFakeIndexedDb();
|
|
const stale: AISession = {
|
|
...makeSession('closed-chat', 1),
|
|
scope: { type: 'terminal', targetId: 'closed-terminal' },
|
|
messages: [{ id: 'm-1', role: 'user', content: 'Old history', timestamp: 1 }],
|
|
};
|
|
const current: AISession = {
|
|
...stale,
|
|
updatedAt: 2,
|
|
messages: [...stale.messages, { id: 'm-2', role: 'assistant', content: 'New history', timestamp: 2 }],
|
|
};
|
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([stale]));
|
|
await idbPutSessions(await openAiSessionsIdb(), [current]);
|
|
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
|
let snapshotAfterMaintenance: AISession[] | null | undefined;
|
|
let putsAfterMaintenance = 0;
|
|
const mounted = await mountAiState((ai) => {
|
|
ai.cleanupOrphanedSessions(new Set(['live-terminal']));
|
|
snapshotAfterMaintenance = latestAISessionsSnapshot;
|
|
putsAfterMaintenance = idb.db.opCounts.get('sessions')!.put;
|
|
});
|
|
try {
|
|
assert.equal(snapshotAfterMaintenance, null, 'pending maintenance must not adopt the legacy transcript');
|
|
assert.equal(putsAfterMaintenance, 1, 'pending maintenance must not persist stale history');
|
|
await __flushIdbWritesForTests();
|
|
const expected = [{ ...current, scopeDangling: true }];
|
|
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
|
|
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
|
|
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS), JSON.stringify([stale]));
|
|
} finally {
|
|
await unmount(mounted, dom);
|
|
idb.restore();
|
|
resetAiSessionsIdbForTests();
|
|
}
|
|
});
|
|
|
|
test('P1: migration includes chunked overflow alongside a populated primary', async () => {
|
|
const dom = installDom();
|
|
const idb = installFakeIndexedDb();
|
|
const primary = makeSession('primary-chat', 3);
|
|
const overflow = [makeSession('overflow-a', 2), makeSession('overflow-b', 1), { ...primary, updatedAt: 0 }];
|
|
const overflowJson = JSON.stringify(overflow);
|
|
const split = Math.floor(overflowJson.length / 2);
|
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, overflowJson.slice(0, split));
|
|
dom.window.localStorage.setItem(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`, overflowJson.slice(split));
|
|
const mounted = await mountAiState();
|
|
try {
|
|
const expected = [primary, ...overflow.slice(0, 2)];
|
|
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
|
|
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
|
|
assert.ok(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY));
|
|
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW), overflowJson.slice(0, split));
|
|
assert.equal(dom.window.localStorage.getItem(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`), overflowJson.slice(split));
|
|
} finally {
|
|
await unmount(mounted, dom);
|
|
idb.restore();
|
|
resetAiSessionsIdbForTests();
|
|
}
|
|
});
|
|
|
|
test('P1: migration awaits disk-only recovery before recording completion beside a populated primary', async () => {
|
|
const dom = installDom();
|
|
const idb = installFakeIndexedDb();
|
|
const primary = makeSession('primary-chat', 3);
|
|
const diskOnly = makeSession('disk-only-chat', 2);
|
|
const deleted = makeSession('deleted-chat', 1);
|
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
|
|
let resolveBackup!: (result: { ok: boolean; json: string }) => void;
|
|
const backup = new Promise<{ ok: boolean; json: string }>((resolve) => { resolveBackup = resolve; });
|
|
let backupReads = 0;
|
|
Object.assign(dom.window, {
|
|
netcatty: { aiSessionsBackupReadLatest: () => { backupReads += 1; return backup; } },
|
|
});
|
|
const mounted = await mountAiState();
|
|
try {
|
|
assert.equal(backupReads, 1);
|
|
assert.equal(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY), false);
|
|
await act(async () => {
|
|
resolveBackup({ ok: true, json: JSON.stringify([primary, diskOnly, deleted]) });
|
|
});
|
|
assert.deepEqual(getAISessionsSnapshot().sessions, [primary, diskOnly]);
|
|
assert.deepEqual(idb.db.dumpStore('sessions'), [primary, diskOnly]);
|
|
assert.ok(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY));
|
|
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), JSON.stringify([diskOnly.id]));
|
|
} finally {
|
|
await unmount(mounted, dom);
|
|
idb.restore();
|
|
resetAiSessionsIdbForTests();
|
|
}
|
|
});
|
|
|
|
// ── P2①: idbFailed boot must persist the legacy set before seeding the baseline ──
|
|
|
|
test('P2①a: after a transient IndexedDB open failure the legacy sessions are persisted and the baseline seeded', async () => {
|
|
const dom = installDom();
|
|
const idb = installFakeIndexedDb({ failOpenTimes: 1 });
|
|
const legacy = [makeSession('chat-a', 2), makeSession('chat-b', 1)];
|
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify(legacy));
|
|
const mounted = await mountAiState();
|
|
try {
|
|
// One open retry happened inside the boot effect: the legacy set now
|
|
// lives in IndexedDB (old code never retried and left the store empty).
|
|
const ids = idb.db.dumpStore('sessions').map((s) => (s as AISession).id).sort();
|
|
assert.deepEqual(ids, ['chat-a', 'chat-b']);
|
|
assert.deepEqual(
|
|
getPersistedAiSessionsSnapshot().map((s) => s.id).sort(),
|
|
['chat-a', 'chat-b'],
|
|
);
|
|
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id).sort(), ['chat-a', 'chat-b']);
|
|
} finally {
|
|
await unmount(mounted, dom);
|
|
idb.restore();
|
|
resetAiSessionsIdbForTests();
|
|
}
|
|
});
|
|
|
|
test('P2①b: with IndexedDB unreachable the baseline stays unset, so a later edit rewrites every record', async () => {
|
|
const dom = installDom();
|
|
const failing = installFakeIndexedDb({ failOpen: true });
|
|
const legacy = [makeSession('chat-a', 2), makeSession('chat-b', 1)];
|
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify(legacy));
|
|
const mounted = await mountAiState();
|
|
let recovered: ReturnType<typeof installFakeIndexedDb> | null = null;
|
|
try {
|
|
// The retry open also failed: nothing was persisted, and the diff
|
|
// baseline must remain unset.
|
|
assert.deepEqual(failing.db.dumpStore('sessions'), []);
|
|
|
|
// IndexedDB recovers, then the user edits ONE session. The full set
|
|
// must reach the store, not only the edited record.
|
|
failing.restore();
|
|
resetAiSessionsIdbForTests();
|
|
recovered = installFakeIndexedDb();
|
|
await act(async () => {
|
|
mounted.ai.addMessageToSession('chat-a', { id: 'm-1', role: 'user', content: 'Edited', timestamp: 3 });
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 600)); // debounced persist
|
|
await act(async () => {});
|
|
await __flushIdbWritesForTests();
|
|
const ids = recovered.db.dumpStore('sessions').map((s) => (s as AISession).id).sort();
|
|
assert.deepEqual(ids, ['chat-a', 'chat-b']);
|
|
} finally {
|
|
await unmount(mounted, dom);
|
|
if (recovered) recovered.restore();
|
|
resetAiSessionsIdbForTests();
|
|
}
|
|
});
|
|
|
|
// ── P2②: pointer validation waits for hydration ──
|
|
|
|
test('P2②: pointers survive cold mount before hydration and stay valid after the boot load', async () => {
|
|
const dom = installDom();
|
|
const source = makeSession('chat-1', 1);
|
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([source]));
|
|
dom.window.localStorage.setItem(
|
|
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
|
JSON.stringify({ 'terminal:t1': 'chat-1' }),
|
|
);
|
|
const mounted = await mountAiState();
|
|
try {
|
|
// Old code nulled the pointer here: the validation effect ran against
|
|
// the initially-empty in-memory session array.
|
|
assert.equal(getAISessionsSnapshot().activeSessionIdMap['terminal:t1'], 'chat-1');
|
|
assert.equal(
|
|
JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)!)['terminal:t1'],
|
|
'chat-1',
|
|
);
|
|
// After hydration the session is restored and the pointer is intact.
|
|
await act(async () => {});
|
|
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['chat-1']);
|
|
assert.equal(getAISessionsSnapshot().activeSessionIdMap['terminal:t1'], 'chat-1');
|
|
} finally {
|
|
await unmount(mounted, dom);
|
|
}
|
|
});
|
|
|
|
// ── P2③: unopenable IndexedDB + corrupt legacy → disk backup is applied ──
|
|
|
|
test('P2③: with IndexedDB unopenable and the legacy store corrupt the newest disk backup generation is applied', async () => {
|
|
const dom = installDom();
|
|
installFakeIndexedDb({ failOpen: true });
|
|
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, 'not-json'); // corrupt legacy
|
|
const backup = [makeSession('backup-chat', 5)];
|
|
const warns: unknown[][] = [];
|
|
const originalWarn = console.warn;
|
|
console.warn = (...args: unknown[]) => { warns.push(args); };
|
|
try {
|
|
Object.assign(dom.window, {
|
|
netcatty: { aiSessionsBackupReadLatest: async () => ({ ok: true, json: JSON.stringify(backup) }) },
|
|
});
|
|
const mounted = await mountAiState();
|
|
try {
|
|
await act(async () => {});
|
|
// The backup generation became the recovery source (old code: the
|
|
// failing IndexedDB recheck rejection was swallowed and the restore
|
|
// never ran).
|
|
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['backup-chat']);
|
|
assert.deepEqual(getPersistedAiSessionsSnapshot().map((s) => s.id), ['backup-chat']);
|
|
// The unopenable-store error was surfaced, not swallowed.
|
|
assert.ok(warns.some((args) => String(args[0]).includes('IndexedDB recheck failed')));
|
|
} finally {
|
|
await unmount(mounted, dom);
|
|
}
|
|
} finally {
|
|
console.warn = originalWarn;
|
|
delete (dom.window as unknown as { netcatty?: unknown }).netcatty;
|
|
resetAiSessionsIdbForTests();
|
|
}
|
|
});
|
|
|
|
// ── P2④: an empty getAll after hydration is an intentional deletion ──
|
|
|
|
test('P2④: adopting an empty IndexedDB store after another window deleted the last chat', async () => {
|
|
const dom = installDom();
|
|
const idb = installFakeIndexedDb();
|
|
const source = makeSession('chat-1', 1);
|
|
await idbPutSessions(await openAiSessionsIdb(), [source]);
|
|
const mounted = await mountAiState();
|
|
try {
|
|
await act(async () => {});
|
|
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['chat-1']);
|
|
// Another window deletes the last chat and announces the change. The
|
|
// announcement must come from a second BroadcastChannel: a channel never
|
|
// delivers to itself (the module-level channel is the receiving window).
|
|
await idbDeleteSessionIds(await openAiSessionsIdb(), ['chat-1']);
|
|
const otherWindow = new BroadcastChannel('netcatty_ai_sessions_changed');
|
|
await act(async () => {
|
|
otherWindow.postMessage(Date.now());
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
});
|
|
otherWindow.close();
|
|
// Old code early-returned on the empty getAll and kept the deleted
|
|
// session alive locally.
|
|
assert.deepEqual(getAISessionsSnapshot().sessions, []);
|
|
assert.deepEqual(getPersistedAiSessionsSnapshot(), []);
|
|
} finally {
|
|
await unmount(mounted, dom);
|
|
idb.restore();
|
|
resetAiSessionsIdbForTests();
|
|
}
|
|
});
|