Files
Codeman/test/story-mode-ui.test.ts
T
Codeman maintainer d3c55e04a3 fix(story-mode): what the captions blurred for a reader who skims (review round 11)
From an engineering manager skimming titles and captions:
- "but a check after that fails" under "Mission complete!" where the
  prompt said that step may fail now adds ", as you said it might".
- Saves no prompt of the turn asked for (a project rule, or Claude's own
  idea) were indistinguishable from requested ones: the turn's first such
  save carries a small line, "a save your prompt here did not ask for".
  "save its log with tee" no longer counts as asking to save the work.
- The first save on a branch Claude created says which: "saves the work
  on a new branch (feat/csvstat)".
- "Claude changes 3 files" hid an edit to your CLAUDE.md: a panel that
  wrote some files and patched others says both ("Claude writes
  README.md and .gitignore and changes CLAUDE.md."); a file created and
  then edited in the same panel is written.
- A turn with a problem left and work still out read "Done for now";
  the catch now wins the title.
- A helper's report that is only a path reads "reports back with the
  file it wrote: README.md."

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 17:44:06 +02:00

1287 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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, edit, end, prompt, read, resetClock, result, said, tool } 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<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
/** The response for `?turn=<id>`: every turn listed, the page of the one asked for. */
function storyAt(lines: unknown[], index: number) {
const reducer = createStoryReducer();
const turns = buildStoryTurns(lines.flatMap((l) => reducer.reduce(l)));
return { supported: true, turns: summarizeTurns(turns), page: buildStoryPage(turns[index]) };
}
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,
};
}
/** Every text a scene drew, in order (the fake context records `fillText`). */
const drawnTexts: string[] = [];
/** A 2D context that accepts every call; measureText answers a plausible width. */
function fakeContext() {
const target: Record<string, unknown> = {
measureText: (text: string) => ({ width: String(text).length * 6 }),
fillText: (text: unknown) => void drawnTexts.push(String(text)),
};
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<string, any>; // 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(
`<!doctype html><body>
<button class="btn-story-header btn-story-header--hidden"></button>
<button id="sessionOptionsStoryBtn" hidden></button>
<div class="file-preview-overlay" id="filePreviewOverlay"><button id="pvOther">Edit</button><button title="Close">×</button></div>
<div class="folder-grid-overlay" id="folderGridOverlay"></div>
</body>`,
{ 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-text')!.textContent).toBe(
'The checkout button does nothing on phones. Can you fix it?'
);
// Who speaks, for a screen reader (visually hidden; the art shows it).
expect($('.story-panel[data-kind="mission"] .story-balloon .story-vh')!.textContent).toBe('You say: ');
expect($('.story-panel[data-kind="mission"]')!.getAttribute('aria-label')).toBe('Panel 1 of 7');
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 CheckoutButton.tsx and banner.css.'
);
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('a balloon too tall for its panel clamps its text, never the hidden speaker words', async () => {
boot(storyOf(demoTurn()));
app.openStoryMode(SID);
await settle();
sizeAndDraw();
const rt = app._story.panels[0];
const balloon = rt.balloon as HTMLElement;
const text = balloon.querySelector('.story-balloon-text') as HTMLElement;
// No layout in JSDOM: a balloon 300 px tall in a 270 px panel.
Object.defineProperty(balloon, 'offsetHeight', { value: 300, configurable: true });
Object.defineProperty(balloon, 'offsetTop', { value: 10, configurable: true });
Object.defineProperty(text, 'offsetHeight', { value: 280, configurable: true });
rt.balloonKey = '';
app._storyPlaceBalloon(rt, null, 0, 1);
expect(text.style.getPropertyValue('--story-lines')).toBe('12');
expect((balloon.querySelector('.story-vh') as HTMLElement).style.getPropertyValue('--story-lines')).toBe('');
});
it('a fold near the start keeps every later panel as it was: no second entrance', async () => {
const data = storyOf(demoTurn());
boot(data);
app.openStoryMode(SID);
await settle();
sizeAndDraw();
const before = new Map(app._story.panels.map((rt: { kind: string }) => [rt.kind, rt]));
const folded = JSON.parse(JSON.stringify(data));
const ps = folded.page.panels;
ps.splice(1, 2, { ...ps[1], kind: 'montage', events: [...ps[1].events, ...ps[2].events] });
responses = [{ status: 200, body: { success: true, data: folded } }];
await app._storyRefresh();
await settle();
expect($$('.story-panel').map((p) => p.dataset.kind)).toEqual([
'mission',
'montage',
'backup',
'loop',
'save',
'complete',
]);
for (const kind of ['mission', 'backup', 'loop', 'save', 'complete']) {
const rt = app._story.panels.find((r: { kind: string }) => r.kind === kind);
expect(rt).toBe(before.get(kind));
}
expect($('.story-panel[data-kind="montage"]')!.classList.contains('story-fresh')).toBe(true);
expect($('.story-panel[data-kind="loop"]')!.classList.contains('story-fresh')).toBe(false);
});
it('a refresh that changes nothing keeps the open drawer as it is, focus included', async () => {
const data = storyOf(demoTurn());
boot(data);
app.openStoryMode(SID);
await settle();
sizeAndDraw();
$('.story-panel[data-kind="loop"] .story-hit')!.click();
const btn = $('.story-drawer-body button')!;
btn.focus();
responses = [{ status: 200, body: { success: true, data: JSON.parse(JSON.stringify(data)) } }];
await app._storyRefresh();
await settle();
expect(win.document.contains(btn)).toBe(true);
expect(win.document.activeElement).toBe(btn);
});
it('a fix that folds into a loop under an open drawer: Escape gives focus to the loop', async () => {
const lines = [prompt('fix it'), ...edit('e1', '/work/demo-shop/a.py', ['+a = 1'])];
boot(storyOf(lines));
app.openStoryMode(SID);
await settle();
sizeAndDraw();
const hit = $$('.story-panel[data-kind="fix"] .story-hit').find((h) => h.dataset.storyOpen?.startsWith('diff@'))!;
hit.focus();
hit.click();
responses = [
{
status: 200,
body: {
success: true,
data: storyOf([
...lines,
...bash('b1', 'pytest -q', 'Run tests', '1 failed, 2 passed in 0.1s'),
...edit('e2', '/work/demo-shop/a.py', ['-a = 1', '+a = 2']),
...bash('b2', 'pytest -q', 'Run tests', '3 passed in 0.1s'),
]),
},
},
];
await app._storyRefresh();
await settle();
sizeAndDraw();
expect($('.story-panel[data-kind="loop"]')).toBeTruthy();
press('Escape');
expect($('.story-drawer')!.hidden).toBe(true);
expect((win.document.activeElement as HTMLElement).dataset.storyOpen).toMatch(/^diff@loop:/);
});
it('a drawer holds its turn when the newest turn moves on, and lets go when it closes', async () => {
const one = [prompt('first job'), ...read('r1', '/work/demo-shop/a.ts'), said('Done.'), end(2000)];
const two = [...one, prompt('second job'), said('On it.')];
boot(storyOf(one));
app.openStoryMode(SID);
await settle();
sizeAndDraw();
const first = app._story.data.page.id;
$('[data-story-open="list:page"]')!.click();
expect($('.story-drawer')!.hidden).toBe(false);
const title = $('.story-drawer-title')!.textContent;
responses = [
{ status: 200, body: { success: true, data: storyOf(two) } },
{ status: 200, body: { success: true, data: storyAt(two, 0) } },
];
calls.length = 0;
await app._storyRefresh();
await settle(12);
expect(app._story.data.page.id).toBe(first);
expect(app._story.turnId).toBe(first);
expect($('.story-drawer')!.hidden).toBe(false);
expect($('.story-drawer-title')!.textContent).toBe(title);
expect($$('.story-turns option')).toHaveLength(2);
// The held turn's last state is fetched a moment later (the refresh's own retry, 250 ms).
await new Promise((r) => setTimeout(r, 320));
await settle();
expect(calls.some((c) => c.includes(`turn=${encodeURIComponent(first)}`))).toBe(true);
expect(app._story.data.page.id).toBe(first);
expect($('.story-sr')!.textContent).toMatch(/stays on the turn you are reading/);
// Closing the drawer follows the newest turn again.
responses = [{ status: 200, body: { success: true, data: storyOf(two) } }];
press('Escape');
await settle(12);
expect(app._story.turnId).toBeNull();
expect(app._story.data.page.id).not.toBe(first);
});
it('Previous on the second turn lands on the first with focus on the picker, not the page', async () => {
const two = [prompt('first job'), said('Done.'), end(2000), prompt('second job'), said('Done too.'), end(2000)];
boot(storyOf(two));
app.openStoryMode(SID);
await settle();
responses = [{ status: 200, body: { success: true, data: storyAt(two, 0) } }];
const prev = $('.story-prev') as HTMLButtonElement;
prev.focus();
prev.click();
await settle(12);
expect(prev.disabled).toBe(true);
expect(win.document.activeElement).toBe($('.story-turns'));
});
it('the diff of a command that also printed a file shows what it printed, with the file to open', async () => {
const lines = [
prompt('note it'),
...bash(
'b1',
"cat >> NOTES.md <<'EOF'\nmore\nEOF\ncat stats.py",
'Append a note and show the module',
'def f(): pass'
),
end(1000),
];
(lines[1] as { cwd?: string }).cwd = '/work/demo-shop';
boot(storyOf(lines));
app.openStoryMode(SID);
await settle();
sizeAndDraw();
$$('.story-panel .story-hit')
.find((h) => h.dataset.storyOpen?.startsWith('diff@'))!
.click();
expect($('.story-drawer-kind')!.textContent).toBe('Diff');
expect($('.story-drawer-body')!.textContent).toContain('def f(): pass');
expect($$('.story-drawer-body .story-out-files button').map((b) => b.textContent)).toEqual(['Open stats.py']);
expect($$('.story-drawer-body button').some((b) => /it wrote/.test(b.textContent || ''))).toBe(false);
});
it('a drawer covering the whole page makes the page behind it inert; one beside it does not', async () => {
boot(storyOf(demoTurn()));
app.openStoryMode(SID);
await settle();
sizeAndDraw();
const st = app._story;
const box = (left: number, width: number) => () => ({
left,
width,
top: 0,
height: 500,
right: left + width,
bottom: 500,
});
st.mainEl.getBoundingClientRect = box(0, 400);
st.drawerEl.getBoundingClientRect = box(0, 400);
$('.story-panel[data-kind="loop"] .story-hit')!.click();
expect(st.mainEl.inert).toBe(true);
press('Escape');
expect(st.mainEl.inert).toBe(false);
st.mainEl.getBoundingClientRect = box(0, 1400);
st.drawerEl.getBoundingClientRect = box(900, 500);
$('.story-panel[data-kind="loop"] .story-hit')!.click();
expect(st.mainEl.inert).toBe(false);
});
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('a turn a background task started gets a notice, not your speech balloon', async () => {
boot(
storyOf([
prompt('<task-notification><summary>Agent "Docs" finished</summary></task-notification>', {
turnOrigin: 'task_notification',
}),
said('Nice.'),
end(1000),
])
);
app.openStoryMode(SID);
await settle();
const balloon = $('.story-panel[data-kind="mission"] .story-balloon')!;
expect(balloon.className).toContain('story-balloon--note');
expect(balloon.querySelector('.story-balloon-text')!.textContent).toBe('Agent "Docs" finished');
expect(balloon.querySelector('.story-vh')!.textContent).toBe('Notice: ');
});
it("the helper drawer fetches and lists the helper's own steps", async () => {
const lines = [
prompt('Docs please'),
tool('a1', 'Agent', { description: 'Write README', subagent_type: 'general-purpose', prompt: 'Write it' }),
result('a1', 'ok', {
toolUseResult: { agentId: 'h1', content: [{ type: 'text', text: 'Written.' }], totalToolUseCount: 2 },
}),
end(3000),
];
boot(storyOf(lines));
const helperPage = storyOf([prompt('Write it'), ...read('r1', '/work/demo-shop/a.md')]).page;
const base = app._api;
app._api = async (path: string) => {
if (path.includes('/story/helpers/h1')) {
calls.push(path);
return {
ok: true,
status: 200,
headers: { get: () => null },
json: async () => ({
success: true,
data: { supported: true, helper: { agentId: 'h1', finished: true }, page: helperPage },
}),
};
}
return base(path);
};
app.openStoryMode(SID);
await settle();
sizeAndDraw();
$('.story-panel[data-kind="backup"] .story-hit')!.click();
await settle();
expect($('.story-drawer-title')!.textContent).toBe('Write README');
expect($('.story-drawer-sub')!.textContent).toContain('general-purpose');
expect(calls).toContain(`/api/sessions/${SID}/story/helpers/h1`);
const steps = $('.story-helper-steps')!;
expect(steps.textContent).toContain('1 step');
// Paths inside the session's folder are shown short; the click still opens the full path.
const link = steps.querySelector('.story-link') as HTMLElement;
expect(link.textContent).toBe('a.md');
link.click();
expect(app.openFilePreview).toHaveBeenLastCalledWith('/work/demo-shop/a.md', SID);
});
it("a page that holds still (304) still refreshes a working helper's steps in its open drawer", async () => {
const data = storyOf([
prompt('docs'),
tool('a1', 'Agent', { description: 'Write README', subagent_type: 'general-purpose', prompt: 'Write it' }),
result('a1', 'Async agent launched successfully. agentId: h9', {
toolUseResult: { isAsync: true, status: 'async_launched', agentId: 'h9' },
}),
end(1000),
]);
boot(data);
const helperPage = storyOf([prompt('Write it'), ...read('r1', '/work/demo-shop/a.md')]).page;
let helperCalls = 0;
app._api = async (path: string) => {
if (path.includes('/story/helpers/h9')) {
helperCalls++;
return {
ok: true,
status: 200,
headers: { get: () => null },
json: async () => ({ success: true, data: { supported: true, helper: { agentId: 'h9' }, page: helperPage } }),
};
}
// The parent page never changes while the helper works: the same tag every time.
return {
ok: true,
status: 200,
headers: { get: (h: string) => (h.toLowerCase() === 'etag' ? 'W/"same"' : null) },
json: async () => ({ success: true, data }),
};
};
app.openStoryMode(SID);
await settle();
sizeAndDraw();
$('.story-panel[data-kind="backup"] .story-hit')!.click();
await settle();
const before = helperCalls;
expect(before).toBeGreaterThan(0);
await app._storyRefresh();
await settle();
expect(helperCalls).toBe(before + 1);
});
it('a request that never settled does not block the next open: the page draws', async () => {
const data = storyOf(demoTurn());
boot(data);
app._api = () => new Promise(() => {}); // hangs forever
app.openStoryMode(SID);
await settle();
app.closeStoryMode();
app._api = async () => ({
ok: true,
status: 200,
headers: { get: () => null },
json: async () => ({ success: true, data }),
});
app.openStoryMode(SID);
await settle();
expect($$('.story-panel').length).toBeGreaterThan(0);
// Reopened, nothing from before is announced as news.
expect($('.story-sr')!.textContent).toBe('');
});
it('a session Story Mode cannot draw is not polled', async () => {
let poll: (() => void) | undefined;
boot({ supported: false, reason: 'A shell session.' });
win.setInterval = ((fn: () => void) => {
poll = fn;
return 1;
}) as never;
app.openStoryMode(SID);
await settle();
app._story.lastIdlePoll = 0;
calls.length = 0;
poll!();
await settle();
expect(calls).toHaveLength(0);
});
it('Tab stays inside the overlay', async () => {
boot(storyOf(demoTurn()));
app.openStoryMode(SID);
await settle();
// JSDOM has no layout (offsetParent is always null), so give every control one.
for (const el of $$('#storyOverlay button, #storyOverlay select'))
Object.defineProperty(el, 'offsetParent', { get: () => win.document.body });
const controls = $$('#storyOverlay button, #storyOverlay select').filter((n) => !(n as HTMLButtonElement).disabled);
controls[controls.length - 1].focus();
press('Tab');
expect(win.document.activeElement).toBe(controls[0]);
});
it("the mission opens the whole prompt, and a task turn's mission opens what the job reported", async () => {
const report = [
'<task-notification>',
'<tool-use-id>a1</tool-use-id>',
'<status>completed</status>',
'<summary>Agent "Docs" finished</summary>',
'<result>I wrote /home/me/demo-shop/README.md. Names look like old.jpg -> new.jpg.</result>',
'</task-notification>',
].join('\n');
boot(storyOf([prompt(report, { turnOrigin: 'task_notification' }), said('Thanks.'), end(1000)]));
app.openStoryMode(SID);
await settle();
sizeAndDraw();
const hit = $('.story-panel[data-kind="mission"] .story-hit')!;
expect(hit.title).toBe('Read the report');
hit.click();
expect($('.story-drawer-kind')!.textContent).toBe('Report');
expect($('.story-drawer-body')!.textContent).toContain('What it reported');
const links = $$('.story-drawer-body .story-link');
// In prose only rooted paths link: `old.jpg` is an example, not a file.
expect(links.map((l) => l.textContent)).toEqual(['/home/me/demo-shop/README.md']);
});
it("the wire files Claude's last words under the final panel", async () => {
boot(storyOf([prompt('look'), ...read('r1', '/work/demo-shop/a.md'), said('All done here.'), end(1000)]));
app.openStoryMode(SID);
await settle();
app._storySetDirector(true);
const rows = $$('.story-wire li').map((li) => li.textContent || '');
const lastGroup = [...rows].reverse().find((t) => t.startsWith('Panel'));
expect(lastGroup).toMatch(/· The end$/);
expect(rows[rows.length - 1]).toContain('All done here.');
});
it('a fix made from the terminal opens what ran, not an empty diff', async () => {
boot(
storyOf([
prompt('patch it'),
...bash('b1', "sed -i 's/a/b/' app.py", 'Patch the file', ''),
...bash('b2', 'printf x > notes.md', 'Write notes', ''),
end(1000),
])
);
app.openStoryMode(SID);
await settle();
sizeAndDraw();
$('.story-panel[data-kind="fix"] .story-hit')!.click();
expect($('.story-drawer-kind')!.textContent).toBe('Output');
expect($('.story-drawer-body')!.textContent).toContain("sed -i 's/a/b/' app.py");
expect($('.story-drawer-body')!.textContent).not.toContain('No changes');
});
it('output links skip globs and paths a tool elided', async () => {
boot(
storyOf([
prompt('test'),
...bash('b1', 'pytest -q', 'Run tests', 'FAILED test_a.py::t - 2...rted_0/a.txt\nsee *.test.ts and src/app.py'),
end(1000),
])
);
app.openStoryMode(SID);
await settle();
sizeAndDraw();
$('.story-panel[data-kind="check"] .story-hit')!.click();
const links = $$('.story-drawer-body .story-link').map((l) => l.textContent);
expect(links).toContain('src/app.py');
expect(links.some((l) => /\.\.\.|\*/.test(l || ''))).toBe(false);
expect(links).not.toContain('.test.ts');
});
it('a helper still working in the background says so, in its drawer and on the final panel', async () => {
boot(
storyOf([
prompt('docs'),
tool('a1', 'Agent', { description: 'Write README', subagent_type: 'general-purpose', prompt: 'Write it' }),
result('a1', 'Async agent launched successfully. agentId: h9', {
toolUseResult: { isAsync: true, status: 'async_launched', agentId: 'h9' },
}),
end(1000),
])
);
app.openStoryMode(SID);
await settle();
sizeAndDraw();
expect($('.story-panel[data-kind="complete"] .story-chips')!.textContent).toContain('1 helper, 1 still working');
// Work still out is not a finished mission.
expect($('.story-panel[data-kind="complete"] .story-splash-title')!.textContent).toBe('Done for now');
$('.story-panel[data-kind="backup"] .story-hit')!.click();
await settle();
expect($('.story-drawer-sub')!.textContent).toContain('in the background');
expect($('.story-drawer-body')!.textContent).toContain('Still working in the background.');
expect($('.story-drawer-body')!.textContent).not.toContain('Async agent launched');
});
it('a click region reused for a new page gets the new label', async () => {
boot(storyOf([prompt('one'), ...read('r1', '/work/demo-shop/README.md'), end(1000)]));
app.openStoryMode(SID);
await settle();
sizeAndDraw();
expect($('.story-panel[data-kind="investigate"] .story-hit')!.title).toBe('Open README.md');
const next = storyOf([prompt('one'), ...read('r1', '/work/demo-shop/rename_by_date.sh'), end(1000)]);
next.page!.id = app._story.data.page.id;
app._story.data = next;
app._storyRender();
sizeAndDraw();
expect($('.story-panel[data-kind="investigate"] .story-hit')!.title).toBe('Open rename_by_date.sh');
});
it('files written through a here-document open as a diff from the wire, with short paths', async () => {
const lines = [
prompt('write'),
...bash('b1', "cat > app.js <<'EOF'\nconst a = 1;\nEOF\nnode --test", 'Write and test', '# pass 1\n# fail 0'),
end(1000),
];
(lines[1] as { cwd?: string }).cwd = '/work/demo-shop';
boot(storyOf(lines));
app.openStoryMode(SID);
await settle();
app._storySetDirector(true);
const link = $$('.story-wire .story-link').find((l) => l.dataset.storyOpen?.startsWith('diff:'))!;
link.click();
expect($('.story-drawer-kind')!.textContent).toBe('Diff');
expect($('.story-drawer-title')!.textContent).toBe('app.js');
expect($('.story-df-path')!.textContent).toBe('app.js');
expect($('.story-df-path')!.title).toBe('/work/demo-shop/app.js');
expect($$('.story-ln--add').map((l) => l.textContent)).toEqual(['1+const a = 1;']);
});
it('an append draws without line numbers, and a check offers the file it printed', async () => {
const lines = [
prompt('add and test'),
...bash(
'b1',
"cat >> notes.md <<'EOF'\nmore\nEOF\ncat stats.py && pytest -q",
'Append, show and test',
'def f(): pass\n5 passed in 0.1s'
),
end(1000),
];
(lines[1] as { cwd?: string }).cwd = '/work/demo-shop';
boot(storyOf(lines));
app.openStoryMode(SID);
await settle();
sizeAndDraw();
$('.story-panel[data-kind="check"] .story-hit')!.click();
const open = $$('.story-drawer-body .story-out-files button');
expect(open.map((b) => b.textContent)).toEqual(['Open stats.py']);
open[0].click();
expect(app.openFilePreview).toHaveBeenLastCalledWith('/work/demo-shop/stats.py', SID);
$$('.story-drawer-body button')
.find((b) => b.textContent === 'See the file it wrote')!
.click();
expect($('.story-ln--hunk')!.textContent).toBe('Added at the end of the file (1 line)');
expect($$('.story-ln--add').map((l) => l.textContent)).toEqual(['+more']);
});
it('a turn whose last words name an open problem ends "Done, with a catch", not "Mission complete!"', async () => {
const words = [
'I built the pomodoro timer and its tests, and all of them pass.',
'It keeps the time in memory and redraws the clock every second while it runs. '.repeat(3),
"Opening the page: it won't work if you double-click index.html.",
].join(' ');
boot(storyOf([prompt('build it'), ...edit('e1', '/work/demo-shop/t.js', ['+x']), said(words), end(1000)]));
app.openStoryMode(SID);
await settle();
expect($('.story-panel[data-kind="complete"] .story-splash-title')!.textContent).toBe('Done, with a catch');
expect($('.story-panel[data-kind="complete"] .story-balloon-text')!.textContent).toMatch(
/won't work if you double-click/
);
});
it("a second opinion is a line in the next panel's caption, not a panel of its own", async () => {
const advisor = {
type: 'assistant',
timestamp: '2026-10-18T09:41:20.000Z',
message: {
content: [
{ type: 'server_tool_use', id: 'srv1', name: 'advisor', input: {} },
{ type: 'advisor_tool_result', tool_use_id: 'srv1', content: { type: 'advisor_redacted_result' } },
],
},
};
boot(storyOf([prompt('fix it'), advisor, ...edit('e1', '/work/demo-shop/a.py', ['+a = 1']), end(1000)]));
app.openStoryMode(SID);
await settle();
expect($$('.story-panel').map((p) => p.dataset.kind)).toEqual(['mission', 'fix', 'complete']);
const note = $('.story-panel[data-kind="fix"] .story-cap-note')!;
expect(note.hidden).toBe(false);
expect(note.textContent).toBe('after asking a stronger AI model for a second opinion');
});
it('a second opinion asked in the middle of a step says so, and the badge counts the card in front', async () => {
const advisor = {
type: 'assistant',
timestamp: '2026-10-18T09:41:20.000Z',
message: {
content: [
{ type: 'server_tool_use', id: 'srv1', name: 'advisor', input: {} },
{ type: 'advisor_tool_result', tool_use_id: 'srv1', content: { type: 'advisor_redacted_result' } },
],
},
};
boot(
storyOf([
prompt('fix it'),
...edit('e1', '/work/demo-shop/a.py', ['+a = 1', '+b = 2']),
advisor,
...edit('e2', '/work/demo-shop/b.py', ['+c = 3', '+d = 4', '+e = 5']),
end(1000),
])
);
drawnTexts.length = 0;
app.openStoryMode(SID);
await settle();
expect($('.story-panel[data-kind="fix"] .story-cap-note')!.textContent).toBe(
'along the way, asks a stronger AI model for a second opinion'
);
// Two files: the badge counts a.py, the card in front (+2), not the panel's +5. (JSDOM lays out
// nothing, so the panel is given a size and drawn late in its animation, the badge showing.)
const rt = app._story.panels.find((p: { kind: string }) => p.kind === 'fix');
Object.assign(rt, { w: 600, h: 270, dpr: 1, bgKey: '', clock: 200 });
app._storyDrawPanel(rt);
expect(drawnTexts).toContain('+2');
expect(drawnTexts).not.toContain('+5');
});
it("the first save no prompt asked for says so in a small line; a catch beats 'Done for now'", async () => {
boot(
storyOf([
prompt('Add a.'),
...bash('b1', 'git commit -qm "add a"', 'Commit', ''),
// Still running when the turn ends: the page would read "Done for now" without the catch.
...bash('b2', 'sleep 60 && pytest -q', 'Slow tests', 'Command running in background with ID: b2x'),
said(
'I added a and saved it. ' +
'It reads the file and prints the counts for each column, one line per column. '.repeat(3) +
'One known issue: big numbers are shown rounded.'
),
end(1000),
])
);
app.openStoryMode(SID);
await settle();
expect($('.story-panel[data-kind="save"] .story-cap-note')!.textContent).toBe(
'a save your prompt here did not ask for'
);
expect($('.story-panel[data-kind="complete"] .story-splash-title')!.textContent).toBe('Done, with a catch');
});
it('a second opinion that came back with an error says no answer came', async () => {
const failed = {
type: 'assistant',
timestamp: '2026-10-18T09:41:20.000Z',
message: {
content: [
{ type: 'server_tool_use', id: 'srv1', name: 'advisor', input: {} },
{
type: 'advisor_tool_result',
tool_use_id: 'srv1',
content: { type: 'advisor_tool_result_error', error_code: 'unavailable' },
},
],
},
};
boot(storyOf([prompt('fix it'), failed, ...edit('e1', '/work/demo-shop/a.py', ['+a = 1']), end(1000)]));
app.openStoryMode(SID);
await settle();
expect($('.story-panel[data-kind="fix"] .story-cap-note')!.textContent).toBe(
'after trying to ask a stronger AI model for a second opinion (no answer came)'
);
});
it('the final panel counts the commits of the whole turn, and says when the work was uploaded', async () => {
const chipsFor = async (to: string) => {
boot(
storyOf([
prompt('ship it'),
...edit('e1', '/work/demo-shop/a.py', ['+a = 1']),
...bash('b1', 'pytest -q && git commit -qam "fix: a"', 'Test and commit', '3 passed in 0.1s'),
...bash(
'b2',
'git commit -qam "docs: b" && git push',
'Commit and push',
`To ${to}\n 1a2b3c4..5d6e7f8 main -> main`
),
end(1000),
])
);
app.openStoryMode(SID);
await settle();
return $$('.story-panel[data-kind="complete"] .story-chips li').map((li) => li.textContent);
};
expect(await chipsFor('git@github.com:me/shop.git')).toContain('2 commits, uploaded');
app.closeStoryMode?.();
// A folder on this computer: a copy, named.
expect(await chipsFor('./origin.git')).toContain('2 commits, copied to origin.git');
});
it('a chip that opened the drawer takes focus back, even when the page was redrawn meanwhile', async () => {
boot(storyOf(demoTurn()));
app.openStoryMode(SID);
await settle();
sizeAndDraw();
const chip = $$('.story-chips button').find((b) => b.dataset.storyOpen === 'diff:page')!;
chip.focus();
chip.click();
expect($('.story-drawer-kind')!.textContent).toBe('Diff');
// A redraw with the same chips keeps the same element.
app._storyRender();
expect(win.document.contains(chip)).toBe(true);
// One that did replace it: focus goes to the chip that opens the same thing.
const twin = chip.cloneNode(true) as HTMLElement;
chip.replaceWith(twin);
press('Escape');
expect($('.story-drawer')!.hidden).toBe(true);
expect(win.document.activeElement).toBe(twin);
});
it("the compact panel opens what was packed up: the compaction's numbers, not an empty output", async () => {
boot(
storyOf([
prompt('go'),
...read('r1', '/work/demo-shop/a.ts'),
{
type: 'system',
subtype: 'compact_boundary',
timestamp: '2026-10-18T09:45:00.000Z',
compactMetadata: { trigger: 'auto', preTokens: 46928, postTokens: 3623, durationMs: 24014 },
},
said('Carrying on.'),
end(1000),
])
);
app.openStoryMode(SID);
await settle();
sizeAndDraw();
const hit = $('.story-panel[data-kind="compact"] .story-hit')!;
expect(hit.getAttribute('aria-label')).toBe('See what was packed up');
hit.click();
expect($('.story-drawer')!.hidden).toBe(false);
const body = $('.story-drawer-body')!.textContent!;
expect(body).toMatch(/46\.9k/);
expect(body).toMatch(/3\.6k/);
expect(body).not.toMatch(/No output|nothing to show/i);
});
it('parked on an older turn, the poll learns about new turns and says so, without moving the reader', async () => {
const two = [prompt('first job'), said('Done.'), end(2000), prompt('second job'), said('Done too.'), end(2000)];
const three = [...two, prompt('third job'), said('All three.'), end(2000)];
let poll: (() => void) | undefined;
boot(storyOf(two));
win.setInterval = ((fn: () => void) => {
poll = fn;
return 1;
}) as never;
app.openStoryMode(SID);
await settle();
sizeAndDraw();
const first = app._story.data.turns[0].id;
responses = [{ status: 200, body: { success: true, data: storyAt(two, 0) } }];
app._storyPickTurn(first);
await settle();
expect(app._story.data.page.id).toBe(first);
const announced = $('.story-sr')!.textContent;
// A third turn arrives while the reader looks at the first.
responses = [{ status: 200, body: { success: true, data: storyAt(three, 0) } }];
app._story.lastIdlePoll = 0;
calls.length = 0;
poll!();
await settle();
expect(calls).toHaveLength(1);
expect(calls[0]).toContain(`turn=${encodeURIComponent(first)}`);
expect($$('.story-turns option')).toHaveLength(3);
expect(app._story.data.page.id).toBe(first);
expect($('.story-turns')!.getAttribute('aria-label')).toBe('Turn 1 of 3');
// The new turn is news, said once, without moving the reader.
expect($('.story-sr')!.textContent).not.toBe(announced);
expect($('.story-sr')!.textContent).toBe('A new turn started: third job. You are on turn 1 of 3.');
});
it('while a question waits, the pencils say so; the chosen answer is marked, not struck', async () => {
const ask = (answered: boolean) =>
storyOf([
prompt('pick'),
tool('q1', 'AskUserQuestion', {
questions: [{ question: 'Which name?', options: [{ label: 'slugify' }, { label: 'kebab' }] }],
}),
...(answered ? [result('q1', 'kebab', { toolUseResult: { answers: { 'Which name?': 'kebab' } } })] : []),
]);
boot(ask(false));
app.openStoryMode(SID);
await settle();
expect($('.story-pencils small')!.textContent).toBe('Waiting for your answer…');
responses = [{ status: 200, body: { success: true, data: ask(true) } }];
await app._storyRefresh();
await settle();
sizeAndDraw();
$('.story-panel[data-kind="ask"] .story-hit')!.click();
const chosen = $('.story-drawer-body .is-chosen')!;
expect(chosen.textContent).toBe('kebab');
expect($('.story-drawer-body .is-done')).toBeNull();
expect($('.story-pencils small')!.textContent).toBe('Claude is working…');
});
it('a bare name under an ls header links into that folder; the session folder reads as "."', async () => {
boot(
storyOf([
prompt('look'),
...bash(
'b1',
'ls /work/demo-shop && ls -R /work/demo-shop/clone',
'List the clone',
'clone/src:\nkebab.py\n\nnotes.md'
),
end(1000),
])
);
app.openStoryMode(SID);
await settle();
sizeAndDraw();
$('.story-panel[data-kind="investigate"] .story-hit')!.click();
expect($('.story-out-cmd')!.textContent).toBe('$ ls . && ls -R clone');
const links = $$('.story-drawer-body .story-link');
links.find((l) => l.textContent === 'kebab.py')!.click();
expect(app.openFilePreview).toHaveBeenLastCalledWith('/work/demo-shop/clone/src/kebab.py', SID);
links.find((l) => l.textContent === 'notes.md')!.click();
expect(app.openFilePreview).toHaveBeenLastCalledWith('/work/demo-shop/notes.md', SID);
});
it('a file opened from Story Mode takes keyboard focus, keeps Tab inside, and gives focus back on Escape', async () => {
boot(storyOf(demoTurn()));
const overlay = () => $('#filePreviewOverlay')!;
app.openFilePreview = vi.fn(() => overlay().classList.add('visible'));
app.openStoryMode(SID);
await settle();
sizeAndDraw();
$('.story-panel[data-kind="loop"] .story-hit')!.click();
const open = $$('.story-drawer-body .story-btn-s').find((b) => b.textContent === 'Open file')!;
open.focus();
open.click();
await new Promise((r) => win.requestAnimationFrame(() => r(null)));
const close = $('#filePreviewOverlay button[title="Close"]')!;
expect(win.document.activeElement).toBe(close);
for (const n of $$('#filePreviewOverlay button'))
Object.defineProperty(n, 'offsetParent', { get: () => win.document.body });
press('Tab');
expect(overlay().contains(win.document.activeElement)).toBe(true);
press('Escape');
expect(app.closeFilePreview).toHaveBeenCalled();
expect(win.document.activeElement).toBe(open);
expect($('#storyOverlay')!.hidden).toBe(false);
});
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($('#sessionOptionsStoryBtn')!.hidden).toBe(true);
expect($('#storyOverlay')!.hidden).toBe(true);
app.loadAppSettingsFromStorage = () => ({ showStoryButton: true });
app.applyStoryButtonVisibility();
expect($('#sessionOptionsStoryBtn')!.hidden).toBe(false);
});
});
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> = {}): 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]);
});
});