live-session-store.test.mjs

  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});