Files
Codeman/test/story-mode.browser.test.ts
T
Codeman maintainer 250a77d945 fix(story-mode): the reader cannot stall the server, and reads only real transcripts
From review round 7 (security):

- Eviction past the per-transcript caps dropped entries one shift() at
  a time after the whole tail was parsed: quadratic and never yielding
  (a 2 s event-loop stall on a 70 MB long turn, 51 s from 300k entries).
  It is one splice, done at every 1 MB yield, so the peak stays near the
  caps; and a window that saw a turn start which was then trimmed away no
  longer widens to a bigger tail that would be trimmed the same.
- A FIFO or a device in place of a transcript or a helper's .meta.json
  (stat said size 0, then open() waited for a writer) pinned a libuv
  worker per request; four of them hang every fs, dns and crypto call on
  the server. Files are now opened non-blocking, checked with the
  handle's own stat (a regular file only) and read through that handle.
- The cache held 6 transcripts for everyone, so a seventh open view made
  every poll a full re-read, and a 304 was decided only after reading. An
  unchanged file is now a 304 from a stat alone, and the cache holds up
  to 24 transcripts within 30 million characters.
- Story Mode is the first route to send a transcript's content to the
  browser, so the hook-reported path is used only when it really lies
  (symlinks resolved) in a Claude projects folder: the session's
  relocated CLAUDE_CONFIG_DIR (now also used for the lookup by id) or the
  server's. Anything else falls back to the lookup by conversation id.

Tests move their transcripts into the (temp) projects folder and cover a
path outside it, a symlink out of it, a FIFO transcript, a FIFO meta file
and a 100,000-entry tail.

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

164 lines
7.6 KiB
TypeScript

