test(story-mode): end to end in a real browser (browser suite)

A real server in test mode, real Chromium, a synthetic transcript handed over
through a real stop hook: off by default, the header button draws the running
turn, a grown transcript plus a hook redraws it live over SSE, the code page
opens the diff, Escape closes drawer then overlay, and a phone-width viewport
has no sideways scroll. Listed in BROWSER_TEST_GLOBS (nightly, not the gate).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 09:46:10 +02:00
1 parent fb71180632
commit d313b868d4
2 files changed
+129

No files matched your search

+1
View File
@@ -20,6 +20,7 @@
*/
export const BROWSER_TEST_GLOBS = [
'test/tmux-capture-sgr.browser.test.ts',
'test/story-mode.browser.test.ts',
'test/tab-rail-resize.browser.test.ts',
'test/tab-activation.browser.test.ts',
'test/tab-layout-editing.browser.test.ts',
+128
View File
@@ -0,0 +1,128 @@
/**
* @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, mkdtempSync, 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 { 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 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-'));
transcript = join(root, '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 });
}, 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('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);
await page.evaluate(() => (window as any).app.closeStoryMode());
await page.setViewportSize({ width: 1400, height: 950 });
});
});