From dfe023d49dc2244b36b400ce4b2bb1c2a90ba315 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 09:35:03 +0200 Subject: [PATCH] test(story-mode): frontend overlay test in JSDOM, every scene drawn in every shape Runs the real story-mode.js against pages the real server core builds from invented transcripts: panels, captions, balloons, the turn picker, the Director's cut wire, file and diff clicks with their line numbers, output links through the terminal's own path matcher, a live refresh that keeps existing panels' clocks, activity filtering, Escape one layer per press, teardown, error notes and the setting switch. A permissive fake 2D context lets every scene draw every panel kind across its whole animation, at four sizes, in both reading levels, without throwing. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/story-mode-ui.test.ts | 464 +++++++++++++++++++++++++++++++++++++ 1 file changed, 464 insertions(+) create mode 100644 test/story-mode-ui.test.ts diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts new file mode 100644 index 00000000..81ef09c0 --- /dev/null +++ b/test/story-mode-ui.test.ts @@ -0,0 +1,464 @@ +/** + * @fileoverview Frontend test for story-mode.js (Story Mode overlay). Builds a JSDOM window in-test + * (like admin-ui.test.ts), evaluates the real module, and feeds it pages built by the real server + * core from invented transcripts (test/story-fixtures.ts). Canvas 2D is a permissive fake, so every + * scene's drawing code runs: a scene that throws for some panel shape fails here, not in a browser. + */ + +import { readFileSync } from 'node:fs'; +import { JSDOM } from 'jsdom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildStoryPage, buildStoryTurns, createStoryReducer, summarizeTurns } from '../src/story-mode.js'; +import type { StoryPage, StoryPanel } from '../src/types/story.js'; +import { bash, demoTurn, end, prompt, read, resetClock, said } from './story-fixtures.js'; + +const STORY_JS = readFileSync(new URL('../src/web/public/story-mode.js', import.meta.url), 'utf-8'); +// The real path matcher (findFilePathLinks / resolveLinkedFilePath) the terminal uses. +const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8'); +const SID = 'session-0001-abcdef'; + +type Win = Window & typeof globalThis & Record; // eslint-disable-line @typescript-eslint/no-explicit-any + +function storyOf(lines: unknown[]) { + const reducer = createStoryReducer(); + const turns = buildStoryTurns(lines.flatMap((l) => reducer.reduce(l))); + return { + supported: true, + turns: summarizeTurns(turns), + page: turns.length ? buildStoryPage(turns[turns.length - 1]) : null, + }; +} + +/** A 2D context that accepts every call; measureText answers a plausible width. */ +function fakeContext() { + const target: Record = { + measureText: (text: string) => ({ width: String(text).length * 6 }), + }; + return new Proxy(target, { + get(t, key) { + if (key in t) return t[key as string]; + return () => undefined; + }, + set(t, key, value) { + t[key as string] = value; + return true; + }, + }); +} + +let dom: JSDOM; +let win: Win; +let app: Record; // eslint-disable-line @typescript-eslint/no-explicit-any +let responses: Array<{ status: number; body: unknown }>; +let calls: string[]; + +function boot(initial: unknown) { + dom = new JSDOM( + ` + +
+
+ `, + { url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true } + ); + win = dom.window as unknown as Win; + win.HTMLCanvasElement.prototype.getContext = (() => fakeContext()) as never; + win.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as never; + win.IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as never; + win.matchMedia = ((q: string) => ({ + matches: false, + media: q, + addEventListener() {}, + removeEventListener() {}, + })) as never; + win.eval(CONSTANTS_JS); + win.eval('class CodemanApp {}; window.CodemanApp = CodemanApp;'); + win.eval(STORY_JS); + responses = [{ status: 200, body: { success: true, data: initial } }]; + calls = []; + app = Object.create(win.CodemanApp.prototype); + Object.assign(app, { + activeSessionId: SID, + sessions: new Map([[SID, { name: 'demo-shop', workingDir: '/work/demo-shop' }]]), + loadAppSettingsFromStorage: () => ({ showStoryButton: true }), + getDefaultSettings: () => ({}), + showToast: vi.fn(), + openFilePreview: vi.fn(), + closeFilePreview: vi.fn(() => win.document.getElementById('filePreviewOverlay')!.classList.remove('visible')), + filePreviewEdit: null, + _api: async (path: string) => { + calls.push(path); + const next = responses.length > 1 ? responses.shift()! : responses[0]; + return { ok: next.status < 300, status: next.status, headers: { get: () => null }, json: async () => next.body }; + }, + }); +} + +const settle = async (n = 6) => { + for (let i = 0; i < n; i++) await new Promise((r) => setTimeout(r, 0)); +}; +const $ = (sel: string) => win.document.querySelector(sel) as HTMLElement | null; +const $$ = (sel: string) => [...win.document.querySelectorAll(sel)] as HTMLElement[]; +/** Panels have no layout in JSDOM; give them a size so scenes draw and click regions exist. */ +function sizeAndDraw() { + for (const rt of app._story.panels) { + rt.w = 600; + rt.h = 270; + rt.dpr = 1; + rt.canvas.width = 600; + rt.canvas.height = 270; + rt.bgKey = ''; + app._storyDrawPanel(rt); + } +} +const press = (key: string) => + win.dispatchEvent(new win.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + +beforeEach(() => resetClock()); +afterEach(() => { + app?.closeStoryMode?.(); + dom?.window.close(); +}); + +describe('story-mode.js overlay', () => { + it('draws the newest turn: panels, captions, balloons, turn picker', async () => { + boot(storyOf([prompt('first job'), said('Done.'), end(2000), ...demoTurn()])); + app.openStoryMode(SID); + await settle(); + expect(calls).toEqual([`/api/sessions/${SID}/story`]); + expect($$('.story-panel').map((p) => p.dataset.kind)).toEqual([ + 'mission', + 'investigate', + 'plan', + 'backup', + 'loop', + 'save', + 'complete', + ]); + expect($('.story-panel[data-kind="mission"] .story-balloon')!.textContent).toBe( + 'The checkout button does nothing on phones. Can you fix it?' + ); + expect($('.story-panel[data-kind="mission"] .story-balloon')!.className).toContain('story-balloon--you'); + expect($('.story-panel[data-kind="investigate"] .story-cap-story')!.textContent).toBe( + 'Claude reads 2 files to find its way around.' + ); + expect($('.story-splash-title')!.textContent).toBe('Mission complete!'); + expect($$('.story-turns option').map((o) => o.textContent)).toEqual([ + '1. first job', + '2. The checkout button does nothing on phones. Can you fix it?', + ]); + expect(($('.story-prev') as HTMLButtonElement).disabled).toBe(false); + expect(($('.story-next') as HTMLButtonElement).disabled).toBe(true); + expect($('.story-bar .story-live')!.hidden).toBe(true); + expect($('.story-pencils')).toBeNull(); + expect($('.story-note')!.hidden).toBe(true); + }); + + it("the Director's cut shows one wire row per event and the tool captions", async () => { + const data = storyOf(demoTurn()); + boot(data); + app.openStoryMode(SID); + await settle(); + app._storySetDirector(true); + expect($('.story-overlay')!.classList.contains('is-director')).toBe(true); + expect($$('.story-ev')).toHaveLength((data.page as StoryPage).events.length); + expect($$('.story-wire-group')[0].textContent).toBe('Panel 1 · The mission'); + expect($('.story-panel[data-kind="investigate"] .story-cap-dir')!.textContent).toBe('Read ×2 · Bash'); + }); + + it('a file card opens the file preview; the code page opens the diff with real line numbers', async () => { + boot(storyOf(demoTurn())); + app.openStoryMode(SID); + await settle(); + sizeAndDraw(); + const card = $$('.story-panel[data-kind="investigate"] .story-hit').find( + (b) => b.getAttribute('aria-label') === 'Open CheckoutButton.tsx' + ); + expect(card).toBeTruthy(); + card!.click(); + expect(app.openFilePreview).toHaveBeenCalledWith('/work/demo-shop/src/checkout/CheckoutButton.tsx', SID); + + const page = $$('.story-panel[data-kind="loop"] .story-hit').find( + (b) => b.getAttribute('aria-label') === "See Claude's changes" + ); + page!.click(); + expect($('.story-drawer')!.hidden).toBe(false); + expect($('.story-drawer-kind')!.textContent).toBe('Diff'); + expect($$('.story-df')).toHaveLength(2); + // The first hunk starts at line 10 in both files: context 10/10, an added line at new 11, context 11/12. + const rows = $$('.story-df')[0].querySelectorAll('.story-ln:not(.story-ln--hunk)'); + expect([...rows].map((r) => [...r.querySelectorAll('.story-ln-no')].map((n) => n.textContent))).toEqual([ + ['10', '10'], + ['', '11'], + ['11', '12'], + ]); + ($$('.story-df')[0].querySelector('.story-btn-s') as HTMLElement).click(); + expect(app.openFilePreview).toHaveBeenLastCalledWith('/work/demo-shop/src/styles/banner.css', SID); + }); + + it('the test board opens the output, with paths in it as links', async () => { + boot( + storyOf([ + prompt('go'), + ...bash('b1', 'npm test', 'Run tests', 'FAIL src/a.test.ts:12\n Tests 1 failed | 3 passed (4)', true), + end(1000), + ]) + ); + app.openStoryMode(SID); + await settle(); + sizeAndDraw(); + $('.story-panel[data-kind="check"] .story-hit')!.click(); + expect($('.story-drawer-kind')!.textContent).toBe('Output'); + const link = $('.story-drawer .story-pre .story-link'); + expect(link!.textContent).toBe('src/a.test.ts'); + link!.click(); + expect(app.openFilePreview).toHaveBeenLastCalledWith('/work/demo-shop/src/a.test.ts', SID); + }); + + it('a live refresh adds panels with an entrance and keeps the clocks of the ones already there', async () => { + const lines = [prompt('go'), ...read('r1', '/w/a.ts')]; + boot(storyOf(lines)); + app.openStoryMode(SID); + await settle(); + expect($$('.story-panel').map((p) => p.dataset.kind)).toEqual(['mission', 'investigate']); + expect($('.story-pencils')).toBeTruthy(); + expect($('.story-bar .story-live')!.hidden).toBe(false); + const first = app._story.panels[1]; + first.clock = 500; + responses = [ + { + status: 200, + body: { + success: true, + data: storyOf([...lines, ...bash('b1', 'npm test', 'Run tests', ' Tests 4 passed (4)'), end(9000)]), + }, + }, + ]; + await app._storyRefresh(); + await settle(); + expect($$('.story-panel').map((p) => p.dataset.kind)).toEqual(['mission', 'investigate', 'check', 'complete']); + expect(app._story.panels[1]).toBe(first); + expect(first.clock).toBeGreaterThanOrEqual(500); // kept (and still running), never restarted + expect($('.story-panel[data-kind="check"]')!.classList.contains('story-fresh')).toBe(true); + expect($('.story-panel[data-kind="investigate"]')!.classList.contains('story-fresh')).toBe(false); + expect($('.story-pencils')).toBeNull(); + }); + + it('activity for this session refreshes, activity for another does not', async () => { + vi.useFakeTimers(); + try { + boot(storyOf(demoTurn())); + app.openStoryMode(SID); + await vi.advanceTimersByTimeAsync(10); + calls.length = 0; + app._storyOnActivity({ data: JSON.stringify({ sessionId: 'someone-else' }) }); + await vi.advanceTimersByTimeAsync(1000); + expect(calls).toEqual([]); + app._storyOnActivity({ data: JSON.stringify({ sessionId: SID }) }); + app._storyOnActivity({ data: JSON.stringify({ sessionId: SID }) }); + await vi.advanceTimersByTimeAsync(1000); + expect(calls).toEqual([`/api/sessions/${SID}/story`]); + } finally { + vi.useRealTimers(); + } + }); + + it('Escape closes one layer per press: file preview, then drawer, then the overlay', async () => { + boot(storyOf(demoTurn())); + app.openStoryMode(SID); + await settle(); + sizeAndDraw(); + $$('.story-panel[data-kind="loop"] .story-hit')[0].click(); + $('#filePreviewOverlay')!.classList.add('visible'); + press('Escape'); + expect(app.closeFilePreview).toHaveBeenCalledTimes(1); + expect($('.story-drawer')!.hidden).toBe(false); + press('Escape'); + expect($('.story-drawer')!.hidden).toBe(true); + expect($('#storyOverlay')!.hidden).toBe(false); + press('Escape'); + expect($('#storyOverlay')!.hidden).toBe(true); + expect(app._story.open).toBe(false); + }); + + it('leaves Escape to a folder grid on top and to a preview being edited', async () => { + boot(storyOf(demoTurn())); + app.openStoryMode(SID); + await settle(); + $('#folderGridOverlay')!.classList.add('visible'); + press('Escape'); + expect($('#storyOverlay')!.hidden).toBe(false); + $('#folderGridOverlay')!.classList.remove('visible'); + $('#filePreviewOverlay')!.classList.add('visible'); + app.filePreviewEdit = { dirty: true }; + press('Escape'); + expect(app.closeFilePreview).not.toHaveBeenCalled(); + expect($('#storyOverlay')!.hidden).toBe(false); + }); + + it('closing stops listening: no Escape handling and no refresh after close', async () => { + vi.useFakeTimers(); + try { + boot(storyOf([prompt('go'), ...read('r1', '/w/a.ts')])); + app.openStoryMode(SID); + await vi.advanceTimersByTimeAsync(10); + app.closeStoryMode(); + calls.length = 0; + await vi.advanceTimersByTimeAsync(20_000); + expect(calls).toEqual([]); + expect($$('.story-panel')).toHaveLength(0); + app._storyOnActivity({ data: JSON.stringify({ sessionId: SID }) }); + await vi.advanceTimersByTimeAsync(2000); + expect(calls).toEqual([]); + } finally { + vi.useRealTimers(); + } + }); + + it('says why when a session cannot be drawn, or the request fails', async () => { + boot({ supported: false, reason: 'Story Mode draws Claude Code sessions. This session runs a different CLI.' }); + app.openStoryMode(SID); + await settle(); + expect($('.story-note')!.textContent).toMatch(/different CLI/); + expect($$('.story-panel')).toHaveLength(0); + responses = [{ status: 404, body: { success: false, error: 'Session not found' } }]; + await app._storyRefresh(); + expect($('.story-note')!.textContent).toBe('Session not found'); + }); + + it('turning the setting off hides the button and closes an open story', async () => { + boot(storyOf(demoTurn())); + app.openStoryMode(SID); + await settle(); + app.loadAppSettingsFromStorage = () => ({ showStoryButton: false }); + app.applyStoryButtonVisibility(); + expect($('.btn-story-header')!.classList.contains('btn-story-header--hidden')).toBe(true); + expect($('#storyOverlay')!.hidden).toBe(true); + }); +}); + +describe('story-mode.js scenes', () => { + /** Every panel kind, in plain and awkward shapes, drawn across its whole animation. */ + function pageWith(panels: StoryPanel[], extra: Partial = {}): StoryPage { + const base = storyOf(demoTurn()).page as StoryPage; + return { ...base, ...extra, panels }; + } + const cap = { story: 's', director: 'd' }; + + it('no scene throws, whatever the panel holds', async () => { + const base = storyOf(demoTurn()).page as StoryPage; + const manyFiles = Array.from( + { length: 20 }, + (_, i) => `/w/src/a/very/deep/path/file${i}WithALongCamelCaseName.test.tsx` + ); + const shapes: StoryPanel[] = [ + { kind: 'mission', events: [0], caption: cap, balloon: { who: 'you', text: 'x'.repeat(500) } }, + { kind: 'investigate', events: [], caption: cap, files: [] }, + { kind: 'investigate', events: [], caption: cap, files: manyFiles, notes: ['grep -rn x'] }, + { kind: 'investigate', events: [], caption: cap, files: ['/w/a'] }, + { + kind: 'research', + events: [], + caption: cap, + notes: ['https://example.com/a/very/long/url/' + 'x'.repeat(200), 'q'], + }, + { kind: 'research', events: [], caption: cap }, + { + kind: 'plan', + events: [], + caption: cap, + todos: Array.from({ length: 12 }, (_, i) => ({ text: `step ${i} ` + 'y'.repeat(80), done: i % 2 === 0 })), + }, + { kind: 'plan', events: [], caption: cap, notes: [] }, + { kind: 'backup', events: [], caption: cap, helpers: [] }, + { + kind: 'backup', + events: [], + caption: cap, + helpers: Array.from({ length: 6 }, (_, i) => ({ + name: `h${i}`, + type: 'Explore', + task: 'z'.repeat(90), + event: 0, + })), + }, + { kind: 'fix', events: [], caption: cap, files: [], added: 0, removed: 0 }, + { + kind: 'fix', + events: base.panels.find((p) => p.kind === 'loop')!.events, + caption: cap, + files: manyFiles, + added: 999, + removed: 999, + }, + { kind: 'check', events: [], caption: cap }, + { kind: 'check', events: [], caption: cap, ok: true, tests: { passed: 5000, failed: 0 } }, + { kind: 'check', events: [], caption: cap, ok: false, tests: { passed: 0, failed: 3 } }, + { kind: 'check', events: [], caption: cap, ok: false }, + { kind: 'loop', events: [], caption: cap, rounds: 7, ok: false }, + { kind: 'loop', events: base.panels.find((p) => p.kind === 'loop')!.events, caption: cap, rounds: 2, ok: true }, + { kind: 'run', events: [], caption: cap, notes: [] }, + { kind: 'run', events: [], caption: cap, notes: Array.from({ length: 10 }, () => 'w'.repeat(300)) }, + { kind: 'save', events: [], caption: cap }, + { kind: 'ask', events: [], caption: cap, question: { text: 'Which one?', answer: 'The first' } }, + { kind: 'compact', events: [], caption: cap }, + { kind: 'montage', events: [], caption: cap, notes: ['a'] }, + { kind: 'complete', events: [], caption: cap, balloon: { who: 'claude', text: 'Done.' } }, + ]; + for (const interrupted of [false, true]) { + boot({ supported: true, turns: [], page: pageWith(shapes, { interrupted, helpers: interrupted ? 0 : 3 }) }); + app.openStoryMode(SID); + await settle(); + expect($$('.story-panel')).toHaveLength(shapes.length); + for (const director of [false, true]) { + app._storySetDirector(director); + for (const frame of [0, 1, 15, 30, 45, 72, 200, 1000]) { + for (const rt of app._story.panels) { + rt.clock = frame; + for (const [w, h] of [ + [600, 270], + [300, 300], + [900, 270], + [220, 430], + ]) { + rt.w = w; + rt.h = h; + rt.dpr = 1; + rt.bgKey = ''; + expect(() => app._storyDrawPanel(rt), `${rt.kind} at frame ${frame}, ${w}x${h}`).not.toThrow(); + } + } + } + } + app.closeStoryMode(); + } + }); +}); + +describe('story-mode.js layout', () => { + it('fills every 6-column row exactly, and the last panel stretches to the end', () => { + boot(null); + const spans = (prefs: number[], cols = 6) => win.storyLayoutSpans(prefs, cols) as number[]; + for (const prefs of [[3, 3, 2, 2, 2, 6], [3, 2, 2, 2, 3, 2], [2], [3, 4, 2, 2], [6, 6], [2, 2, 2, 2, 2, 2, 2]]) { + const s = spans(prefs); + expect(s).toHaveLength(prefs.length); + // Walk the rows: every row adds up to exactly 6. + let row = 0; + for (const span of s) { + row += span; + expect(row).toBeLessThanOrEqual(6); + if (row === 6) row = 0; + } + expect(row).toBe(0); + } + expect(spans([1, 1, 1], 2)).toEqual([1, 1, 2]); + }); +});