Files
Codeman/test/story-mode.browser.test.ts
T
Codeman maintainer 0c45682136 feat(story-mode): save as image, a helper's own steps, a way in on phones, and polish
- Save image: the page as one PNG to share (each panel canvas, then every
  caption, balloon and chip redrawn from its computed style and box).
- The helper drawer lists what the helper did, read live from its own
  transcript (GET .../story/helpers/:agentId), led by the task Claude gave it.
- Session Options has a Story button (same setting), the way in on phones.
- A turn a background task or another session started reads as a notice,
  not as your speech balloon.
- Wide panels keep their scene in a centered stage instead of spreading the
  art to both ends; the fix panel's agent stands next to the page.
- An unchanged page (same ETag) is neither parsed nor redrawn.
- Paths inside the session's folder are shown short; clicks keep full paths.
- Tab stays inside the overlay; a missing 2D context skips the art, not the page.
- A rebindable Story Mode shortcut (no default chord).

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

145 lines
6.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, 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 { 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('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);
await page.evaluate(() => (window as any).app.closeStoryMode());
await page.setViewportSize({ width: 1400, height: 950 });
});
});