mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
- 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>
145 lines
6.6 KiB
TypeScript
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 });
|
|
});
|
|
});
|