diff --git a/config/test-suites.ts b/config/test-suites.ts index 2148a467..ba461a79 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -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', diff --git a/test/story-mode.browser.test.ts b/test/story-mode.browser.test.ts new file mode 100644 index 00000000..b1214749 --- /dev/null +++ b/test/story-mode.browser.test.ts @@ -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 }); + }); +});