mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
chore: remove five unreferenced capture and verification scripts
capture-readme-gifs.mjs, capture-readme-real.mjs, capture-readme-skin.mjs, capture-video-screenshots.mjs (it wrote to scripts/scripts/remotion/public, a path that does not exist) and verify-session-sidebar.mts, a one-time harness for the session sidebar that imported the server's internals without being type-checked. Nothing in package.json, CI, the docs or another script names any of them. 2,719 lines. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
d44b049c51
commit
75ea908f29
5 files changed
-2719
No files matched your search
@@ -1,482 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* capture-readme-gifs.mjs
|
||||
*
|
||||
* Deterministic README GIFs — no real server, Claude CLI, or tmux. Reuses the
|
||||
* mock-injection pipeline from capture-readme-screenshots.mjs (static file
|
||||
* server + page.route mocks), drives a scripted timeline in the page, records
|
||||
* it with Playwright video, and converts to GIF via ffmpeg palette encoding.
|
||||
*
|
||||
* Scenes:
|
||||
* 1. subagent-demo.gif — terminal spawns 3 parallel agents; floating agent
|
||||
* windows open one by one and stream tool-call activity live (driven
|
||||
* through the real _onSubagentDiscovered/_onSubagentToolCall handlers).
|
||||
* 2. zerolag-demo.gif — side-by-side typing: instant local echo (zerolag)
|
||||
* vs bursty ~350 ms server echo, rendered with the vendored xterm.
|
||||
*
|
||||
* Usage: node scripts/capture-readme-gifs.mjs
|
||||
* SCREENSHOT_OUT_DIR=/path/to/review node scripts/capture-readme-gifs.mjs
|
||||
* Output: docs/images/ (or flat into SCREENSHOT_OUT_DIR)
|
||||
* Requires: ffmpeg
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { execSync } from 'child_process';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
PORT,
|
||||
SESSION_IDS,
|
||||
STANDARD_SESSIONS,
|
||||
buildInitPayload,
|
||||
startStaticServer,
|
||||
setupRoutes,
|
||||
injectState,
|
||||
outPath,
|
||||
RST, GRN, YEL, MAG, CYN, GRY, BOLD,
|
||||
} from './capture-readme-screenshots.mjs';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const GIF_COLORS = 192;
|
||||
|
||||
// ─── ffmpeg conversion (palette recipe from capture-subagent-gif.mjs) ────────
|
||||
|
||||
function webmToGif(videoPath, gifPath, { ss, duration, width, fps }) {
|
||||
// One GLOBAL palette (default stats_mode=full) + ordered dither: per-frame
|
||||
// palettes (stats_mode=single:new=1) make dirty rectangles visibly mismatch
|
||||
// on flat dark UI, and error-diffusion dither shimmers between frames.
|
||||
const filters = `fps=${fps},scale=${width}:-1:flags=lanczos`;
|
||||
execSync(
|
||||
`ffmpeg -y -loglevel error -ss ${ss.toFixed(2)} -t ${duration} -i "${videoPath}" ` +
|
||||
`-vf "${filters},split[s0][s1];[s0]palettegen=max_colors=${GIF_COLORS}:reserve_transparent=0[p];` +
|
||||
`[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" "${gifPath}"`,
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Scene 1: subagent demo ──────────────────────────────────────────────────
|
||||
|
||||
const SUBAGENT_VIEWPORT = { width: 1440, height: 810 };
|
||||
|
||||
// Terminal content visible before the agents spawn
|
||||
const TERMINAL_PRESPAWN = [
|
||||
'',
|
||||
`${GRN}●${RST} Working on ${CYN}/home/arkon/codeman-cases/testcase${RST} - I'll use the ${BOLD}Task tool${RST} to spawn parallel agents.`,
|
||||
'',
|
||||
`${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`,
|
||||
` ${GRY}░${RST} Read ${BOLD}127${RST} lines ${GRY}│${RST} ${CYN}1.2KB${RST}`,
|
||||
'',
|
||||
`${GRN}●${RST} ${BOLD}Bash${RST}(find . -name "*.ts" -not -path "*/node_modules/*" | head -20)`,
|
||||
` ${GRY}░${RST} ./src/index.ts`,
|
||||
` ${GRY}░${RST} ./src/session.ts`,
|
||||
` ${GRY}░${RST} ./src/web/server.ts`,
|
||||
` ${GRY}░${RST} ${GRY}... (17 more)${RST}`,
|
||||
'',
|
||||
`${GRN}●${RST} I'll spawn 3 parallel research agents to analyze different parts of the codebase simultaneously.`,
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
function makeAgent(agentId, description, startedOffsetMs) {
|
||||
return {
|
||||
agentId,
|
||||
sessionId: 'claude-sess-w1-0001',
|
||||
projectHash: 'abc123',
|
||||
filePath: `/tmp/${agentId}.jsonl`,
|
||||
startedAt: new Date(Date.now() - startedOffsetMs).toISOString(),
|
||||
lastActivityAt: Date.now(),
|
||||
status: 'active',
|
||||
toolCallCount: 0,
|
||||
entryCount: 0,
|
||||
fileSize: 4000,
|
||||
description,
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
modelShort: 'haiku',
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
parentSessionId: SESSION_IDS.w1,
|
||||
};
|
||||
}
|
||||
|
||||
// Timeline events: t (ms from scene start) + kind
|
||||
// term — write raw data to the session terminal
|
||||
// discover — register subagent + open + position its floating window
|
||||
// tool — stream a tool call into an agent window
|
||||
// msg — stream an assistant message into an agent window
|
||||
// complete — flip an agent to completed
|
||||
function buildSubagentTimeline() {
|
||||
const T = (lines) => lines.join('\r\n') + '\r\n';
|
||||
const tool = (t, agentId, name, input) => ({ t, kind: 'tool', agentId, tool: name, input });
|
||||
const msg = (t, agentId, text) => ({ t, kind: 'msg', agentId, text });
|
||||
|
||||
return [
|
||||
{
|
||||
t: 600,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${BOLD}Task${RST}(Find and document all API endpoints in src/)`,
|
||||
` ${GRY}░${RST} Spawned ${CYN}agent-001${RST} ${GRY}(haiku)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
{
|
||||
t: 1000,
|
||||
kind: 'discover',
|
||||
agent: makeAgent('agent-001', 'Find and document all API endpoints in src/', 2000),
|
||||
x: 440, y: 45,
|
||||
},
|
||||
tool(1500, 'agent-001', 'Glob', { pattern: 'src/**/*.ts' }),
|
||||
{
|
||||
t: 2000,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${BOLD}Task${RST}(Explore and understand test structure in test/)`,
|
||||
` ${GRY}░${RST} Spawned ${CYN}agent-002${RST} ${GRY}(haiku)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(2200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/server.ts' }),
|
||||
{
|
||||
t: 2500,
|
||||
kind: 'discover',
|
||||
agent: makeAgent('agent-002', 'Explore and understand test structure in test/', 1200),
|
||||
x: 880, y: 45,
|
||||
},
|
||||
tool(3000, 'agent-002', 'Glob', { pattern: 'test/**/*.test.ts' }),
|
||||
{
|
||||
t: 3300,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${BOLD}Task${RST}(Analyze TypeScript type definitions in src/types.ts)`,
|
||||
` ${GRY}░${RST} Spawned ${CYN}agent-003${RST} ${GRY}(haiku)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(3500, 'agent-001', 'Grep', { pattern: 'app\\.get|app\\.post|app\\.delete', path: 'src/' }),
|
||||
{
|
||||
t: 3800,
|
||||
kind: 'discover',
|
||||
agent: makeAgent('agent-003', 'Analyze TypeScript type definitions in src/types.ts', 400),
|
||||
x: 660, y: 400,
|
||||
},
|
||||
tool(4100, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }),
|
||||
{
|
||||
t: 4500,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${MAG}✻${RST} ${YEL}Waiting for agents...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 32s · ↓ 1.7k tokens · thinking)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(4700, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types.ts' }),
|
||||
tool(5200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/schemas.ts' }),
|
||||
tool(5600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/config/vitest.config.ts' }),
|
||||
tool(6100, 'agent-003', 'Grep', { pattern: 'export (interface|type)', path: 'src/types/' }),
|
||||
msg(6700, 'agent-001', 'Found 47 API endpoints across server.ts. Documenting REST paths...'),
|
||||
tool(7100, 'agent-002', 'Grep', { pattern: 'const PORT =', path: 'test/' }),
|
||||
msg(7700, 'agent-002', 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...'),
|
||||
tool(8100, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types/index.ts' }),
|
||||
msg(8700, 'agent-003', 'Mapped 38 exported interfaces across 15 domain files. Building summary...'),
|
||||
{
|
||||
t: 9300,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${CYN}agent-001${RST}: ${GRY}12 tool calls — Glob, Read(server.ts), Grep(endpoints)...${RST}`,
|
||||
`${GRN}●${RST} ${CYN}agent-002${RST}: ${GRY}8 tool calls — Glob, Read(test-utils), Read(vitest.config)...${RST}`,
|
||||
`${GRN}●${RST} ${CYN}agent-003${RST}: ${GRY}7 tool calls — Read(types.ts), Grep(interface)...${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(10100, 'agent-001', 'Glob', { pattern: 'src/web/routes/*.ts' }),
|
||||
tool(10600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/setup.ts' }),
|
||||
tool(11100, 'agent-003', 'Grep', { pattern: 'assertNever', path: 'src/' }),
|
||||
{
|
||||
t: 11600,
|
||||
kind: 'term',
|
||||
data: T([`${GRN}●${RST} ${GRY}171.8k, 13s${RST} ${GRY}│${RST} ${GRY}1.7k tokens${RST} ${GRY}│${RST} ${GRY}thinking${RST}`, '']),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const SUBAGENT_TAIL_HOLD = 2500; // hold the final frame
|
||||
|
||||
async function recordSubagentScene(browser, videoDir) {
|
||||
console.log('\n1/2 Recording subagent-demo...');
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: SUBAGENT_VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
recordVideo: { dir: videoDir, size: SUBAGENT_VIEWPORT },
|
||||
});
|
||||
const recStart = Date.now();
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
// Start with NO subagents — they appear during the recording
|
||||
const initPayload = buildInitPayload(STANDARD_SESSIONS);
|
||||
await setupRoutes(page, initPayload, TERMINAL_PRESPAWN);
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await injectState(page, initPayload, TERMINAL_PRESPAWN, SESSION_IDS.w1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
try { window.app?.fitAddon?.fit(); } catch {}
|
||||
window.app?.terminal?.scrollToBottom();
|
||||
});
|
||||
await sleep(500);
|
||||
|
||||
const timeline = buildSubagentTimeline();
|
||||
const totalMs = Math.max(...timeline.map((e) => e.t)) + SUBAGENT_TAIL_HOLD;
|
||||
const sceneStart = Date.now();
|
||||
|
||||
// Run the whole timeline inside the page so events interleave naturally
|
||||
await page.evaluate((events) => {
|
||||
const app = window.app;
|
||||
for (const ev of events) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (ev.kind === 'term') {
|
||||
app.terminal.write(ev.data);
|
||||
app.terminal.scrollToBottom();
|
||||
} else if (ev.kind === 'discover') {
|
||||
app._onSubagentDiscovered(ev.agent);
|
||||
app.openSubagentWindow(ev.agent.agentId);
|
||||
// The spawn animation (400ms) lands on the auto-grid; glide to our tile after it
|
||||
setTimeout(() => {
|
||||
const win = app.subagentWindows.get(ev.agent.agentId);
|
||||
if (win?.element) {
|
||||
win.element.style.transition = 'left 0.25s ease, top 0.25s ease';
|
||||
win.element.style.left = `${ev.x}px`;
|
||||
win.element.style.top = `${ev.y}px`;
|
||||
}
|
||||
}, 520);
|
||||
setTimeout(() => {
|
||||
const win = app.subagentWindows.get(ev.agent.agentId);
|
||||
if (win?.element) win.element.style.transition = '';
|
||||
app.updateConnectionLines();
|
||||
}, 850);
|
||||
} else if (ev.kind === 'tool') {
|
||||
app._onSubagentToolCall({
|
||||
agentId: ev.agentId,
|
||||
tool: ev.tool,
|
||||
input: ev.input,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else if (ev.kind === 'msg') {
|
||||
app._onSubagentMessage({
|
||||
agentId: ev.agentId,
|
||||
role: 'assistant',
|
||||
text: ev.text,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else if (ev.kind === 'complete') {
|
||||
app._onSubagentCompleted({ agentId: ev.agentId, timestamp: new Date().toISOString() });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('timeline event failed', ev, err);
|
||||
}
|
||||
}, ev.t);
|
||||
}
|
||||
}, timeline);
|
||||
|
||||
await sleep(totalMs + 500);
|
||||
|
||||
await page.close();
|
||||
const videoPath = await page.video().path();
|
||||
await context.close();
|
||||
|
||||
return {
|
||||
videoPath,
|
||||
ss: (sceneStart - recStart) / 1000 - 0.4,
|
||||
duration: (totalMs + 400) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Scene 2: zerolag typing comparison ──────────────────────────────────────
|
||||
|
||||
const ZEROLAG_VIEWPORT = { width: 1280, height: 470 };
|
||||
const TYPED_TEXT = 'echo "zero lag typing from anywhere"';
|
||||
const TYPE_INTERVAL_MS = 110;
|
||||
const REMOTE_FLUSH_MS = 350; // server-echo pane flushes queued chars in bursts
|
||||
const ZEROLAG_TAIL_HOLD = 1800;
|
||||
|
||||
const ZEROLAG_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="http://localhost:${PORT}/vendor/xterm.css">
|
||||
<script src="http://localhost:${PORT}/vendor/xterm.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 1280px; height: 470px; background: #0a0a0c;
|
||||
display: flex; align-items: center; justify-content: center; gap: 48px;
|
||||
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.pane { width: 560px; }
|
||||
.card {
|
||||
background: #131316; border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
|
||||
}
|
||||
.card-head {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; align-self: center; }
|
||||
.title { font-size: 15px; font-weight: 600; color: #e8e8ea; }
|
||||
.sub { font-size: 12.5px; color: #8b8b92; }
|
||||
.term { padding: 16px 8px 12px 16px; height: 165px; }
|
||||
.good .dot { background: #22c55e; box-shadow: 0 0 8px rgba(34,197,94,0.7); }
|
||||
.bad .dot { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,0.7); }
|
||||
.tag {
|
||||
margin-top: 14px; text-align: center; font-size: 14.5px; color: #7e7e86;
|
||||
}
|
||||
.tag b { color: #22c55e; font-weight: 600; }
|
||||
.bad-tag b { color: #ef4444; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pane">
|
||||
<div class="card good">
|
||||
<div class="card-head">
|
||||
<span class="dot"></span>
|
||||
<span class="title">With zerolag-input</span>
|
||||
<span class="sub">instant local echo</span>
|
||||
</div>
|
||||
<div class="term" id="termLeft"></div>
|
||||
</div>
|
||||
<div class="tag">keystrokes echo in <b>0 ms</b></div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<div class="card bad">
|
||||
<div class="card-head">
|
||||
<span class="dot"></span>
|
||||
<span class="title">Without</span>
|
||||
<span class="sub">server round-trip echo</span>
|
||||
</div>
|
||||
<div class="term" id="termRight"></div>
|
||||
</div>
|
||||
<div class="tag bad-tag">keystrokes echo after <b>~350 ms</b></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
async function recordZerolagScene(browser, videoDir) {
|
||||
console.log('\n2/2 Recording zerolag-demo...');
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: ZEROLAG_VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
recordVideo: { dir: videoDir, size: ZEROLAG_VIEWPORT },
|
||||
});
|
||||
const recStart = Date.now();
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
await page.setContent(ZEROLAG_HTML, { waitUntil: 'load' });
|
||||
await page.waitForFunction(() => typeof Terminal !== 'undefined');
|
||||
|
||||
await page.evaluate(() => {
|
||||
const theme = {
|
||||
background: '#131316',
|
||||
foreground: '#e8e8ea',
|
||||
cursor: '#22c55e',
|
||||
cursorAccent: '#131316',
|
||||
};
|
||||
const mk = (id) => {
|
||||
const term = new Terminal({
|
||||
cols: 44,
|
||||
rows: 5,
|
||||
fontSize: 20,
|
||||
fontFamily: "'SF Mono', 'Cascadia Code', Menlo, monospace",
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
theme,
|
||||
});
|
||||
term.open(document.getElementById(id));
|
||||
term.write('\x1b[32m❯\x1b[0m ');
|
||||
return term;
|
||||
};
|
||||
window.termLeft = mk('termLeft');
|
||||
window.termRight = mk('termRight');
|
||||
});
|
||||
await sleep(600);
|
||||
|
||||
const sceneStart = Date.now();
|
||||
const typingMs = TYPED_TEXT.length * TYPE_INTERVAL_MS;
|
||||
const totalMs = typingMs + REMOTE_FLUSH_MS + ZEROLAG_TAIL_HOLD;
|
||||
|
||||
await page.evaluate(
|
||||
({ text, interval, flushEvery }) => {
|
||||
let i = 0;
|
||||
const remoteQueue = [];
|
||||
const typer = setInterval(() => {
|
||||
if (i >= text.length) { clearInterval(typer); return; }
|
||||
const ch = text[i++];
|
||||
window.termLeft.write(ch); // local echo: instant
|
||||
remoteQueue.push(ch); // server echo: waits for the round-trip
|
||||
}, interval);
|
||||
const flusher = setInterval(() => {
|
||||
if (remoteQueue.length) window.termRight.write(remoteQueue.splice(0).join(''));
|
||||
if (i >= text.length && remoteQueue.length === 0) clearInterval(flusher);
|
||||
}, flushEvery);
|
||||
},
|
||||
{ text: TYPED_TEXT, interval: TYPE_INTERVAL_MS, flushEvery: REMOTE_FLUSH_MS }
|
||||
);
|
||||
|
||||
await sleep(totalMs + 400);
|
||||
|
||||
await page.close();
|
||||
const videoPath = await page.video().path();
|
||||
await context.close();
|
||||
|
||||
return {
|
||||
videoPath,
|
||||
ss: (sceneStart - recStart) / 1000 - 0.6, // small lead-in with idle cursors
|
||||
duration: (totalMs + 600) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log('='.repeat(60));
|
||||
console.log('Codeman README GIF Capture');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const server = await startStaticServer();
|
||||
const videoDir = mkdtempSync(join(tmpdir(), 'codeman-gifs-'));
|
||||
let browser;
|
||||
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
||||
});
|
||||
|
||||
const sub = await recordSubagentScene(browser, videoDir);
|
||||
const subGif = outPath('images', 'subagent-demo.gif');
|
||||
webmToGif(sub.videoPath, subGif, { ss: Math.max(0, sub.ss), duration: sub.duration, width: 960, fps: 8 });
|
||||
console.log(` Saved: ${subGif}`);
|
||||
|
||||
const zl = await recordZerolagScene(browser, videoDir);
|
||||
const zlGif = outPath('images', 'zerolag-demo.gif');
|
||||
webmToGif(zl.videoPath, zlGif, { ss: Math.max(0, zl.ss), duration: zl.duration, width: 900, fps: 10 });
|
||||
console.log(` Saved: ${zlGif}`);
|
||||
|
||||
console.log('\nDone.');
|
||||
} catch (err) {
|
||||
console.error('\nFatal error:', err.message);
|
||||
console.error(err.stack);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
server.close();
|
||||
rmSync(videoDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => process.exit(1));
|
||||
|
||||
main();
|
||||
@@ -1,253 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* capture-readme-real.mjs
|
||||
*
|
||||
* Captures README desktop scenes (multi-session dashboard, monitor, subagent
|
||||
* windows) from a REAL Codeman instance — intended to run against an ISOLATED
|
||||
* dev/beta instance (CODEMAN_INSTANCE=beta on :5000) seeded from prod's settings,
|
||||
* NOT prod itself (never touch prod's live sessions).
|
||||
*
|
||||
* Reuses the high-quality capture recipe proven in capture-real-overview.mjs:
|
||||
* - DSF=2 + ?nowebgl → crisp retina at the TRUE font size (WebGL doubles
|
||||
* glyphs under DSF=2; the DOM renderer respects devicePixelRatio).
|
||||
* - per-device localStorage seeding so the capture matches a real device.
|
||||
*
|
||||
* SCENE=dashboard|monitor|subagent|all BASE=http://localhost:5000 \
|
||||
* OUT=screenshots-readme-real/desktop node scripts/capture-readme-real.mjs
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const OUT = process.env.OUT || 'screenshots-readme-real/desktop';
|
||||
const SKIN = process.env.SKIN || 'daylight-blue';
|
||||
const SCENE = process.env.SCENE || 'all';
|
||||
const FONT = Math.max(10, Math.min(24, Number(process.env.FONT || 13)));
|
||||
const VIEWPORT = { width: Number(process.env.VW || 1280), height: Number(process.env.VH || 720) };
|
||||
const DSF = Number(process.env.DSF || 2);
|
||||
const PLAN_USAGE = process.env.PLAN_USAGE !== '0';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const url = (extra = '') => {
|
||||
const sep = BASE.includes('?') ? '&' : '?';
|
||||
const params = [];
|
||||
if (DSF > 1) params.push('nowebgl'); // DOM renderer → correct font size at DSF>1
|
||||
if (extra) params.push(extra);
|
||||
return params.length ? `${BASE}${sep}${params.join('&')}` : BASE;
|
||||
};
|
||||
|
||||
async function newCtx(browser) {
|
||||
const context = await browser.newContext({
|
||||
viewport: VIEWPORT,
|
||||
deviceScaleFactor: DSF,
|
||||
ignoreHTTPSErrors: BASE.startsWith('https'),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
await page.addInitScript(
|
||||
([skin, planUsage, font]) => {
|
||||
try {
|
||||
localStorage.setItem('codeman:skin', skin);
|
||||
localStorage.setItem('codeman-font-size', String(font));
|
||||
const blob = { skin, showFileBrowser: false, showProjectInsights: false, showTokenCount: false };
|
||||
// Don't auto-hide subagent windows that belong to a non-active tab — the
|
||||
// subagent scene re-homes agents and needs both windows visible at once.
|
||||
blob.subagentActiveTabOnly = false;
|
||||
if (planUsage) blob.showPlanUsageLimits = true;
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify(blob));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[SKIN, PLAN_USAGE, FONT]
|
||||
);
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
async function bootstrap(page) {
|
||||
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 20000 });
|
||||
await sleep(1200);
|
||||
}
|
||||
|
||||
async function listSessions(page) {
|
||||
return page.evaluate(() =>
|
||||
Array.from(window.app.sessions.values()).map((s) => ({ id: s.id, name: s.name, mode: s.mode }))
|
||||
);
|
||||
}
|
||||
|
||||
async function shoot(page, name) {
|
||||
const out = join(OUT, name);
|
||||
await page.evaluate((f) => {
|
||||
try {
|
||||
if (window.app.setFontSize) window.app.setFontSize(f);
|
||||
} catch {}
|
||||
try {
|
||||
window.app.fitAddon && window.app.fitAddon.fit();
|
||||
} catch {}
|
||||
try {
|
||||
window.app.applyHeaderVisibilitySettings && window.app.applyHeaderVisibilitySettings();
|
||||
} catch {}
|
||||
}, FONT);
|
||||
await sleep(1500);
|
||||
await page.screenshot({ path: out, fullPage: false });
|
||||
console.log(' Saved: ' + out);
|
||||
}
|
||||
|
||||
async function sceneDashboard(browser) {
|
||||
console.log('Scene: dashboard');
|
||||
const { context, page } = await newCtx(browser);
|
||||
await page.goto(url(), { waitUntil: 'domcontentloaded' });
|
||||
await bootstrap(page);
|
||||
const sessions = await listSessions(page);
|
||||
// Select a claude session so the active terminal shows rich content; all tabs render.
|
||||
const target = sessions.find((s) => s.mode === 'claude') || sessions[0];
|
||||
if (target) await page.evaluate((id) => window.app.selectSession(id), target.id);
|
||||
await sleep(4000);
|
||||
await shoot(page, 'multi-session-dashboard.png');
|
||||
await context.close();
|
||||
}
|
||||
|
||||
async function sceneMonitor(browser) {
|
||||
console.log('Scene: monitor');
|
||||
const { context, page } = await newCtx(browser);
|
||||
await page.goto(url(), { waitUntil: 'domcontentloaded' });
|
||||
await bootstrap(page);
|
||||
const sessions = await listSessions(page);
|
||||
const target = sessions.find((s) => s.mode === 'claude') || sessions[0];
|
||||
if (target) await page.evaluate((id) => window.app.selectSession(id), target.id);
|
||||
await sleep(2500);
|
||||
// toggleMonitorPanel() opens the panel, clears the hidden state, loads REAL
|
||||
// mux sessions (/api/mux), starts stats, and renders the task panel.
|
||||
await page.evaluate(async () => {
|
||||
try {
|
||||
await window.app.toggleMonitorPanel();
|
||||
} catch {}
|
||||
});
|
||||
await sleep(3000);
|
||||
await shoot(page, 'multi-session-monitor.png');
|
||||
await context.close();
|
||||
}
|
||||
|
||||
async function sceneSubagent(browser) {
|
||||
console.log('Scene: subagent');
|
||||
const { context, page } = await newCtx(browser);
|
||||
await page.goto(url(), { waitUntil: 'domcontentloaded' });
|
||||
await bootstrap(page);
|
||||
// Select the session whose subagents we want (subagentActiveTabOnly means
|
||||
// app.subagents only fills for the active tab). Prefer SUBAGENT_SID env.
|
||||
const sessions = await listSessions(page);
|
||||
const targetId = process.env.SUBAGENT_SID || (sessions.find((s) => s.mode === 'claude') || sessions[0])?.id;
|
||||
if (targetId) await page.evaluate((id) => window.app.selectSession(id), targetId);
|
||||
// Wait (up to ~45s) for live subagents to arrive via SSE into app.subagents.
|
||||
let agents = [];
|
||||
for (let i = 0; i < 45; i++) {
|
||||
agents = await page.evaluate(() =>
|
||||
Array.from(window.app.subagents?.entries?.() || []).map(([id, a]) => ({ id, name: a.name ?? a.agentType ?? '' }))
|
||||
);
|
||||
if (agents.length >= 1) break;
|
||||
await sleep(1000);
|
||||
}
|
||||
console.log(' live in-browser subagents:', JSON.stringify(agents));
|
||||
if (agents.length === 0) {
|
||||
console.log(' NO live subagents — skipping (stage a longer subagent task and run this while it runs).');
|
||||
await context.close();
|
||||
return;
|
||||
}
|
||||
// The window body renders from app.subagentActivity, which fills ONLY from live
|
||||
// SSE tool-call/progress events — a fresh client never gets past activity replayed.
|
||||
// So sit connected and wait for live activity to accumulate, then open the two
|
||||
// agents that actually have content (otherwise the windows read "No activity yet").
|
||||
let active = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
active = await page.evaluate(() =>
|
||||
Array.from(window.app.subagentActivity?.entries?.() || [])
|
||||
.filter(([, arr]) => Array.isArray(arr) && arr.length >= 1)
|
||||
.map(([id, arr]) => ({ id, n: arr.length }))
|
||||
.sort((a, b) => b.n - a.n)
|
||||
);
|
||||
if (active.length >= 2) break;
|
||||
// xhigh-effort agents churn in bursts between long thinking pauses, so be
|
||||
// patient (~150s); accept a single populated window after ~45s if that's all.
|
||||
if (i >= 30 && active.length >= 1) break;
|
||||
await sleep(1500);
|
||||
}
|
||||
console.log(' agents with live activity:', JSON.stringify(active));
|
||||
const openIds = (active.length ? active : agents).map((a) => a.id);
|
||||
// Capture-only DOM nudge: on fresh dev sessions, a tab's claudeSessionId stays the
|
||||
// Codeman id and never becomes the real Claude conversation UUID, so the window
|
||||
// open-gate (claudeSessionId === agent.sessionId) + the activeTabOnly hide rule both
|
||||
// fail. Re-home the chosen agents onto the active tab and align its claudeSessionId
|
||||
// to the agents' (shared) sessionId so the windows open AND show their live activity.
|
||||
await page.evaluate(
|
||||
(ids) => {
|
||||
const activeId = window.app.activeSessionId;
|
||||
const tab = window.app.sessions.get(activeId);
|
||||
ids.slice(0, 2).forEach((id) => {
|
||||
const a = window.app.subagents.get(id);
|
||||
if (!a) return;
|
||||
a.parentSessionId = activeId;
|
||||
if (tab && a.sessionId) tab.claudeSessionId = a.sessionId;
|
||||
});
|
||||
},
|
||||
openIds
|
||||
);
|
||||
await page.evaluate(
|
||||
(ids) => {
|
||||
ids.slice(0, 2).forEach((id) => {
|
||||
try {
|
||||
window.app.openSubagentWindow(id);
|
||||
} catch {}
|
||||
});
|
||||
},
|
||||
openIds
|
||||
);
|
||||
await sleep(2000);
|
||||
await page.evaluate(() => {
|
||||
// Viewport-relative tiling: center two subagent windows over the terminal so
|
||||
// the layout adapts to whatever VW/VH the capture uses (e.g. the HQ 1100×650
|
||||
// recipe) instead of overflowing at narrower widths.
|
||||
const wins = Array.from(window.app.subagentWindows.values());
|
||||
const W = window.innerWidth;
|
||||
const H = window.innerHeight;
|
||||
const winW = Math.min(440, Math.floor((W - 60) / 2 - 10));
|
||||
const winH = Math.min(360, Math.floor(H * 0.56));
|
||||
const top = Math.floor(H * 0.16);
|
||||
const gap = 16;
|
||||
const totalW = winW * 2 + gap;
|
||||
const startLeft = Math.max(16, Math.floor((W - totalW) / 2));
|
||||
wins.slice(0, 2).forEach((win, i) => {
|
||||
const el = win.element;
|
||||
// Force visible: a freshly opened window may be hidden by the activeTabOnly
|
||||
// rule before we override it (we also seed subagentActiveTabOnly:false).
|
||||
win.hidden = false;
|
||||
win.minimized = false;
|
||||
el.style.display = 'flex';
|
||||
el.style.left = startLeft + i * (winW + gap) + 'px';
|
||||
el.style.top = top + 'px';
|
||||
el.style.width = winW + 'px';
|
||||
el.style.height = winH + 'px';
|
||||
});
|
||||
});
|
||||
await sleep(1500);
|
||||
await shoot(page, 'subagent-spawn.png');
|
||||
await context.close();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
||||
});
|
||||
console.log(`BASE=${BASE} SKIN=${SKIN} DSF=${DSF} VIEWPORT=${VIEWPORT.width}x${VIEWPORT.height} SCENE=${SCENE}`);
|
||||
if (SCENE === 'dashboard' || SCENE === 'all') await sceneDashboard(browser);
|
||||
if (SCENE === 'monitor' || SCENE === 'all') await sceneMonitor(browser);
|
||||
if (SCENE === 'subagent' || SCENE === 'all') await sceneSubagent(browser);
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('FATAL', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,731 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* capture-video-screenshots.mjs
|
||||
*
|
||||
* Captures real Codeman UI screenshots for the Remotion demo video.
|
||||
* Uses Playwright + page.route() mock injection (same pattern as capture-readme-screenshots.mjs).
|
||||
* No real Claude CLI or server needed — all API responses are mocked.
|
||||
*
|
||||
* Usage: node scripts/capture-video-screenshots.mjs
|
||||
* Port: 3198 (static file server)
|
||||
* Output: scripts/scripts/remotion/public/ (6 PNGs)
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { createServer } from 'http';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join, extname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PROJECT_ROOT = join(__dirname, '..');
|
||||
const PUBLIC_DIR = join(PROJECT_ROOT, 'src', 'web', 'public');
|
||||
const OUTPUT_DIR = join(PROJECT_ROOT, 'scripts', 'remotion', 'public');
|
||||
const PORT = 3198;
|
||||
|
||||
const DESKTOP_VIEWPORT = { width: 1920, height: 1080 };
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ─── MIME Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff2': 'font/woff2',
|
||||
'.woff': 'font/woff',
|
||||
'.ttf': 'font/ttf',
|
||||
};
|
||||
|
||||
// ─── Static File Server ──────────────────────────────────────────────────────
|
||||
|
||||
function startStaticServer() {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer((req, res) => {
|
||||
let urlPath = req.url.split('?')[0];
|
||||
if (urlPath === '/') urlPath = '/index.html';
|
||||
|
||||
const filePath = join(PUBLIC_DIR, urlPath);
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = readFileSync(filePath);
|
||||
const ext = extname(filePath);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
res.end(data);
|
||||
} catch {
|
||||
res.writeHead(500);
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Static server on http://localhost:${PORT}`);
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Mock Data ───────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSession(id, name, mode, status, extra = {}) {
|
||||
return {
|
||||
id,
|
||||
pid: status === 'idle' ? null : 12345 + Math.floor(Math.random() * 10000),
|
||||
status,
|
||||
workingDir: '/home/user/my-project',
|
||||
currentTaskId: null,
|
||||
createdAt: Date.now() - 3600000,
|
||||
lastActivityAt: Date.now() - (status === 'idle' ? 60000 : 5000),
|
||||
name,
|
||||
mode,
|
||||
autoClearEnabled: false,
|
||||
autoClearThreshold: 140000,
|
||||
autoCompactEnabled: false,
|
||||
autoCompactThreshold: 110000,
|
||||
autoCompactPrompt: '',
|
||||
imageWatcherEnabled: false,
|
||||
totalCost: mode === 'claude' ? 0.12 : 0,
|
||||
inputTokens: mode === 'claude' ? 18000 : 0,
|
||||
outputTokens: mode === 'claude' ? 11500 : 0,
|
||||
ralphEnabled: false,
|
||||
niceEnabled: false,
|
||||
niceValue: 10,
|
||||
color: 'default',
|
||||
flickerFilterEnabled: false,
|
||||
cliVersion: '2.1.61',
|
||||
cliModel: mode === 'claude' ? 'Opus 4.6' : 'GPT-4o',
|
||||
cliAccountType: mode === 'claude' ? 'Claude Max' : 'API Key',
|
||||
cliLatestVersion: '2.1.61',
|
||||
messageCount: 15,
|
||||
isWorking: status === 'busy',
|
||||
lastPromptTime: Date.now() - 30000,
|
||||
bufferStats: {
|
||||
terminalBufferSize: 4096,
|
||||
textOutputSize: 2048,
|
||||
messageCount: 15,
|
||||
maxTerminalBuffer: 2097152,
|
||||
maxTextOutput: 1048576,
|
||||
maxMessages: 1000,
|
||||
},
|
||||
taskStats: { total: 0, running: 0, completed: 0, failed: 0 },
|
||||
taskTree: [],
|
||||
tokens: {
|
||||
input: mode === 'claude' ? 18000 : 0,
|
||||
output: mode === 'claude' ? 11500 : 0,
|
||||
total: mode === 'claude' ? 29500 : 0,
|
||||
},
|
||||
autoClear: { enabled: false, threshold: 140000 },
|
||||
nice: { enabled: false, niceValue: 10 },
|
||||
ralphLoop: null,
|
||||
ralphTodos: [],
|
||||
ralphTodoStats: { total: 0, completed: 0, percentComplete: 0 },
|
||||
respawnEnabled: false,
|
||||
respawnConfig: null,
|
||||
respawn: null,
|
||||
claudeSessionId: `claude-${id}`,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function buildInitPayload(sessions) {
|
||||
const respawnStatus = {};
|
||||
for (const s of sessions) {
|
||||
respawnStatus[s.id] = {
|
||||
state: 'idle',
|
||||
cycleCount: 0,
|
||||
lastActivityTime: Date.now(),
|
||||
timeSinceActivity: 0,
|
||||
promptDetected: false,
|
||||
workingDetected: false,
|
||||
detection: {},
|
||||
config: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
version: '0.1651',
|
||||
sessions,
|
||||
scheduledRuns: [],
|
||||
respawnStatus,
|
||||
globalStats: {
|
||||
totalInputTokens: 145000,
|
||||
totalOutputTokens: 87000,
|
||||
totalCost: 1.82,
|
||||
totalSessionsCreated: 12,
|
||||
firstRecordedAt: Date.now() - 86400000,
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
subagents: [],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Terminal Content (ANSI) ─────────────────────────────────────────────────
|
||||
|
||||
const RST = '\x1b[0m';
|
||||
const RED = '\x1b[31m';
|
||||
const GRN = '\x1b[32m';
|
||||
const YEL = '\x1b[33m';
|
||||
const MAG = '\x1b[35m';
|
||||
const GRY = '\x1b[90m';
|
||||
const BOLD = '\x1b[1m';
|
||||
const DIM = '\x1b[2m';
|
||||
|
||||
// Claude Code init banner
|
||||
const TERMINAL_CLAUDE = [
|
||||
'',
|
||||
` ${RED}████${RST} ${BOLD}Claude Code${RST} v2.1.61`,
|
||||
` ${RED}████${RST} ${GRY}Opus 4.6${RST} ${DIM}·${RST} ${GRY}Claude Max${RST}`,
|
||||
` ${RED}██${RST} ${RED}██${RST} ${GRY}~/my-project${RST}`,
|
||||
'',
|
||||
'',
|
||||
`${GRY}❯ Try "how do I log an error?"${RST}`,
|
||||
'',
|
||||
'',
|
||||
`${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`,
|
||||
` ${BOLD}0 tokens${RST}`,
|
||||
` ${GRY}current: 2.1.61${RST} ${DIM}·${RST} ${GRY}latest: 2.1.61${RST}`,
|
||||
].join('\r\n');
|
||||
|
||||
// OpenCode init banner
|
||||
const TERMINAL_OPENCODE = [
|
||||
'',
|
||||
` ${GRN}████${RST} ${BOLD}OpenCode${RST} v0.3.12`,
|
||||
` ${GRN}████${RST} ${GRY}GPT-4o${RST} ${DIM}·${RST} ${GRY}API Key${RST}`,
|
||||
` ${GRN}██${RST} ${GRN}██${RST} ${GRY}~/my-project${RST}`,
|
||||
'',
|
||||
'',
|
||||
`${GRY}❯ Try "explain this codebase"${RST}`,
|
||||
'',
|
||||
'',
|
||||
`${YEL}»»${RST} ${BOLD}bypass permissions on${RST}`,
|
||||
` ${BOLD}0 tokens${RST}`,
|
||||
].join('\r\n');
|
||||
|
||||
// ─── Route Interceptors ──────────────────────────────────────────────────────
|
||||
|
||||
async function setupRoutes(page, initPayload, terminalContent) {
|
||||
// Block SSE to prevent reconnection loops
|
||||
await page.route('**/api/events', async (route) => {
|
||||
await route.abort();
|
||||
});
|
||||
|
||||
// Terminal buffer endpoint
|
||||
await page.route('**/api/sessions/*/terminal**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
terminalBuffer: terminalContent,
|
||||
status: 'busy',
|
||||
fullSize: terminalContent.length,
|
||||
truncated: false,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Mux sessions
|
||||
await page.route('**/api/mux-sessions/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/mux-sessions', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ sessions: [], muxAvailable: true }),
|
||||
});
|
||||
});
|
||||
|
||||
// Settings
|
||||
await page.route('**/api/settings', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
showSubagents: false,
|
||||
subagentTrackingEnabled: false,
|
||||
subagentActiveTabOnly: false,
|
||||
showMonitor: false,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Subagent window states
|
||||
await page.route('**/api/subagent-window-states', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
|
||||
// Subagent parents
|
||||
await page.route('**/api/subagent-parents', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
|
||||
// Session-specific subagents
|
||||
await page.route('**/api/sessions/*/subagents', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true, data: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
// Interactive attach
|
||||
await page.route('**/api/sessions/*/interactive', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
});
|
||||
|
||||
// Resize
|
||||
await page.route('**/api/sessions/*/resize', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
});
|
||||
|
||||
// Catch-all API
|
||||
await page.route('**/api/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject mock state and render the app.
|
||||
*/
|
||||
async function injectState(page, initPayload, terminalContent, activeSessionId) {
|
||||
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 15000 });
|
||||
await sleep(2000);
|
||||
|
||||
// Cancel SSE fallback timer and inject state
|
||||
await page.evaluate((payload) => {
|
||||
const app = window.app;
|
||||
if (app._initFallbackTimer) {
|
||||
clearTimeout(app._initFallbackTimer);
|
||||
app._initFallbackTimer = null;
|
||||
}
|
||||
app.handleInit(payload);
|
||||
}, initPayload);
|
||||
|
||||
await sleep(3000);
|
||||
|
||||
// Select the target session
|
||||
if (activeSessionId) {
|
||||
await page.evaluate((sid) => {
|
||||
window.app.activeSessionId = null;
|
||||
window.app.selectSession(sid);
|
||||
}, activeSessionId);
|
||||
await sleep(4000);
|
||||
}
|
||||
|
||||
// Write terminal content directly as backup
|
||||
if (terminalContent && activeSessionId) {
|
||||
await page.evaluate((content) => {
|
||||
const app = window.app;
|
||||
if (app.terminal) {
|
||||
const buf = app.terminal.buffer?.active;
|
||||
const hasContent = buf && buf.length > 2 && buf.getLine(1)?.translateToString().trim();
|
||||
if (!hasContent) {
|
||||
app.terminal.clear();
|
||||
app.terminal.reset();
|
||||
app.terminal.write(content);
|
||||
app.terminal.scrollToBottom();
|
||||
}
|
||||
}
|
||||
}, terminalContent);
|
||||
await sleep(3000);
|
||||
}
|
||||
|
||||
// Clean up UI artifacts
|
||||
await page.evaluate(() => {
|
||||
const app = window.app;
|
||||
if (app) {
|
||||
if (app.sseReconnectTimeout) clearTimeout(app.sseReconnectTimeout);
|
||||
if (app.eventSource) { app.eventSource.close(); app.eventSource = null; }
|
||||
app._connectionStatus = 'connected';
|
||||
app._updateConnectionIndicator = () => {};
|
||||
app.connectSSE = () => {};
|
||||
app.setConnectionStatus = () => {};
|
||||
}
|
||||
|
||||
// Remove connection indicator
|
||||
const indicator = document.getElementById('connectionIndicator');
|
||||
if (indicator) indicator.remove();
|
||||
|
||||
// Hide respawn banner
|
||||
const respawnBanner = document.getElementById('respawnBanner');
|
||||
if (respawnBanner) respawnBanner.style.display = 'none';
|
||||
|
||||
// Mock CPU/MEM stats
|
||||
const statsEl = document.getElementById('headerSystemStats');
|
||||
if (statsEl) {
|
||||
statsEl.innerHTML = `
|
||||
<span class="stat-label">CPU</span>
|
||||
<div class="stat-bar"><div class="stat-bar-fill cpu-bar" style="width: 18%"></div></div>
|
||||
<span class="stat-value">18%</span>
|
||||
<span class="stat-label">MEM</span>
|
||||
<div class="stat-bar"><div class="stat-bar-fill mem-bar" style="width: 42%"></div></div>
|
||||
<span class="stat-value">13.7G</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// Hide subagents panel entirely
|
||||
const subagentsPanel = document.getElementById('subagentsPanel');
|
||||
if (subagentsPanel) subagentsPanel.style.display = 'none';
|
||||
|
||||
// Close and hide monitor panel
|
||||
const monitorPanel = document.getElementById('monitorPanel');
|
||||
if (monitorPanel) {
|
||||
monitorPanel.classList.remove('open');
|
||||
monitorPanel.style.display = 'none';
|
||||
}
|
||||
|
||||
// Hide monitor toggle button in toolbar
|
||||
const monitorBtn = document.getElementById('monitorToggle');
|
||||
if (monitorBtn) monitorBtn.style.display = 'none';
|
||||
|
||||
// Update token display
|
||||
if (app && app.activeSessionId) {
|
||||
const session = app.sessions.get(app.activeSessionId);
|
||||
if (session) {
|
||||
const tokens = (session.tokens?.total || 0);
|
||||
const tokenEl = document.getElementById('headerTokens');
|
||||
if (tokenEl) {
|
||||
const formatted = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
|
||||
tokenEl.innerHTML = `<span class="token-icon">⊙</span> <span class="token-count">${formatted} tokens</span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Final settle — let xterm.js WebGL renderer, fonts, and layout fully stabilize
|
||||
await sleep(4000);
|
||||
}
|
||||
|
||||
// ─── Screenshot Scenarios ────────────────────────────────────────────────────
|
||||
|
||||
// 1. Desktop welcome — no sessions, welcome overlay visible
|
||||
async function captureDesktopWelcome(browser) {
|
||||
console.log('\n1/6 Capturing desktop-welcome.png...');
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
try {
|
||||
const initPayload = buildInitPayload([]);
|
||||
await setupRoutes(page, initPayload, '');
|
||||
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 15000 });
|
||||
await sleep(2000);
|
||||
|
||||
// Inject empty state — welcome overlay should show automatically
|
||||
await page.evaluate((payload) => {
|
||||
const app = window.app;
|
||||
if (app._initFallbackTimer) {
|
||||
clearTimeout(app._initFallbackTimer);
|
||||
app._initFallbackTimer = null;
|
||||
}
|
||||
app.handleInit(payload);
|
||||
}, initPayload);
|
||||
await sleep(3000);
|
||||
|
||||
// Clean up UI
|
||||
await page.evaluate(() => {
|
||||
const app = window.app;
|
||||
if (app) {
|
||||
if (app.sseReconnectTimeout) clearTimeout(app.sseReconnectTimeout);
|
||||
if (app.eventSource) { app.eventSource.close(); app.eventSource = null; }
|
||||
app._connectionStatus = 'connected';
|
||||
app._updateConnectionIndicator = () => {};
|
||||
app.connectSSE = () => {};
|
||||
app.setConnectionStatus = () => {};
|
||||
}
|
||||
const indicator = document.getElementById('connectionIndicator');
|
||||
if (indicator) indicator.remove();
|
||||
const respawnBanner = document.getElementById('respawnBanner');
|
||||
if (respawnBanner) respawnBanner.style.display = 'none';
|
||||
|
||||
// Mock stats
|
||||
const statsEl = document.getElementById('headerSystemStats');
|
||||
if (statsEl) {
|
||||
statsEl.innerHTML = `
|
||||
<span class="stat-label">CPU</span>
|
||||
<div class="stat-bar"><div class="stat-bar-fill cpu-bar" style="width: 18%"></div></div>
|
||||
<span class="stat-value">18%</span>
|
||||
<span class="stat-label">MEM</span>
|
||||
<div class="stat-bar"><div class="stat-bar-fill mem-bar" style="width: 42%"></div></div>
|
||||
<span class="stat-value">13.7G</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// Hide monitor and subagent panels
|
||||
const subagentsPanel = document.getElementById('subagentsPanel');
|
||||
if (subagentsPanel) subagentsPanel.style.display = 'none';
|
||||
const monitorPanel = document.getElementById('monitorPanel');
|
||||
if (monitorPanel) {
|
||||
monitorPanel.classList.remove('open');
|
||||
monitorPanel.style.display = 'none';
|
||||
}
|
||||
const monitorBtn = document.getElementById('monitorToggle');
|
||||
if (monitorBtn) monitorBtn.style.display = 'none';
|
||||
});
|
||||
await sleep(3000);
|
||||
|
||||
await page.screenshot({
|
||||
path: join(OUTPUT_DIR, 'desktop-welcome.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: scripts/remotion/public/desktop-welcome.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Desktop with single Claude tab active
|
||||
async function captureDesktopClaude(browser) {
|
||||
console.log('\n2/6 Capturing desktop-claude.png...');
|
||||
|
||||
const claudeSession = makeSession('sess-claude-1', 'w1-my-project', 'claude', 'busy');
|
||||
const initPayload = buildInitPayload([claudeSession]);
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
try {
|
||||
await setupRoutes(page, initPayload, TERMINAL_CLAUDE);
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await injectState(page, initPayload, TERMINAL_CLAUDE, 'sess-claude-1');
|
||||
|
||||
await page.screenshot({
|
||||
path: join(OUTPUT_DIR, 'desktop-claude.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: scripts/remotion/public/desktop-claude.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Desktop with 2 tabs (Claude + OpenCode), Claude active
|
||||
async function captureDesktopBothClaude(browser) {
|
||||
console.log('\n3/6 Capturing desktop-both-claude.png...');
|
||||
|
||||
const claudeSession = makeSession('sess-claude-2', 'w1-my-project', 'claude', 'busy');
|
||||
const opencodeSession = makeSession('sess-opencode-2', 'w2-my-project', 'opencode', 'busy');
|
||||
const initPayload = buildInitPayload([claudeSession, opencodeSession]);
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
try {
|
||||
await setupRoutes(page, initPayload, TERMINAL_CLAUDE);
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await injectState(page, initPayload, TERMINAL_CLAUDE, 'sess-claude-2');
|
||||
|
||||
await page.screenshot({
|
||||
path: join(OUTPUT_DIR, 'desktop-both-claude.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: scripts/remotion/public/desktop-both-claude.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Desktop with 2 tabs (Claude + OpenCode), OpenCode active
|
||||
async function captureDesktopBothOpencode(browser) {
|
||||
console.log('\n4/6 Capturing desktop-both-opencode.png...');
|
||||
|
||||
const claudeSession = makeSession('sess-claude-3', 'w1-my-project', 'claude', 'busy');
|
||||
const opencodeSession = makeSession('sess-opencode-3', 'w2-my-project', 'opencode', 'busy');
|
||||
const initPayload = buildInitPayload([claudeSession, opencodeSession]);
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
try {
|
||||
await setupRoutes(page, initPayload, TERMINAL_OPENCODE);
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await injectState(page, initPayload, TERMINAL_OPENCODE, 'sess-opencode-3');
|
||||
|
||||
await page.screenshot({
|
||||
path: join(OUTPUT_DIR, 'desktop-both-opencode.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: scripts/remotion/public/desktop-both-opencode.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Mobile with Claude tab
|
||||
async function captureMobileClaude(browser) {
|
||||
console.log('\n5/6 Capturing mobile-claude.png...');
|
||||
|
||||
const claudeSession = makeSession('sess-claude-m1', 'w1-my-project', 'claude', 'busy');
|
||||
const initPayload = buildInitPayload([claudeSession]);
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: MOBILE_VIEWPORT,
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
try {
|
||||
await setupRoutes(page, initPayload, TERMINAL_CLAUDE);
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await injectState(page, initPayload, TERMINAL_CLAUDE, 'sess-claude-m1');
|
||||
|
||||
await page.screenshot({
|
||||
path: join(OUTPUT_DIR, 'mobile-claude.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: scripts/remotion/public/mobile-claude.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Mobile with OpenCode tab
|
||||
async function captureMobileOpencode(browser) {
|
||||
console.log('\n6/6 Capturing mobile-opencode.png...');
|
||||
|
||||
const opencodeSession = makeSession('sess-opencode-m1', 'w1-my-project', 'opencode', 'busy');
|
||||
const initPayload = buildInitPayload([opencodeSession]);
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: MOBILE_VIEWPORT,
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
try {
|
||||
await setupRoutes(page, initPayload, TERMINAL_OPENCODE);
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await injectState(page, initPayload, TERMINAL_OPENCODE, 'sess-opencode-m1');
|
||||
|
||||
await page.screenshot({
|
||||
path: join(OUTPUT_DIR, 'mobile-opencode.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: scripts/remotion/public/mobile-opencode.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log('='.repeat(60));
|
||||
console.log('Codeman Video Screenshot Capture');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`Port: ${PORT}`);
|
||||
console.log(`Desktop: ${DESKTOP_VIEWPORT.width}x${DESKTOP_VIEWPORT.height}`);
|
||||
console.log(`Mobile: ${MOBILE_VIEWPORT.width}x${MOBILE_VIEWPORT.height} @2x`);
|
||||
console.log(`Output: ${OUTPUT_DIR}`);
|
||||
console.log('');
|
||||
|
||||
const server = await startStaticServer();
|
||||
let browser;
|
||||
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
],
|
||||
});
|
||||
|
||||
await captureDesktopWelcome(browser);
|
||||
await captureDesktopClaude(browser);
|
||||
await captureDesktopBothClaude(browser);
|
||||
await captureDesktopBothOpencode(browser);
|
||||
await captureMobileClaude(browser);
|
||||
await captureMobileOpencode(browser);
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('All 6 screenshots captured!');
|
||||
console.log('='.repeat(60));
|
||||
console.log('\nOutput files:');
|
||||
console.log(' scripts/remotion/public/desktop-welcome.png');
|
||||
console.log(' scripts/remotion/public/desktop-claude.png');
|
||||
console.log(' scripts/remotion/public/desktop-both-claude.png');
|
||||
console.log(' scripts/remotion/public/desktop-both-opencode.png');
|
||||
console.log(' scripts/remotion/public/mobile-claude.png');
|
||||
console.log(' scripts/remotion/public/mobile-opencode.png');
|
||||
} catch (err) {
|
||||
console.error('\nFatal error:', err.message);
|
||||
console.error(err.stack);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
server.close();
|
||||
console.log('\nDone.');
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nInterrupted.');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
main();
|
||||
@@ -1,179 +0,0 @@
|
||||
/**
|
||||
* Manual verification harness for the session-sidebar feature.
|
||||
*
|
||||
* Renders the real UI in headless Chromium against a testMode WebServer,
|
||||
* injects a synthetic 25-session fleet, and screenshots every layout state.
|
||||
* Not part of the automated suite — run it by hand:
|
||||
*
|
||||
* npx tsx scripts/verify-session-sidebar.mts
|
||||
*
|
||||
* SAFETY: uses the repo's own test harness (temp HOME, testMode server) on a
|
||||
* dedicated port. It never touches a real Codeman instance or tmux socket.
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Mirror test/setup.ts: isolate HOME before the app modules touch state.
|
||||
process.env.HOME = mkdtempSync(join(tmpdir(), 'codeman-sidebar-verify-'));
|
||||
process.env.VITEST = 'true';
|
||||
|
||||
const PORT = 3299;
|
||||
const OUT = process.env.SIDEBAR_SHOTS_DIR ?? join(tmpdir(), 'codeman-sidebar-shots');
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
// Generic on purpose: these names end up in the harness screenshots, so they
|
||||
// should not carry one contributor's project list into everyone else's review.
|
||||
// The mix of CLI modes matters (each renders a different badge); the names do not.
|
||||
const PROJECTS = [
|
||||
['api-server', 'claude'],
|
||||
['web-client', 'claude'],
|
||||
['mobile-app', 'codex'],
|
||||
['data-pipeline', 'claude'],
|
||||
['shared-lib', 'gemini'],
|
||||
['codeman', 'claude'],
|
||||
['docs-site', 'claude'],
|
||||
['batch-jobs', 'opencode'],
|
||||
['search-index', 'claude'],
|
||||
];
|
||||
const STATUSES = ['idle', 'busy', 'idle', 'busy', 'error', 'idle'];
|
||||
|
||||
function fleet(n: number) {
|
||||
const out: any[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [proj, mode] = PROJECTS[i % PROJECTS.length];
|
||||
const status = STATUSES[i % STATUSES.length];
|
||||
out.push({
|
||||
id: `sess-${String(i).padStart(4, '0')}-aaaa-bbbb-cccc-dddddddddddd`,
|
||||
pid: 10000 + i,
|
||||
status,
|
||||
workingDir: `${tmpdir()}/projects/${proj}`,
|
||||
name: `${proj}${i > 8 ? '-' + Math.floor(i / 9) : ''}`,
|
||||
mode,
|
||||
currentTaskId: null,
|
||||
createdAt: Date.now() - i * 60000,
|
||||
lastActivityAt: Date.now() - i * 1000,
|
||||
isWorking: status === 'busy',
|
||||
messageCount: i * 3,
|
||||
totalCost: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
color: 'default',
|
||||
taskStats: { total: i % 4, running: i % 3 === 0 ? 2 : 0, completed: 0, failed: 0 },
|
||||
taskTree: [],
|
||||
tokens: { input: 0, output: 0, total: 0 },
|
||||
bufferStats: { terminalBufferSize: 0, textOutputSize: 0, messageCount: 0 },
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const SESSIONS = fleet(25);
|
||||
|
||||
async function main() {
|
||||
const server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const results: string[] = [];
|
||||
|
||||
async function shot(
|
||||
name: string,
|
||||
opts: { layout: 'header' | 'sidebar'; collapsed?: boolean; width: number; height: number; touch?: boolean }
|
||||
) {
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: opts.width, height: opts.height },
|
||||
hasTouch: !!opts.touch,
|
||||
isMobile: !!opts.touch,
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
const settings = JSON.stringify({ sessionListLayout: opts.layout });
|
||||
const collapsed = opts.collapsed === undefined ? null : opts.collapsed ? '1' : '0';
|
||||
await page.addInitScript(
|
||||
([s, c]) => {
|
||||
localStorage.setItem('codeman-app-settings', s as string);
|
||||
localStorage.setItem('codeman-app-settings-mobile', s as string);
|
||||
if (c !== null) localStorage.setItem('codeman-sidebar-collapsed', c as string);
|
||||
else localStorage.removeItem('codeman-sidebar-collapsed');
|
||||
},
|
||||
[settings, collapsed]
|
||||
);
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await page.evaluate((list) => {
|
||||
const app = (window as any).app;
|
||||
if (!app) throw new Error('no window.app');
|
||||
app.sessions.clear();
|
||||
for (const s of list as any[]) app.sessions.set(s.id, s);
|
||||
// The renderer iterates sessionOrder, not the map.
|
||||
app.sessionOrder = (list as any[]).map((s) => s.id);
|
||||
app.activeSessionId = (list as any[])[3].id;
|
||||
// renderSessionTabs() is debounced; drive the immediate path directly.
|
||||
(app._fullRenderSessionTabs ?? app._renderSessionTabsImmediate)?.call(app);
|
||||
app.applySessionListLayout?.();
|
||||
}, SESSIONS as any);
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
const info = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
const aside = document.getElementById('sessionSidebar');
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const asideBox = aside?.getBoundingClientRect();
|
||||
const cs = aside ? getComputedStyle(aside) : null;
|
||||
return {
|
||||
dataSessionList: root.dataset.sessionList ?? null,
|
||||
dataSidebar: root.dataset.sidebar ?? null,
|
||||
rows: document.querySelectorAll('.session-tab').length,
|
||||
tabsParent: tabsEl?.parentElement?.id || tabsEl?.parentElement?.className || null,
|
||||
asideWidth: asideBox ? Math.round(asideBox.width) : null,
|
||||
asideVisible: cs ? cs.display !== 'none' && cs.visibility !== 'hidden' : null,
|
||||
asideInert: aside?.hasAttribute('inert') ?? null,
|
||||
ariaHidden: aside?.getAttribute('aria-hidden') ?? null,
|
||||
toggleAriaExpanded: document.getElementById('sidebarToggleBtn')?.getAttribute('aria-expanded') ?? null,
|
||||
firstRowText:
|
||||
(document.querySelector('.session-tab') as HTMLElement | null)?.innerText
|
||||
?.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.slice(0, 40) ?? null,
|
||||
listScrollable: (() => {
|
||||
const el = document.getElementById('sessionTabs');
|
||||
return el ? el.scrollHeight > el.clientHeight + 2 : null;
|
||||
})(),
|
||||
};
|
||||
});
|
||||
|
||||
await page.waitForTimeout(400);
|
||||
const file = join(OUT, `${name}.png`);
|
||||
await page.screenshot({ path: file });
|
||||
results.push(`${name.padEnd(28)} ${JSON.stringify(info)}`);
|
||||
await ctx.close();
|
||||
return info;
|
||||
}
|
||||
|
||||
await shot('01-header-desktop', { layout: 'header', width: 1600, height: 900 });
|
||||
await shot('02-sidebar-expanded', { layout: 'sidebar', collapsed: false, width: 1600, height: 900 });
|
||||
await shot('03-sidebar-collapsed-rail', { layout: 'sidebar', collapsed: true, width: 1600, height: 900 });
|
||||
await shot('04-sidebar-narrow-1000', { layout: 'sidebar', collapsed: true, width: 1000, height: 800 });
|
||||
await shot('05-sidebar-drawer-open-1000', { layout: 'sidebar', collapsed: false, width: 1000, height: 800 });
|
||||
await shot('06-sidebar-phone-closed', { layout: 'sidebar', collapsed: true, width: 393, height: 852, touch: true });
|
||||
await shot('07-sidebar-phone-open', { layout: 'sidebar', collapsed: false, width: 393, height: 852, touch: true });
|
||||
|
||||
console.log('\n=== RESULTS ===');
|
||||
for (const r of results) console.log(r);
|
||||
console.log(`\nScreenshots in ${OUT}`);
|
||||
|
||||
await browser.close();
|
||||
await server.stop();
|
||||
}
|
||||
|
||||
main().then(
|
||||
() => process.exit(0),
|
||||
(e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
);
|
||||
Reference in new issue
Block a user