|
| 1 | +#!/usr/bin/env node |
| 2 | +/* Capture fresh screenshots of the TokenHUD board for og.png, board.png, |
| 3 | + and the GitHub social preview. Runs against the dev server with the |
| 4 | + self-host API stubbed so the admin shell renders with real-looking data. |
| 5 | +
|
| 6 | + Usage: npx playwright test --config='' -- node scripts/capture-screenshots.mjs |
| 7 | + Or: node scripts/capture-screenshots.mjs (needs playwright installed) |
| 8 | +*/ |
| 9 | + |
| 10 | +import { dirname, join } from 'path' |
| 11 | +import { chromium } from 'playwright' |
| 12 | +import { fileURLToPath } from 'url' |
| 13 | + |
| 14 | +const __dirname = dirname(fileURLToPath(import.meta.url)) |
| 15 | +const root = join(__dirname, '..') |
| 16 | + |
| 17 | +const DEV_PORT = process.env.PORT || '5176' |
| 18 | +const BASE = `http://localhost:${DEV_PORT}` |
| 19 | + |
| 20 | +/* ── mock data: realistic overview payload ─────────────────────────── */ |
| 21 | +const NOW = new Date().toISOString() |
| 22 | +const DAY = 86400e3 |
| 23 | + |
| 24 | +function mockOverview() { |
| 25 | + const byDay = Array.from({ length: 47 }, (_, i) => { |
| 26 | + const d = new Date(Date.now() - i * DAY) |
| 27 | + const p = n => String(n).padStart(2, '0') |
| 28 | + const tokens = Math.floor(800000 + Math.random() * 1600000) |
| 29 | + return { |
| 30 | + date: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`, |
| 31 | + tokens, |
| 32 | + estUSD: +(tokens * 0.000004).toFixed(2), |
| 33 | + sessions: Math.floor(1 + Math.random() * 4), |
| 34 | + toolCalls: Math.floor(200 + Math.random() * 800), |
| 35 | + messages: Math.floor(50 + Math.random() * 200), |
| 36 | + } |
| 37 | + }) |
| 38 | + |
| 39 | + return { |
| 40 | + generatedAt: NOW, |
| 41 | + hosts: [ |
| 42 | + { host: 'studio-mbp', label: 'studio-mbp', last_seen: NOW, agent_version: '0.2.6', ageSeconds: 12, status: 'up' }, |
| 43 | + { host: 'dev-linux', label: 'dev-linux', last_seen: new Date(Date.now() - 3600e3).toISOString(), agent_version: '0.2.6', ageSeconds: 3600, status: 'down' }, |
| 44 | + ], |
| 45 | + latest: [ |
| 46 | + { |
| 47 | + host: 'studio-mbp', |
| 48 | + agentVersion: '0.2.6', |
| 49 | + collectedAt: NOW, |
| 50 | + metrics: { |
| 51 | + host: { hostname: 'studio-mbp', os: 'Darwin', arch: 'arm64', cpus: 14 }, |
| 52 | + claude: { |
| 53 | + sessions: 59, |
| 54 | + messages: 62100, |
| 55 | + toolCalls: 28400, |
| 56 | + activeDays: 47, |
| 57 | + totalInputTokens: 31200000, |
| 58 | + totalOutputTokens: 59700000, |
| 59 | + totalCacheReadTokens: 890000000, |
| 60 | + totalCacheWriteTokens: 2100000, |
| 61 | + estValueUSD: 27400, |
| 62 | + models: [ |
| 63 | + { model: 'claude-sonnet-4-20250514', tokens: 41200000, estUSD: 14200 }, |
| 64 | + { model: 'claude-opus-4-20250918', tokens: 18400000, estUSD: 9800 }, |
| 65 | + { model: 'claude-haiku-4-20250912', tokens: 119000, estUSD: 23 }, |
| 66 | + ], |
| 67 | + byDay, |
| 68 | + }, |
| 69 | + processes: [ |
| 70 | + { pid: 90210, args: 'agent · explore', started: new Date(Date.now() - 7200e3).toISOString() }, |
| 71 | + { pid: 90445, args: 'agent · code', started: new Date(Date.now() - 1800e3).toISOString() }, |
| 72 | + { pid: 91002, args: 'agent · plan', started: new Date(Date.now() - 600e3).toISOString() }, |
| 73 | + ], |
| 74 | + usage: { |
| 75 | + available: true, |
| 76 | + session: { pct: 0.22, resetsAt: new Date(Date.now() + 14400e3).toISOString(), label: 'Session (5h)' }, |
| 77 | + weekly: { pct: 0.08, resetsAt: new Date(Date.now() + 432000e3).toISOString(), label: 'Weekly (7 day)' }, |
| 78 | + reportedAt: new Date(Date.now() - 300e3).toISOString(), |
| 79 | + }, |
| 80 | + assistants: [ |
| 81 | + { id: 'claude-code', name: 'Claude Code', hasData: true, supported: true, sessions: 59 }, |
| 82 | + { id: 'codex', name: 'Codex CLI', hasData: true, supported: true, sessions: 12 }, |
| 83 | + { id: 'cursor', name: 'Cursor', hasData: false, supported: false }, |
| 84 | + { id: 'gemini-cli', name: 'Gemini CLI', hasData: false, supported: false }, |
| 85 | + { id: 'windsurf', name: 'Windsurf', hasData: false, supported: false }, |
| 86 | + ], |
| 87 | + integrations: [ |
| 88 | + { id: 'claude-code', name: 'Claude Code', state: 'reading', headline: 'Read by this board.', installed: true, hasData: true, confidence: 'verified', steps: [], where: '~/.claude/projects/**/*.jsonl', fields: 'input, output, cache tokens per message', docs: null }, |
| 89 | + { id: 'codex', name: 'Codex CLI', state: 'reading', headline: 'Read by this board.', installed: true, hasData: true, confidence: 'verified', steps: [], where: '~/.codex/sessions/**/*.jsonl', fields: 'input, output tokens per session', docs: null }, |
| 90 | + { id: 'copilot-cli', name: 'GitHub Copilot CLI', state: 'reading', headline: 'Read by this board.', installed: true, hasData: true, confidence: 'verified', steps: ['Install the CLI', 'Sign in', 'Use it'], where: '~/.copilot/session-state/', fields: 'tokens per model, premium requests', docs: 'https://docs.github.com' }, |
| 91 | + { id: 'devin', name: 'Devin CLI', state: 'reading', headline: 'Read by this board.', installed: true, hasData: true, confidence: 'verified', steps: ['Install: curl -fsSL https://devin.ai/install.sh | sh', 'Sign in: devin login', 'Run a session'], where: '~/.local/share/devin/', fields: 'credits and ACU per session', docs: 'https://docs.devin.ai' }, |
| 92 | + { id: 'gemini-cli', name: 'Gemini CLI', state: 'needs-setup', headline: 'Installed. One step on this machine turns its numbers on.', installed: true, hasData: false, confidence: 'documented', steps: ['Open ~/.gemini/settings.json', 'Add telemetry block', 'Run gemini once'], where: '~/.gemini/telemetry.log', fields: 'token counts per API call', docs: 'https://github.com/google-gemini/gemini-cli' }, |
| 93 | + { id: 'cline', name: 'Cline', state: 'needs-setup', headline: 'Installed. Per-task tokens tracked by default.', installed: true, hasData: false, confidence: 'documented', steps: [], where: 'VS Code global storage', fields: 'tokensIn, tokensOut, cost', docs: null }, |
| 94 | + { id: 'cursor', name: 'Cursor', state: 'api-only', headline: 'Installed here, but it keeps no usage on this machine.', installed: true, hasData: false, confidence: 'documented', steps: [], where: 'API only', fields: 'per-request tokens with team key', docs: null }, |
| 95 | + { id: 'windsurf', name: 'Windsurf', state: 'api-only', headline: 'Installed here, but it keeps no usage on this machine.', installed: true, hasData: false, confidence: 'documented', steps: [], where: 'API only', fields: 'credits with team key', docs: null }, |
| 96 | + ], |
| 97 | + integrationSummary: { known: 26, reading: 4, ready: 0, needsSetup: 2, apiOnly: 8, installed: 10 }, |
| 98 | + governance: { mcp: { servers: 5 }, toolCalls: { allowed: 34 }, permissions: { auto: 12 }, extensions: { count: 19 } }, |
| 99 | + codex: { available: true }, |
| 100 | + projects: Array.from({ length: 21 }, (_, i) => ({ name: `project-${i + 1}` })), |
| 101 | + daemon: { running: true, pid: 1234, uptime: 432000 }, |
| 102 | + prompts: [], |
| 103 | + }, |
| 104 | + }, |
| 105 | + ], |
| 106 | + endings: [ |
| 107 | + { host: 'studio-mbp', pid: 90210, args: 'agent · explore', started: new Date(Date.now() - 9000e3).toISOString(), ended: new Date(Date.now() - 1800e3).toISOString(), noticed_at: NOW, duration: 7200 }, |
| 108 | + ], |
| 109 | + store: { snapshots: 4200 }, |
| 110 | + machines: [], |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +/* Stubs the self-host API so the board renders with data */ |
| 115 | +async function stubApi(page) { |
| 116 | + await page.route('**/api/v1/overview', route => |
| 117 | + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockOverview()) }) |
| 118 | + ) |
| 119 | + await page.route('**/api/v1/stream', route => |
| 120 | + route.fulfill({ status: 200, contentType: 'text/event-stream', body: '' }) |
| 121 | + ) |
| 122 | + await page.route('**/api/v1/stream-token', route => |
| 123 | + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ token: 'mock' }) }) |
| 124 | + ) |
| 125 | + await page.route('**/api/v1/portal-key', route => |
| 126 | + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ key: 'mock-key' }) }) |
| 127 | + ) |
| 128 | + /* Block the GitHub release check */ |
| 129 | + await page.route('**/api.github.com/**', route => |
| 130 | + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tag_name: 'v0.2.6' }) }) |
| 131 | + ) |
| 132 | +} |
| 133 | + |
| 134 | +async function capture() { |
| 135 | + const browser = await chromium.launch() |
| 136 | + const { copyFileSync } = await import('fs') |
| 137 | + |
| 138 | + /* ── 1. Self-host board screenshot (1440x900) ── */ |
| 139 | + const ctx = await browser.newContext({ |
| 140 | + viewport: { width: 1440, height: 900 }, |
| 141 | + deviceScaleFactor: 2, |
| 142 | + colorScheme: 'dark', |
| 143 | + }) |
| 144 | + const page = await ctx.newPage() |
| 145 | + await stubApi(page) |
| 146 | + |
| 147 | + await page.goto(BASE, { waitUntil: 'networkidle' }) |
| 148 | + /* Open the portal — CTA click */ |
| 149 | + await page.locator('.hero__actions .btn--primary').click() |
| 150 | + await page.waitForTimeout(500) |
| 151 | + /* If we see the sign-in overlay, click "use a local server instead" */ |
| 152 | + const selfHostBtn = page.locator('button:text-is("use a local server instead")') |
| 153 | + if (await selfHostBtn.count()) { |
| 154 | + console.log('Found self-host link, clicking...') |
| 155 | + await selfHostBtn.click() |
| 156 | + } |
| 157 | + /* Wait for the SelfHost admin shell to fully render with data */ |
| 158 | + console.log('Waiting for admin shell to appear...') |
| 159 | + await page.locator('.adm-root').waitFor({ timeout: 15000 }).catch(async () => { |
| 160 | + console.log('Admin shell timeout. DOM classes:', await page.evaluate(() => |
| 161 | + [...document.querySelectorAll('[class]')].slice(0, 30).map(e => e.className).join(' | ') |
| 162 | + )) |
| 163 | + }) |
| 164 | + await page.waitForTimeout(2000) |
| 165 | + |
| 166 | + /* Check if we actually got the admin shell */ |
| 167 | + const hasAdmin = await page.locator('.adm-root').count() |
| 168 | + if (hasAdmin > 0) { |
| 169 | + await page.screenshot({ path: join(root, 'public/board.png'), type: 'png' }) |
| 170 | + console.log('Captured: public/board.png (admin shell)') |
| 171 | + copyFileSync(join(root, 'public/board.png'), join(root, '..', 'docs/board.png')) |
| 172 | + console.log('Copied: docs/board.png') |
| 173 | + } else { |
| 174 | + console.log('Admin shell not visible, taking fallback screenshot') |
| 175 | + await page.screenshot({ path: join(root, 'public/board.png'), type: 'png' }) |
| 176 | + copyFileSync(join(root, 'public/board.png'), join(root, '..', 'docs/board.png')) |
| 177 | + } |
| 178 | + |
| 179 | + /* ── 2. OG image (1200x630) ── */ |
| 180 | + const ogCtx = await browser.newContext({ |
| 181 | + viewport: { width: 1200, height: 630 }, |
| 182 | + deviceScaleFactor: 2, |
| 183 | + colorScheme: 'dark', |
| 184 | + }) |
| 185 | + const ogPage = await ogCtx.newPage() |
| 186 | + await stubApi(ogPage) |
| 187 | + await ogPage.goto(BASE, { waitUntil: 'networkidle' }) |
| 188 | + const ogCta = ogPage.locator('.hero__actions .btn--primary') |
| 189 | + await ogCta.click() |
| 190 | + await ogPage.waitForTimeout(500) |
| 191 | + const ogSelfHost = ogPage.locator('text=use a local server instead') |
| 192 | + const ogHasSH = await ogSelfHost.count() |
| 193 | + if (ogHasSH) { |
| 194 | + await ogSelfHost.click() |
| 195 | + await ogPage.locator('.adm-root').waitFor({ timeout: 8000 }).catch(() => {}) |
| 196 | + } |
| 197 | + await ogPage.waitForTimeout(2500) |
| 198 | + await ogPage.screenshot({ path: join(root, 'public/og-board.png'), type: 'png' }) |
| 199 | + console.log('Captured: public/og-board.png (1200x630)') |
| 200 | + |
| 201 | + await browser.close() |
| 202 | + console.log('Done.') |
| 203 | +} |
| 204 | + |
| 205 | +capture().catch(e => { console.error(e); process.exit(1) }) |
0 commit comments