1/**
2 * Tests for durable live-session state.
3 * Run with: node --test tests/live-session-store.test.mjs
4 */
5
6import { describe, it, beforeEach, afterEach } from 'node:test';
7import assert from 'node:assert/strict';
8import { mkdirSync, mkdtempSync, rmSync, appendFileSync, readFileSync } from 'node:fs';
9import { join } from 'node:path';
10import { tmpdir } from 'node:os';
11
12import { createLiveSessionStore } from '../skill/scripts/live-session-store.mjs';
13import {
14 getLegacyLiveSessionsDir,
15 getLiveAnnotationsDir,
16 getLiveSessionsDir,
17} from '../skill/scripts/impeccable-paths.mjs';
18
19describe('live-session-store', () => {
20 let tmp;
21
22 beforeEach(() => {
23 tmp = mkdtempSync(join(tmpdir(), 'impeccable-session-store-'));
24 });
25
26 afterEach(() => {
27 rmSync(tmp, { recursive: true, force: true });
28 });
29
30 it('rebuilds an active snapshot from an append-only journal after process restart', () => {
31 const store = createLiveSessionStore({ cwd: tmp, sessionId: 'session-a' });
32 store.appendEvent({
33 type: 'generate',
34 id: 'session-a',
35 action: 'polish',
36 count: 3,
37 pageUrl: 'http://localhost:4321/',
38 element: { outerHTML: '<section class="hero">Hero</section>', tagName: 'section' },
39 screenshotPath: join(getLiveAnnotationsDir(tmp), 'session-a.png'),
40 });
41 store.appendEvent({ type: 'variants_ready', id: 'session-a', file: 'src/pages/index.astro', arrivedVariants: 3 });
42 store.appendEvent({ type: 'accept_intent', id: 'session-a', variantId: 2, paramValues: { density: 'packed' } });
43
44 const restarted = createLiveSessionStore({ cwd: tmp, sessionId: 'session-a' });
45 const snapshot = restarted.getSnapshot('session-a');
46
47 assert.equal(snapshot.id, 'session-a');
48 assert.equal(snapshot.phase, 'accept_requested');
49 assert.equal(snapshot.expectedVariants, 3);
50 assert.equal(snapshot.arrivedVariants, 3);
51 assert.equal(snapshot.sourceFile, 'src/pages/index.astro');
52 assert.equal(snapshot.visibleVariant, 2);
53 assert.deepEqual(snapshot.paramValues, { density: 'packed' });
54 assert.equal(snapshot.annotationArtifacts[0].path.endsWith('session-a.png'), true);
55
56 const active = restarted.listActiveSessions();
57 assert.equal(
58 active.length,
59 1,
60 'event=live_session_store.active_restart actor=agent operation=list_active_sessions risk=server_restart_loses_live_state expected=one active session actual=' + active.length + ' suggestion=inspect journal replay and completed phase filtering',
61 );
62 assert.equal(active[0].id, 'session-a');
63 });
64
65 it('reports corrupted journal lines while preserving valid prior events', () => {
66 const store = createLiveSessionStore({ cwd: tmp, sessionId: 'corrupt-session' });
67 store.appendEvent({
68 type: 'generate',
69 id: 'corrupt-session',
70 action: 'layout',
71 count: 2,
72 element: { outerHTML: '<div>Card</div>', tagName: 'div' },
73 });
74
75 appendFileSync(join(getLiveSessionsDir(tmp), 'corrupt-session.jsonl'), '{not json}\n');
76
77 const restarted = createLiveSessionStore({ cwd: tmp, sessionId: 'corrupt-session' });
78 const snapshot = restarted.getSnapshot('corrupt-session');
79
80 assert.equal(snapshot.phase, 'generate_requested');
81 assert.equal(snapshot.expectedVariants, 2);
82 assert.equal(snapshot.diagnostics.length, 1);
83 assert.match(snapshot.diagnostics[0].error, /journal_parse_failed/);
84 });
85
86 it('does not duplicate parse diagnostics when valid entries follow a corrupted journal line', () => {
87 const dir = getLiveSessionsDir(tmp);
88 mkdirSync(dir, { recursive: true });
89 appendFileSync(join(dir, 'corrupt-then-valid.jsonl'), '{not json}\n');
90 appendFileSync(join(dir, 'corrupt-then-valid.jsonl'), JSON.stringify({
91 seq: 1,
92 id: 'corrupt-then-valid',
93 type: 'generate',
94 ts: new Date().toISOString(),
95 event: {
96 type: 'generate',
97 id: 'corrupt-then-valid',
98 action: 'polish',
99 count: 2,
100 element: { outerHTML: '<h1>Title</h1>', tagName: 'h1' },
101 },
102 }) + '\n');
103
104 const store = createLiveSessionStore({ cwd: tmp, sessionId: 'corrupt-then-valid' });
105 const snapshot = store.getSnapshot('corrupt-then-valid');
106 const parseDiagnostics = snapshot.diagnostics.filter((d) => d.error === 'journal_parse_failed');
107
108 assert.equal(snapshot.phase, 'generate_requested');
109 assert.equal(
110 parseDiagnostics.length,
111 1,
112 'event=live_session_store.duplicate_parse_diagnostic actor=store operation=journal_replay risk=duplicate_status_noise expected=1 actual=' + parseDiagnostics.length,
113 );
114 });
115
116 it('preserves zero-valued checkpoint revisions and empty explicit fields', () => {
117 const store = createLiveSessionStore({ cwd: tmp, sessionId: 'zero-checkpoint' });
118 store.appendEvent({
119 type: 'checkpoint',
120 id: 'zero-checkpoint',
121 revision: 0,
122 phase: '',
123 owner: '',
124 arrivedVariants: 0,
125 visibleVariant: 0,
126 paramValues: { density: 0 },
127 });
128
129 const snapshot = store.getSnapshot('zero-checkpoint');
130 assert.equal(
131 snapshot.checkpointRevision,
132 0,
133 'event=live_session_store.zero_checkpoint_revision actor=browser operation=checkpoint_replay risk=zero_revision_dropped expected=0 actual=' + snapshot.checkpointRevision,
134 );
135 assert.equal(snapshot.phase, '');
136 assert.equal(snapshot.activeOwner, '');
137 assert.equal(snapshot.visibleVariant, 0);
138 assert.deepEqual(snapshot.paramValues, { density: 0 });
139 });
140
141 it('ignores stale checkpoints and keeps the newest browser state', () => {
142 const store = createLiveSessionStore({ cwd: tmp, sessionId: 'checkpoint-session' });
143 store.appendEvent({
144 type: 'generate',
145 id: 'checkpoint-session',
146 action: 'layout',
147 count: 3,
148 element: { outerHTML: '<section>Hero</section>', tagName: 'section' },
149 });
150 store.appendEvent({ type: 'checkpoint', id: 'checkpoint-session', revision: 5, phase: 'cycling', visibleVariant: 3, paramValues: { density: 'packed' } });
151 store.appendEvent({ type: 'checkpoint', id: 'checkpoint-session', revision: 2, phase: 'cycling', visibleVariant: 1, paramValues: { density: 'airy' } });
152
153 const snapshot = store.getSnapshot('checkpoint-session');
154 assert.equal(snapshot.checkpointRevision, 5);
155 assert.equal(snapshot.visibleVariant, 3);
156 assert.deepEqual(snapshot.paramValues, { density: 'packed' });
157 assert.equal(
158 snapshot.diagnostics.some((d) => d.error === 'stale_checkpoint_ignored' && d.revision === 2),
159 true,
160 'event=live_session_store.stale_checkpoint actor=browser operation=checkpoint_replay risk=old_browser_state_overwrites_newer_choice expected=stale diagnostic actual=' + JSON.stringify(snapshot.diagnostics),
161 );
162 });
163
164 it('keeps carbonize-required accepted sessions active until explicit completion', () => {
165 const store = createLiveSessionStore({ cwd: tmp, sessionId: 'carbonize-session' });
166 store.appendEvent({
167 type: 'accept',
168 id: 'carbonize-session',
169 variantId: '2',
170 paramValues: { tone: 'sharp' },
171 });
172 store.appendEvent({ type: 'agent_done', id: 'carbonize-session', file: 'src/App.jsx', carbonize: true });
173
174 const snapshot = store.getSnapshot('carbonize-session');
175 assert.equal(
176 snapshot.phase,
177 'carbonize_required',
178 'event=live_session_store.carbonize_required actor=agent operation=accept_ack risk=carbonize_session_hidden_from_recovery expected=carbonize_required actual=' + snapshot.phase,
179 );
180 assert.equal(snapshot.sourceFile, 'src/App.jsx');
181 assert.equal(snapshot.pendingEvent, null);
182 assert.equal(store.listActiveSessions().some((s) => s.id === 'carbonize-session'), true);
183
184 store.appendEvent({ type: 'complete', id: 'carbonize-session' });
185 const completed = store.getSnapshot('carbonize-session', { includeCompleted: true });
186 assert.equal(completed.phase, 'completed');
187 assert.equal(store.listActiveSessions().some((s) => s.id === 'carbonize-session'), false);
188 });
189
190 it('clears pending events when an agent error is acknowledged', () => {
191 const store = createLiveSessionStore({ cwd: tmp, sessionId: 'error-session' });
192 store.appendEvent({
193 type: 'generate',
194 id: 'error-session',
195 action: 'polish',
196 count: 1,
197 element: { outerHTML: '<button>Try</button>', tagName: 'button' },
198 });
199 store.appendEvent({ type: 'agent_error', id: 'error-session', message: 'accept failed' });
200
201 const snapshot = store.getSnapshot('error-session');
202 assert.equal(snapshot.phase, 'agent_error');
203 assert.equal(snapshot.pendingEvent, null);
204 assert.equal(snapshot.pendingEventSeq, null);
205 assert.equal(
206 store.listActiveSessions()[0].pendingEvent,
207 null,
208 'event=live_session_store.agent_error_ack actor=agent operation=restart_replay risk=acknowledged_error_event_redelivered expected=null actual=' + JSON.stringify(store.listActiveSessions()[0].pendingEvent),
209 );
210 });
211
212 it('keeps completed sessions auditable but excludes them from active sessions by default', () => {
213 const store = createLiveSessionStore({ cwd: tmp, sessionId: 'done-session' });
214 store.appendEvent({
215 type: 'generate',
216 id: 'done-session',
217 action: 'bolder',
218 count: 1,
219 element: { outerHTML: '<h1>Title</h1>', tagName: 'h1' },
220 });
221 store.appendEvent({ type: 'agent_done', id: 'done-session', file: 'src/pages/index.astro' });
222 store.appendEvent({ type: 'complete', id: 'done-session' });
223
224 const active = store.listActiveSessions();
225 const completed = store.getSnapshot('done-session', { includeCompleted: true });
226
227 assert.equal(active.length, 0);
228 assert.equal(completed.phase, 'completed');
229 assert.equal(completed.sourceFile, 'src/pages/index.astro');
230 });
231
232 it('writes a rebuildable snapshot cache without making it authoritative', () => {
233 const store = createLiveSessionStore({ cwd: tmp, sessionId: 'cache-session' });
234 store.appendEvent({
235 type: 'generate',
236 id: 'cache-session',
237 action: 'colorize',
238 count: 2,
239 element: { outerHTML: '<div>Palette</div>', tagName: 'div' },
240 });
241
242 const snapshotPath = join(getLiveSessionsDir(tmp), 'cache-session.snapshot.json');
243 const cached = JSON.parse(readFileSync(snapshotPath, 'utf-8'));
244 assert.equal(cached.phase, 'generate_requested');
245
246 // Simulate stale snapshot cache. Restart must prefer journal truth and repair cache.
247 appendFileSync(snapshotPath, '');
248 const restarted = createLiveSessionStore({ cwd: tmp, sessionId: 'cache-session' });
249 restarted.appendEvent({ type: 'agent_done', id: 'cache-session', file: 'src/pages/index.astro' });
250 const repaired = JSON.parse(readFileSync(snapshotPath, 'utf-8'));
251
252 assert.equal(repaired.phase, 'variants_ready');
253 assert.equal(repaired.sourceFile, 'src/pages/index.astro');
254 });
255
256 it('recovers legacy journals from .impeccable-live/sessions', () => {
257 const legacyDir = getLegacyLiveSessionsDir(tmp);
258 mkdirSync(legacyDir, { recursive: true });
259 appendFileSync(join(legacyDir, 'legacy-session.jsonl'), JSON.stringify({
260 seq: 1,
261 id: 'legacy-session',
262 type: 'generate',
263 ts: new Date().toISOString(),
264 event: {
265 type: 'generate',
266 id: 'legacy-session',
267 action: 'polish',
268 count: 2,
269 element: { outerHTML: '<section>Legacy</section>', tagName: 'section' },
270 },
271 }) + '\n');
272
273 const store = createLiveSessionStore({ cwd: tmp, sessionId: 'legacy-session' });
274 const snapshot = store.getSnapshot('legacy-session');
275
276 assert.equal(snapshot.phase, 'generate_requested');
277 assert.equal(snapshot.expectedVariants, 2);
278 assert.equal(store.listActiveSessions().some((s) => s.id === 'legacy-session'), true);
279
280 store.appendEvent({ type: 'agent_done', id: 'legacy-session', file: 'src/App.jsx' });
281 const restarted = createLiveSessionStore({ cwd: tmp, sessionId: 'legacy-session' });
282 const migratedSnapshot = restarted.getSnapshot('legacy-session');
283 assert.equal(migratedSnapshot.phase, 'variants_ready');
284 assert.equal(migratedSnapshot.expectedVariants, 2);
285 assert.equal(migratedSnapshot.sourceFile, 'src/App.jsx');
286 });
287});