/**
* @fileoverview Story Mode end to end: a real server (test mode: no tmux, no CLI), real Chromium, a
* synthetic transcript (test/story-fixtures.ts) handed to the session the way Claude's hooks do it,
* then grown while the overlay is open so the live redraw runs off real SSE events.
*/
import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
import { claudeProjectsDir } from '../src/utils/claude-transcript.js';
import { demoTurn, resetClock } from './story-fixtures.js';
const jsonl = (lines: unknown[]) => lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
describe('Story Mode in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
let root: string;
let transcriptDir: string;
let transcript: string;
let sessionId: string;
let rest: unknown[];
const kinds = () =>
page.evaluate(() => [...document.querySelectorAll('.story-panel')].map((p) => (p as HTMLElement).dataset.kind));
const hook = (event: string) =>
page.evaluate(
async ({ sid, path, ev }) => {
await fetch('/api/hook-event', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event: ev, sessionId: sid, data: { transcript_path: path } }),
});
},
{ sid: sessionId, path: transcript, ev: event }
);
beforeAll(async () => {
resetClock();
root = mkdtempSync(join(homedir(), 'story-ui-'));
// Where Claude Code writes transcripts (under the test's temp HOME): the only place a hook path is read.
mkdirSync(claudeProjectsDir(), { recursive: true });
transcriptDir = mkdtempSync(join(claudeProjectsDir(), 'story-ui-'));
transcript = join(transcriptDir, 'conversation.jsonl');
const turn = demoTurn();
// The turn up to the first edit's result is "running"; the rest arrives during the test.
writeFileSync(transcript, jsonl(turn.slice(0, 14)));
rest = turn.slice(14);
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1400, height: 950 } });
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
sessionId = await page.evaluate(async (workingDir) => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, mode: 'claude', name: 'story-browser' }),
});
return (await res.json()).data.session.id as string;
}, root);
await hook('stop');
await page.evaluate(async (sid) => {
const app = (window as any).app;
for (let i = 0; i < 100 && !app.sessions.has(sid); i++) await new Promise((r) => setTimeout(r, 100));
await app.selectSession(sid);
}, sessionId);
}, 120000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
rmSync(root, { recursive: true, force: true });
rmSync(transcriptDir, { recursive: true, force: true });
}, 60000);
it('is off by default: no header button', async () => {
expect(await page.locator('.btn-story-header').isVisible()).toBe(false);
});
it('turning it on shows the button, and the button draws the running turn', async () => {
await page.evaluate(() => {
const app = (window as any).app;
const s = app.loadAppSettingsFromStorage();
s.showStoryButton = true;
app.saveAppSettingsToStorage(s);
app.applyHeaderVisibilitySettings();
});
await page.locator('.btn-story-header').click();
await page.waitForFunction(() => document.querySelectorAll('.story-panel').length >= 4, null, { timeout: 10000 });
expect(await kinds()).toEqual(['mission', 'investigate', 'plan', 'backup', 'fix']);
expect(await page.locator('.story-bar .story-live').isVisible()).toBe(true);
expect(await page.locator('.story-pencils').count()).toBe(1);
});
it('redraws live when the transcript grows and a hook fires', async () => {
appendFileSync(transcript, jsonl(rest));
await hook('stop');
await page.waitForFunction(() => document.querySelector('.story-panel[data-kind="complete"]'), null, {
timeout: 15000,
});
expect(await kinds()).toEqual(['mission', 'investigate', 'plan', 'backup', 'loop', 'save', 'complete']);
expect(await page.locator('.story-pencils').count()).toBe(0);
expect(await page.locator('.story-panel.story-fresh').count()).toBeGreaterThan(0);
});
it('the code page opens the diff; Escape closes the drawer, then the overlay', async () => {
await page.locator('.story-panel[data-kind="loop"] .story-hit').first().click();
await page.waitForSelector('.story-drawer:not([hidden])');
expect(await page.locator('.story-drawer-kind').textContent()).toBe('Diff');
expect(await page.locator('.story-ln--add, .story-ln--del').count()).toBe(3);
await page.keyboard.press('Escape');
expect(await page.locator('.story-drawer').isHidden()).toBe(true);
expect(await page.locator('#storyOverlay').isVisible()).toBe(true);
await page.keyboard.press('Escape');
expect(await page.locator('#storyOverlay').isHidden()).toBe(true);
});
it('Session Options opens the story, and Save image downloads the page as a PNG', async () => {
await page.evaluate((sid) => (window as any).app.openSessionOptions(sid), sessionId);
await page.locator('#sessionOptionsStoryBtn').click();
await page.waitForFunction(() => document.querySelectorAll('.story-panel').length > 0);
expect(await page.locator('#storyOverlay').isVisible()).toBe(true);
const [download] = await Promise.all([page.waitForEvent('download'), page.locator('.story-save').click()]);
expect(download.suggestedFilename()).toMatch(/^story-story-browser-.*\.png$/);
const path = await download.path();
const head = readFileSync(path!).subarray(0, 24);
expect(head.subarray(1, 4).toString()).toBe('PNG');
expect(head.readUInt32BE(16)).toBeGreaterThan(1000); // width, at 2x
await page.keyboard.press('Escape');
});
it('fits a phone-width viewport without sideways scrolling', async () => {
await page.setViewportSize({ width: 400, height: 860 });
await page.evaluate((sid) => (window as any).app.openStoryMode(sid), sessionId);
await page.waitForFunction(() => document.querySelectorAll('.story-panel').length > 0);
const overflow = await page.evaluate(() => {
const main = document.querySelector('.story-main') as HTMLElement;
return main.scrollWidth - main.clientWidth;
});
expect(overflow).toBe(0);
// The bar keeps to three rows, and a panel below the fold gets its buttons without a scroll.
expect(await page.evaluate(() => (document.querySelector('.story-bar') as HTMLElement).offsetHeight)).toBeLessThan(
190
);
await page.waitForFunction(
() => {
const p = document.querySelector('.story-panel[data-kind="loop"]') as HTMLElement;
const main = document.querySelector('.story-main') as HTMLElement;
return p.getBoundingClientRect().top > main.getBoundingClientRect().bottom && !!p.querySelector('.story-hit');
},
null,
{ timeout: 10000 }
);
await page.evaluate(() => (window as any).app.closeStoryMode());
await page.setViewportSize({ width: 1400, height: 950 });
});
});