nyx/scripts/capture-screenshots.mjs

644 lines
23 KiB
JavaScript
Raw Normal View History

Prerelease cleanup (#46) * feat: Add const_bound_vars tracking to prevent false positives in ownership checks * feat: Introduce field interner and typed bounded vars for enhanced type tracking * feat: Add typed_call_receivers and typed_bounded_dto_fields for enhanced type tracking * feat: Centralize method name extraction with bare_method_name helper * feat: Implement Phase-6 hierarchy fan-out for runtime virtual dispatch * feat: Enhance C++ taint tracking with additional container operations and inline method resolution * feat: Introduce field-sensitive points-to analysis for enhanced resource tracking * feat: Implement Pointer-Phase 6 subscript handling for enhanced container analysis * test: Add comprehensive tests for JavaScript control flow constructs and lattice operations * docs: Update advanced analysis documentation with field-sensitive points-to and hierarchy fan-out details * test: Add comprehensive tests for lattice algebra laws and SSA edge cases * feat: Add destructured session user handling and safe user ID access patterns * feat: Implement row-population reverse-walk for enhanced authorization checks * feat: Enhance authorization checks with local alias chain for self-actor types * feat: Introduce ActiveRecord query safety checks and enhance snippet extraction * feat: Implement chained method call inner-gate rebinding for SSRF prevention * feat: Add observability and error modules, enhance debug functionality, and implement theme context * feat: Remove Auth Analysis page and update navigation to redirect to Explorer * feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor * feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor * feat: Reset path-safe-suppressed spans before lowering to maintain analysis integrity * fix(ssa): ungate debug_assert_bfs_ordering for release-tests build The helper at src/ssa/lower.rs was gated `#[cfg(debug_assertions)]` while the unit test at the bottom of the file was gated only `#[cfg(test)]`. Since `cfg(test)` is set in release builds with `--tests` but `cfg(debug_assertions)` is not, `cargo build --release --tests` failed with E0425. Removing the gate fixes the build; the body is `debug_assert!` only, so the helper is free in release. Also drop the gate at the call site to avoid a `dead_code` warning when the lib is built without `--tests`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(closure-capture): flip JS/TS fixtures to required-finding The JS and TS closure-capture fixtures pinned the old broken behaviour via `forbidden_findings: [{ "id_prefix": "taint-" }]`. The engine now correctly traces taint through the closure boundary (env source captured by an arrow function, sunk via `child_process.exec` inside the body), so the formerly-forbidden finding is a true positive. Match the Python sibling's shape — `required_findings` with `id_prefix` + `min_count` plus a small `noise_budget` — and rewrite the companion READMEs and the phase8_fragility_tests doc-comments from "known gap" to "regression guard". Verified: - cargo test --release --test phase8_fragility_tests → 8/8 pass - cargo test --release --lib bfs_assertion → pass - corpus benchmark F1 = 0.9976 (TP=205, FP=1, FN=0) — unchanged Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: Add OWASP mapping and baseline mutation hooks for enhanced security analysis * feat: Introduce health module and enhance health score computation with calibration tests * feat: Add expectations configuration and cleanup .gitignore for log files * feat: Implement theme selection and enhance settings panel for triage sync * feat: Suppress false positives for strcpy calls with literal sources in AST * feat: Update analyse_function_ssa to return body CFG for accurate analysis * feat: Add bug report and feature request templates for improved issue tracking * feat: removed dev scripts * feat: update README.md for clarity and consistency in fixture descriptions * feat: removed dev docs * feat: clean up error handling and UI elements for improved user experience * feat: adjust button sizes in HeaderBar for better UI consistency * feat: enhance taint analysis with additional context for sanitizer and taint findings * cargo fmt * prettier * refactor: simplify conditional checks and improve code readability in AST and screenshot capture scripts * feat: add script to frame PNG screenshots with brand gradient * feat: add fuzzing support with new targets and CI workflows * refactor: streamline match expressions and improve formatting in CLI and output handling * feat: enhance configuration display with detailed output options * feat: stage demo configuration for improved CLI screenshot output * feat: expose merge_configs function for user-configurable settings * refactor: simplify code structure and improve readability in config handling * refactor: improve descriptions for vulnerability patterns in various languages * feat: update MIT License section with additional usage details and copyright information * feat: update screenshots * refactor: update build process and paths for frontend assets * feat: add cross-file taint fuzzing target and supporting dictionary * refactor: clean up formatting and comments in fuzz configuration and example files * refactor: remove outdated comments and clean up CI configuration files * chore: update changelog dates and improve formatting in documentation * refactor: update Cargo.toml and CI configuration for improved packaging and build process * refactor: enhance quote-stripping logic to prevent panics and add regression tests --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:58:38 -04:00
#!/usr/bin/env node
/**
* Capture stills + a demo GIF of the Nyx dashboard for the README/docs.
*
* The demo source is embedded below (V1_SERVER + V2_SERVER) so the
* storyboard is reproducible from this file alone. V1 has 4 endpoints
* and yields ~6 findings (one of them a 5-hop CMDi taint flow that
* the GIF drills into); V2 keeps only that flow so scan #2 has fewer
* findings than scan #1 and the overview trend chart shows a
* downward slope.
*
* Phases:
* 1. setup write V1 to SCAN_ROOT, ensure server reachable
* 2. gif (opt) record the storyboard against a fresh DB; this
* also drives scan #1 via the UI
* 3. scan #1 if --gif didn't run, kick off scan #1 via API
* 4. stills/p1 capture pages whose content benefits from many
* findings (findings list, finding detail)
* 5. patch+scan2 overwrite SCAN_ROOT with V2 + run scan #2 via API
* 6. stills/p2 capture pages whose content benefits from a
* two-scan history (overview trend, scans list,
* scan detail) plus the static-ish ones
* (triage, explorer, rules, config)
* 7. frame composite the brand purple gradient around every
* captured PNG via scripts/frame-screenshots.py
*
* Prerequisites (script asserts each before starting):
* - playwright installed (npx playwright)
* - ffmpeg on PATH (palette-based GIF conversion)
* - python3 + Pillow on PATH (frame compositing)
* - nyx serve running on $NYX_URL (default http://127.0.0.1:9876)
* - the served scan root is empty of prior scans (system DB wiped)
*
* Usage:
* node scripts/capture-screenshots.mjs --stills # PNGs only
* node scripts/capture-screenshots.mjs --gif # GIF only
* node scripts/capture-screenshots.mjs --all # both, in one orchestrated run
*
* Output (under assets/screenshots/):
* demo.gif (~2530s walkthrough)
* overview.png (mirror of docs/serve-overview.png; used by README)
* docs/serve-overview.png (overview after scan #2 trend going down)
* docs/serve-findings-list.png (post-scan-#1 list with multiple highs)
* docs/serve-finding-detail.png (5-hop taint flow visualizer)
* docs/serve-triage.png
* docs/serve-explorer.png
* docs/serve-scans.png
* docs/serve-scan-detail.png
* docs/serve-rules.png
* docs/serve-config.png
*/
import { execFileSync } from 'node:child_process';
import {
copyFileSync,
existsSync,
mkdirSync,
rmSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import process from 'node:process';
const URL_BASE = process.env.NYX_URL || 'http://127.0.0.1:9876';
const SCAN_ROOT = process.env.SCAN_ROOT || '/tmp/nyx-demo-app';
const OUT_DIR = process.env.OUT_DIR || '/Users/elipeter/nyx/assets/screenshots';
const FRAMER = process.env.FRAMER || '/Users/elipeter/nyx/scripts/frame-screenshots.py';
const NYX_BIN = process.env.NYX_BIN || '/Users/elipeter/nyx/target/release/nyx';
const VIEW = { width: 1440, height: 900 };
const COLOR_SCHEME = 'light';
const args = new Set(process.argv.slice(2));
const wantStills = args.has('--stills') || args.has('--all');
const wantGif = args.has('--gif') || args.has('--all');
const wantCli = args.has('--cli') || args.has('--all');
if (!wantStills && !wantGif && !wantCli) {
console.error('usage: capture-screenshots.mjs [--stills|--gif|--cli|--all]');
process.exit(2);
}
mkdirSync(join(OUT_DIR, 'docs'), { recursive: true });
// Demo source ----------------------------------------------------------------
const V1_SERVER = `import express from 'express';
import { exec } from 'child_process';
import fs from 'fs';
const app = express();
app.use(express.json());
// Lookup endpoint. Multi-hop CMDi: req.params.user → trim → flag → cmd → exec.
app.get('/lookup/:user', (req, res) => {
const raw = req.params.user;
const cleaned = raw.trim();
const flag = \`--user=\${cleaned}\`;
const cmd = \`whois \${flag} --verbose\`;
exec(cmd, (err, stdout) => {
res.send(stdout);
});
});
// SSRF: req.query.url → fetch.
app.get('/proxy', async (req, res) => {
const target = req.query.url;
const response = await fetch(target);
const body = await response.text();
res.send(body);
});
// Path traversal / unsafe file read.
app.get('/file', (req, res) => {
const requested = req.query.path;
const body = fs.readFileSync(requested, 'utf8');
res.send(body);
});
// Login endpoint with weak (Math.random) session id.
app.post('/login', (req, res) => {
const sid = Math.random().toString(36).slice(2);
res.cookie('sid', sid).json({ ok: true });
});
app.listen(3000);
`;
const V2_SERVER = `import express from 'express';
import { exec } from 'child_process';
const app = express();
app.use(express.json());
// Lookup endpoint. Multi-hop CMDi: req.params.user → trim → flag → cmd → exec.
app.get('/lookup/:user', (req, res) => {
const raw = req.params.user;
const cleaned = raw.trim();
const flag = \`--user=\${cleaned}\`;
const cmd = \`whois \${flag} --verbose\`;
exec(cmd, (err, stdout) => {
res.send(stdout);
});
});
app.listen(3000);
`;
const PACKAGE_JSON = `{ "name": "nyx-demo-app", "version": "1.0.0", "type": "module", "main": "src/server.js" }
`;
const AUTH_JS = `import jwt from 'jsonwebtoken';
const SECRET = 'super-secret-key';
export function sign(p) { return jwt.sign(p, SECRET); }
export function verify(t) { return jwt.verify(t, SECRET); }
`;
function writeDemo(variant) {
mkdirSync(join(SCAN_ROOT, 'src'), { recursive: true });
writeFileSync(join(SCAN_ROOT, 'package.json'), PACKAGE_JSON);
writeFileSync(
join(SCAN_ROOT, 'src/server.js'),
variant === 'v2' ? V2_SERVER : V1_SERVER,
);
const authPath = join(SCAN_ROOT, 'src/auth.js');
if (variant === 'v1') writeFileSync(authPath, AUTH_JS);
if (variant === 'v2' && existsSync(authPath)) unlinkSync(authPath);
}
// Server helpers -------------------------------------------------------------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function fetchJson(path) {
const res = await fetch(URL_BASE + path);
if (!res.ok) throw new Error(`${path}: ${res.status}`);
return res.json();
}
async function csrfToken() {
const r = await fetch(URL_BASE + '/api/session');
return (await r.json()).csrf_token;
}
async function waitForServer() {
for (let i = 0; i < 30; i++) {
try { await fetchJson('/api/health'); return; } catch { await sleep(250); }
}
throw new Error(`nyx serve not reachable at ${URL_BASE}, start it first`);
}
async function startScanViaApi(token) {
const res = await fetch(URL_BASE + '/api/scans', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-nyx-csrf': token },
body: '{}',
});
if (!res.ok && res.status !== 409) {
throw new Error(`POST /api/scans: ${res.status} ${await res.text()}`);
}
}
async function waitForScanComplete(prevScanId, timeoutMs = 90_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const overview = await fetchJson('/api/overview').catch(() => null);
if (
overview?.latest_scan_id &&
overview.state !== 'empty' &&
overview.latest_scan_id !== prevScanId
) {
await sleep(400);
return overview;
}
await sleep(400);
}
throw new Error('scan did not complete within deadline');
}
async function currentScanId() {
const overview = await fetchJson('/api/overview').catch(() => null);
return overview?.latest_scan_id ?? null;
}
// Storyboard helpers ---------------------------------------------------------
async function findFirstTaintRow(page) {
return page.locator('tbody tr').filter({ hasText: 'taint-' }).first();
}
// Stills ---------------------------------------------------------------------
async function captureStillsAfterScan1(page) {
console.error('[stills/p1] findings list');
await page.goto(URL_BASE + '/findings');
await page.waitForSelector('tbody tr', { timeout: 15_000 });
await sleep(1500);
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-findings-list.png') });
console.error('[stills/p1] finding detail (5-hop CMDi)');
const row = await findFirstTaintRow(page);
await row.click();
await page.waitForURL(/\/findings\/\d+/, { timeout: 10_000 });
await sleep(2200); // flow visualizer animation
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-finding-detail.png') });
}
async function captureStillsAfterScan2(page) {
console.error('[stills/p2] overview (with 2-scan trend)');
await page.goto(URL_BASE + '/');
await page
.waitForSelector('.health-score-card, [class*="health"]', { timeout: 10_000 })
.catch(() => {});
await sleep(1800);
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-overview.png') });
console.error('[stills/p2] triage');
await page.goto(URL_BASE + '/triage');
await page.waitForLoadState('domcontentloaded').catch(() => {});
await sleep(1500);
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-triage.png') });
console.error('[stills/p2] explorer');
await page.goto(URL_BASE + '/explorer');
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForSelector('.tree-node', { timeout: 10_000 }).catch(() => {});
await sleep(1000);
// Expand the src/ folder, then click server.js so the screenshot
// includes the highlighted source view rather than a "Select a
// file" prompt. Best-effort: skip silently if selectors miss.
const srcDir = page.locator('.tree-node:has-text("src")').first();
if (await srcDir.count()) {
await srcDir.click().catch(() => {});
await sleep(700);
}
const serverFile = page.locator('.tree-node:has-text("server.js")').first();
if (await serverFile.count()) {
await serverFile.click().catch(() => {});
await sleep(1500);
}
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-explorer.png') });
console.error('[stills/p2] scans list');
await page.goto(URL_BASE + '/scans');
await page.waitForSelector('tbody tr', { timeout: 10_000 }).catch(() => {});
await sleep(1500);
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-scans.png') });
console.error('[stills/p2] scan detail');
const firstScan = page.locator('tbody tr').first();
if (await firstScan.count()) {
await firstScan.click();
await page.waitForURL(/\/scans\/\d+/, { timeout: 10_000 }).catch(() => {});
await sleep(1800);
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-scan-detail.png') });
}
console.error('[stills/p2] rules');
await page.goto(URL_BASE + '/rules');
await page.waitForLoadState('domcontentloaded').catch(() => {});
await sleep(1800);
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-rules.png') });
console.error('[stills/p2] config');
await page.goto(URL_BASE + '/config');
await page.waitForLoadState('domcontentloaded').catch(() => {});
await sleep(1800);
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-config.png') });
}
// GIF storyboard -------------------------------------------------------------
async function captureGifFrames(page) {
console.error('[gif] scene 1: empty dashboard');
await page.goto(URL_BASE + '/');
await page.waitForSelector('text=Run your first scan');
await sleep(2200);
console.error('[gif] scene 2: open Start Scan modal');
await page.click('header button:has-text("Start Scan"), .header button:has-text("Start Scan"), button:has-text("Start Scan")');
await page.waitForSelector('.scan-modal');
await sleep(1200);
console.error('[gif] scene 3: confirm scan');
await page.click('.scan-modal button.btn-primary');
await page.waitForURL('**/scans', { timeout: 10_000 }).catch(() => {});
await waitForScanComplete(null);
console.error('[gif] scene 4: back to overview, scroll');
await page.goto(URL_BASE + '/');
await page
.waitForSelector('.health-score-card, [class*="health"]', { timeout: 10_000 })
.catch(() => {});
await sleep(1800);
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
await sleep(1500);
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
await sleep(1500);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
await sleep(800);
console.error('[gif] scene 5: navigate to Findings');
await page.click('a.nav-link:has-text("Findings"), .sidebar a:has-text("Findings")');
await page.waitForURL('**/findings', { timeout: 10_000 });
await page.waitForSelector('tbody tr', { timeout: 10_000 });
await sleep(1500);
console.error('[gif] scene 6: click the 5-hop taint finding');
const taintRow = await findFirstTaintRow(page);
await taintRow.click();
await page.waitForURL(/\/findings\/\d+/, { timeout: 10_000 });
await sleep(2500);
// Scroll well into the page so the viewer can see the taint flow
// animate before the section toggles fire.
await page.evaluate(() => window.scrollBy({ top: 480, behavior: 'smooth' }));
await sleep(1600);
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
await sleep(1600);
console.error('[gif] scene 7: open the collapsed sections');
for (const title of ['Evidence', 'Analysis Notes', 'Confidence Reasoning']) {
const toggle = page.locator(`.section-toggle:has-text("${title}")`).first();
if (await toggle.count()) {
await toggle.scrollIntoViewIfNeeded();
await sleep(500);
await toggle.click();
await sleep(1100);
}
}
await sleep(800);
console.error('[gif] scene 8: mark Investigating');
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
await sleep(900);
const statusBtn = page.locator('.status-trigger').first();
if (await statusBtn.count()) {
await statusBtn.click().catch(() => {});
await sleep(1100);
const investigating = page.locator('text=Investigating').first();
if (await investigating.count()) {
await investigating.click().catch(() => {});
await sleep(1200);
}
}
console.error('[gif] scene 9: triage page (closing visual)');
await page.goto(URL_BASE + '/triage');
await page.waitForLoadState('domcontentloaded').catch(() => {});
await sleep(1500);
}
async function convertWebmToGif(webm, gifOut) {
const palette = '/tmp/nyx-demo-palette.png';
console.error('[gif] generating palette');
execFileSync('ffmpeg', [
'-y', '-ss', '1.0', '-i', webm,
'-vf', 'fps=15,scale=1440:-1:flags=lanczos,palettegen',
palette,
], { stdio: 'inherit' });
console.error('[gif] palette → gif');
execFileSync('ffmpeg', [
'-y', '-ss', '1.0', '-i', webm, '-i', palette,
'-lavfi', 'fps=15,scale=1440:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle',
gifOut,
], { stdio: 'inherit' });
}
// CLI capture phase ----------------------------------------------------------
//
// `render-cli.py` orchestrates: force ANSI via `CLICOLOR_FORCE=1`,
// merge consecutive SGR escapes (freeze otherwise honors only the
// last one and drops fg/bg/dim), invoke freeze with the brand-
// consistent window chrome, then crop or pad to exactly 1600x992 so
// the framer never resamples the captured text.
const CLI_RENDERER = '/Users/elipeter/nyx/scripts/render-cli.py';
function renderCli(shellCommand, outFile) {
execFileSync(
'python3',
[CLI_RENDERER, outFile, shellCommand],
{ stdio: ['ignore', 'inherit', 'inherit'] },
);
}
// Stage a temporary HOME with a sample nyx.local so that
// `nyx config show` (which now defaults to a diff view) has
// something to display. Without this the capture would be a one-
// line "No overrides" notice — accurate but not a useful screenshot.
//
// The `directories` crate on macOS resolves the config path through
// `$HOME/Library/Application Support/nyx`, so swapping HOME is
// enough to redirect both reads and writes for the wrapped command.
const DEMO_CONFIG_HOME = '/tmp/nyx-demo-config-home';
const DEMO_NYX_LOCAL = `[scanner]
mode = "taint"
min_severity = "Medium"
[output]
default_format = "json"
max_low = 5
[analysis.engine]
backwards_analysis = true
`;
function stageDemoConfigHome() {
const cfgDir = join(DEMO_CONFIG_HOME, 'Library/Application Support/nyx');
rmSync(DEMO_CONFIG_HOME, { recursive: true, force: true });
mkdirSync(cfgDir, { recursive: true });
writeFileSync(join(cfgDir, 'nyx.local'), DEMO_NYX_LOCAL);
}
function captureCli() {
// Re-stage v1 so cli-scan output shows the richer set of findings
// (the previous --stills phase patched the demo to v2).
console.error('[cli/setup] writing v1 demo');
writeDemo('v1');
const out = (name) => join(OUT_DIR, 'docs', name);
// README and quickstart both link to the same `nyx scan` capture —
// emit it once at the top level, no docs/cli-scan-quickstart copy.
console.error('[cli] cli-scan');
renderCli(`${NYX_BIN} scan ${SCAN_ROOT}`, join(OUT_DIR, 'cli-scan.png'));
console.error('[cli] cli-failon');
// `; true` keeps the pipeline's exit code at 0 even when --fail-on
// trips. render-cli.py wraps the whole compound in `{ ...; }
// 2>/dev/null` so progress bars are suppressed for both halves.
renderCli(
`${NYX_BIN} scan ${SCAN_ROOT} --fail-on HIGH; true`,
out('cli-failon.png'),
);
console.error('[cli] cli-explain-engine');
renderCli(
`${NYX_BIN} scan ${SCAN_ROOT} --engine-profile deep --explain-engine`,
out('cli-explain-engine.png'),
);
console.error('[cli] cli-idxstatus');
renderCli(`${NYX_BIN} index status ${SCAN_ROOT}`, out('cli-idxstatus.png'));
console.error('[cli] cli-configshow (with staged nyx.local)');
stageDemoConfigHome();
renderCli(
`HOME=${DEMO_CONFIG_HOME} ${NYX_BIN} config show`,
out('cli-configshow.png'),
);
// cli-rollup-tail.png is intentionally not regenerated. Its alt text
// describes a 57-issue rollup that the synthetic demo cannot produce
// without a much larger fixture; the existing image is left alone.
}
// Frame phase ----------------------------------------------------------------
const STILLS_PNGS = [
'docs/serve-overview.png',
'docs/serve-findings-list.png',
'docs/serve-finding-detail.png',
'docs/serve-triage.png',
'docs/serve-explorer.png',
'docs/serve-scans.png',
'docs/serve-scan-detail.png',
'docs/serve-rules.png',
'docs/serve-config.png',
];
const CLI_PNGS = [
'cli-scan.png',
'docs/cli-failon.png',
'docs/cli-explain-engine.png',
'docs/cli-idxstatus.png',
'docs/cli-configshow.png',
];
function applyFrames(captured, { natural = false } = {}) {
// Frame only paths captured this run. Re-framing a previously-
// framed PNG would treat the framed result as the next inner
// content and produce a frame inside a frame.
const paths = captured.filter((p) => existsSync(p));
if (paths.length === 0) return;
const label = natural ? 'natural-size' : 'fixed';
console.error(`[frame] applying purple gradient frame (${label}) to ${paths.length} files`);
const args = natural ? ['--natural', ...paths] : paths;
execFileSync('python3', [FRAMER, ...args], { stdio: 'inherit' });
// Mirror the framed serve-overview.png to the top-level path the
// README links. Only do this when serve-overview was just
// captured this run; otherwise the existing top-level overview is
// already correct.
const ovSrc = join(OUT_DIR, 'docs/serve-overview.png');
const ovDst = join(OUT_DIR, 'overview.png');
if (paths.includes(ovSrc) && existsSync(ovSrc)) {
copyFileSync(ovSrc, ovDst);
console.error(`[frame] mirrored serve-overview.png → overview.png`);
}
}
// Main -----------------------------------------------------------------------
async function main() {
// Only the serve flows actually need nyx serve; --cli alone runs
// without it.
if (wantStills || wantGif) await waitForServer();
console.error('[setup] writing v1 demo to', SCAN_ROOT);
writeDemo('v1');
const needsBrowser = wantStills || wantGif;
let browser = null;
if (needsBrowser) {
const { chromium } = await import('playwright');
browser = await chromium.launch({ headless: true });
}
try {
if (wantGif) {
const videoDir = '/tmp/nyx-demo-video';
if (existsSync(videoDir)) rmSync(videoDir, { recursive: true });
mkdirSync(videoDir, { recursive: true });
const ctx = await browser.newContext({
viewport: VIEW,
colorScheme: COLOR_SCHEME,
recordVideo: { dir: videoDir, size: VIEW },
});
await ctx.addInitScript(() => {
try { localStorage.setItem('theme', 'light'); } catch {}
});
const page = await ctx.newPage();
await captureGifFrames(page);
await page.close();
await ctx.close();
const fs = await import('node:fs');
const files = fs.readdirSync(videoDir).filter((f) => f.endsWith('.webm'));
if (files.length === 0) throw new Error('no webm captured');
await convertWebmToGif(join(videoDir, files[0]), join(OUT_DIR, 'demo.gif'));
} else if (wantStills) {
// --stills only: GIF didn't run, so we drive scan #1 ourselves.
console.error('[setup] running scan #1 (v1) via API');
const token = await csrfToken();
const before = await currentScanId();
await startScanViaApi(token);
await waitForScanComplete(before);
}
if (wantStills) {
const ctx = await browser.newContext({ viewport: VIEW, colorScheme: COLOR_SCHEME });
await ctx.addInitScript(() => {
try { localStorage.setItem('theme', 'light'); } catch {}
});
const page = await ctx.newPage();
// Phase 1: capture pages that benefit from many findings.
await captureStillsAfterScan1(page);
// Patch demo to v2 + run scan #2 silently to populate the
// trend chart with two data points (second one smaller).
console.error('[setup] patching demo to v2 + running scan #2 via API');
writeDemo('v2');
const token = await csrfToken();
const before = await currentScanId();
await startScanViaApi(token);
await waitForScanComplete(before);
// Phase 2: capture pages whose value depends on the trend or
// are independent of the scan history.
await captureStillsAfterScan2(page);
await ctx.close();
}
if (wantCli) {
captureCli();
}
if (wantStills || wantCli || wantGif) {
// Frame phase — only frame what was captured this run so that
// already-framed PNGs from prior runs aren't framed again.
// Stills and the GIF use the fixed 1600x992 inner; CLI captures
// use --natural so each command keeps its own height.
const fixed = [];
if (wantStills) fixed.push(...STILLS_PNGS.map((p) => join(OUT_DIR, p)));
if (wantGif) fixed.push(join(OUT_DIR, 'demo.gif'));
if (fixed.length) applyFrames(fixed, { natural: false });
if (wantCli) {
const cli = CLI_PNGS.map((p) => join(OUT_DIR, p));
applyFrames(cli, { natural: true });
}
}
} finally {
if (browser) await browser.close();
}
console.error('done');
}
main().catch((e) => {
console.error('FAIL:', e);
process.exit(1);
});