diff --git a/frontend/e2e/README.md b/frontend/e2e/README.md index 719ced56..8bcf0105 100644 --- a/frontend/e2e/README.md +++ b/frontend/e2e/README.md @@ -27,3 +27,53 @@ route interception, so they do not require a (un)reachable LLM. - `smoke.spec.ts` — the app loads and primary navigation renders. - `llm-error.spec.ts` — Story and Filter Generator show a visible error alert (not a blank container) when generation returns `502`. + +## Demo recording + +`demo.spec.ts` is not a test — it drives the walkthrough recorded for the README +GIF (`sample-files/TracePcap-Demo.gif`). It follows the **Office Audit scenario** +from `docs/sample-files.rst`: eight weekly captures of an office network, where +policy violations escalate and then subside after an audit notice. + +It is **opt-in**: the `demo` project only registers when `DEMO=1`, so a normal +`npm run test:e2e` doesn't record video or wait out its pacing. + +### Record against an empty stack + +The demo seeds its own data (uploads the eight `sample-files/monitor_large/` +captures, creates the network, adds snapshots) and expects nothing else to be +there. Record against a fresh stack: + +```bash +docker compose down -v # ⚠️ destroys all uploaded pcaps and monitor data +docker compose up -d +scripts/record-demo.sh # records, then encodes over the README GIF +``` + +This isn't fussiness. Node roles are keyed by **`file_id`, not `network_id`** +(`node_roles` has no network column), so any network sharing a capture sees and +writes the same role labels. A stack with existing data will show *its* labels in +the GIF, and the recording will write labels back into files other networks use. +An empty stack is the only way the recording is both reproducible and +non-destructive. + +The first run analyses all eight captures (a few minutes). Later runs reuse them. + +Record without encoding (writes a WebM under `test-results/`): + +```bash +npm run demo:record +``` + +The spec deletes any existing copy of the fixture first, so each run records a +genuine first upload rather than the dedup ("Open existing") path. + +Tuning knobs on `record-demo.sh`: `DEMO_WIDTH` (default 800), `DEMO_FPS` (10), +`DEMO_COLORS` (96) — these produce ~3MB, roughly GitHub's comfortable ceiling. +Pacing lives in the `BEAT`/`READ` constants in the spec, and it dominates size: +the GIF costs ~100KB per second of runtime, so trimming holds beats tuning the +encoder. Raising width/fps/colors grows the file fast — check the printed size. + +When the UI changes, the spec's selectors are what break — tab labels are +matched by visible text, so renaming a tab fails the recording loudly rather +than silently omitting it. diff --git a/frontend/e2e/demo-fixture.ts b/frontend/e2e/demo-fixture.ts new file mode 100644 index 00000000..e7869b52 --- /dev/null +++ b/frontend/e2e/demo-fixture.ts @@ -0,0 +1,236 @@ +import { APIRequestContext, expect } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Seeds the Office Audit demo scenario (docs/sample-files.rst) via the API. + * + * Everything here is setup the GIF should not show: uploading eight captures and + * waiting out analysis is minutes of progress bars. The recording picks up from + * the Monitor session, which is where the story actually is. + */ + +const here = path.dirname(fileURLToPath(import.meta.url)); +const MONITOR_DIR = path.resolve(here, '../../sample-files/monitor_large'); +const SAMPLE_DIR = path.resolve(here, '../../sample-files'); + +/** The capture the analysis half of the demo uploads on camera. */ +export const DEMO_FILE = 'demo_all_rules.pcap'; + +export const NETWORK_NAME = 'Office Audit — Corp HQ'; +export const NETWORK_DESCRIPTION = + 'Corporate HQ office network — weekly PCAP captures from the managed switch ' + + 'spanning eight weeks. Segment covers staff workstations, servers, printers, and BYOD WiFi.'; + +/** The eight weekly captures, in capture order. */ +export const WEEK_FILES = [ + 'week1_baseline.pcap', + 'week2_personal_laptop_vpn.pcap', + 'week3_telnet_bittorrent.pcap', + 'week4_ftp_exfil_gateway_change.pcap', + 'week5_shadow_device_arp_spoof.pcap', + 'week6_peak_violations.pcap', + 'week7_violations_drop_gateway_back.pcap', + 'week8_near_baseline.pcap', +]; + +/** Role labels from the walkthrough's Step 5 table. 10.0.4.50 is left unlabelled + * on purpose — the demo runs Suggest with AI on it. */ +export const ROLE_LABELS: [string, string][] = [ + ['10.0.2.10', 'File Server (SMB)'], + ['10.0.2.20', 'Mail Server (SMTP/IMAP)'], + ['10.0.2.30', 'Internal Web Server'], + ['10.0.3.5', 'Floor Printer A (IPP)'], + ['10.0.3.6', 'Floor Printer B (IPP)'], + ['10.0.1.10', 'Alice — Staff Workstation'], + ['10.0.1.11', 'Bob — Staff Workstation'], + ['10.0.1.12', 'Carol — Staff Workstation'], + ['10.0.4.20', "Bob's Personal Laptop"], +]; + +/** Upload one capture and wait for analysis to finish. Returns its fileId. */ +async function uploadWeek(request: APIRequestContext, fileName: string): Promise { + const upload = await request.post('/api/v1/files', { + multipart: { + file: { + name: fileName, + mimeType: 'application/octet-stream', + buffer: fs.readFileSync(path.join(MONITOR_DIR, fileName)), + }, + enableNdpi: 'true', + enableFileExtraction: 'false', + source: 'MONITOR', + }, + timeout: 120_000, + }); + // 409 = already uploaded (dedup by hash); reuse it rather than re-analysing. + expect( + upload.ok() || upload.status() === 409, + `upload of ${fileName} failed: ${upload.status()}` + ).toBeTruthy(); + const body = await upload.json(); + const fileId: string = body.fileId ?? body.existingFileId; + expect(fileId, `no fileId for ${fileName}`).toBeTruthy(); + + const deadline = Date.now() + 180_000; + while (Date.now() < deadline) { + const r = await request.get(`/api/v1/files/${fileId}`); + if (!r.ok()) throw new Error(`status check failed for ${fileName}: ${r.status()}`); + const status = (await r.json()).status; + if (status === 'completed') return fileId; + if (status === 'failed') throw new Error(`analysis failed for ${fileName}`); + await new Promise(res => setTimeout(res, 2000)); + } + throw new Error(`analysis timed out for ${fileName}`); +} + +/** + * Ensure all eight captures are uploaded and analysed. Returns fileId by name, + * in capture order. First run on an empty stack analyses all eight (minutes); + * later runs reuse them. + * + * Reuses by *name* rather than relying on the backend's hash dedup: + * gen_monitor_large.py seeds `random` but fills payloads with os.urandom, so + * regenerating the samples changes their hashes and the 409 never fires. + * Matching on name keeps repeat recordings from piling up near-identical + * captures. + * + * Uploads are sequential: the backend analyses on upload, and eight concurrent + * nDPI runs would contend for the same worker pool. + */ +export async function seedWeekFiles( + request: APIRequestContext +): Promise> { + const existing = new Map(); + const list = await request.get('/api/v1/files?page=1&pageSize=200'); + if (list.ok()) { + for (const f of (await list.json()).data ?? []) { + if (f.status === 'completed' && !existing.has(f.fileName)) { + existing.set(f.fileName, f.fileId); + } + } + } + + const ids = new Map(); + for (const f of WEEK_FILES) { + ids.set(f, existing.get(f) ?? (await uploadWeek(request, f))); + } + return ids; +} + +/** + * Apply the walkthrough's Step 5 role labels against a snapshot, minus the one + * the demo types on camera and 10.0.4.50 (deliberately left unlabelled so + * "Suggest with AI" has to characterise it from traffic alone). + * + * Roles are per-snapshot — keyed by fileId — and carried forward onto later + * snapshots when saved, which is what makes the staleness beat work. + */ +export async function seedRoleLabels( + request: APIRequestContext, + fileId: string, + skip: string[] = [] +): Promise { + for (const [ip, label] of ROLE_LABELS) { + if (skip.includes(ip)) continue; + const res = await request.put('/api/v1/node-roles', { + data: { + entityType: 'IP', + entityKey: ip, + roleLabel: label, + roleDescription: '', + confirmedByHuman: true, + fileId, + }, + }); + expect(res.ok(), `role upsert failed for ${ip}: ${res.status()}`).toBeTruthy(); + } +} + +/** + * Remove every copy of the demo capture so the recording can upload it on camera. + * + * The backend dedups by hash: with the file already present, the upload modal + * shows the "already uploaded" branch and offers to open the existing analysis + * rather than running one — not the flow the GIF is meant to show. Deleting + * first is what makes step 1 reproducible on a stack that has recorded before. + */ +export async function clearDemoFile(request: APIRequestContext): Promise { + const list = await request.get('/api/v1/files?page=1&pageSize=200'); + if (!list.ok()) return; + for (const f of (await list.json()).data ?? []) { + if (f.fileName === DEMO_FILE) { + await request.delete(`/api/v1/files/${f.fileId}`); + } + } +} + +/** Absolute path to the demo capture, for setInputFiles(). */ +export function demoFilePath(): string { + return path.join(SAMPLE_DIR, DEMO_FILE); +} + +/** The demo network's id, or null when it doesn't exist yet. */ +export async function findNetworkId(request: APIRequestContext): Promise { + const list = await request.get('/api/v1/monitor/networks'); + if (!list.ok()) return null; + const body = await list.json(); + const networks = Array.isArray(body) ? body : (body.data ?? []); + return networks.find((n: { name: string }) => n.name === NETWORK_NAME)?.id ?? null; +} + +/** + * Ensure the demo network exists with all eight weekly snapshots attached, and + * return its id. Reuses an existing network rather than recreating it: each + * snapshot POST recomputes drift against its predecessor, and the operator's + * generated insights hang off the network row. + */ +export async function seedNetwork( + request: APIRequestContext, + fileIds: Map +): Promise { + let id = await findNetworkId(request); + if (!id) { + const created = await request.post('/api/v1/monitor/networks', { + data: { name: NETWORK_NAME, description: NETWORK_DESCRIPTION }, + }); + expect(created.ok(), `network create failed: ${created.status()}`).toBeTruthy(); + id = (await created.json()).id as string; + } + + // Attach only the weeks that aren't already snapshots, so a re-run against a + // seeded stack is a no-op instead of eight duplicate rows. + const existing = await request.get(`/api/v1/monitor/networks/${id}/snapshots`); + const snaps = existing.ok() ? await existing.json() : []; + const have = new Set( + (Array.isArray(snaps) ? snaps : (snaps.data ?? [])).map((s: { fileId: string }) => s.fileId) + ); + for (const [, fileId] of fileIds) { + if (have.has(fileId)) continue; + const res = await request.post(`/api/v1/monitor/networks/${id}/snapshots`, { + data: { fileId }, + }); + expect(res.ok(), `adding snapshot failed: ${res.status()}`).toBeTruthy(); + } + return id; +} + +/** + * Assert the Monitor half of the demo has something to show. + * + * The walkthrough films network insights (step 11) rather than generating them — + * a ~20s+ LLM call at the very end of a long recording. This fails loudly, and + * early, rather than letting the run reach step 11 and film an empty panel. + */ +export async function assertNetworkInsights( + request: APIRequestContext, + networkId: string +): Promise { + const res = await request.get(`/api/v1/monitor/networks/${networkId}/insights/latest`); + expect( + res.ok(), + 'network insights have not been generated — generate them before recording' + ).toBeTruthy(); + expect((await res.json()).status, 'network insights are not COMPLETED').toBe('COMPLETED'); +} diff --git a/frontend/e2e/demo-timeline.ts b/frontend/e2e/demo-timeline.ts new file mode 100644 index 00000000..2617c862 --- /dev/null +++ b/frontend/e2e/demo-timeline.ts @@ -0,0 +1,68 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Records which spans of the demo recording are dead time, so the encoder can + * fast-forward exactly those (see scripts/record-demo.sh). + * + * Waiting on an LLM is real — insight generation takes ~20s+ — but nobody wants + * to watch it. Rather than hide the wait, the GIF races through it: the spinner + * is visibly on screen, just at 10x. Timestamps beat a hardcoded trim because + * these waits are model- and machine-dependent. + */ + +const here = path.dirname(fileURLToPath(import.meta.url)); +export const TIMELINE_FILE = path.resolve(here, '../test-results/demo-timeline.json'); + +export interface Span { + /** Seconds from recording start. */ + start: number; + end: number; + /** Playback multiplier for this span (10 = 10x faster). */ + speed: number; + /** Shown in the script's log, so a slow beat is identifiable. */ + label: string; +} + +/** + * How hard to race the waits. A story generation can run 3+ minutes against a + * local model, which even at 10x is ~19s of spinner — most of the finished GIF. + * The wait is still shown, just briefly: the point is that the work is real, not + * that you watch it. Override with DEMO_FF_SPEED to slow it back down. + */ +const SPEED = Number(process.env.DEMO_FF_SPEED ?? 40); + +export class Timeline { + private t0 = Date.now(); + private spans: Span[] = []; + + /** Call when the first page load starts — video recording begins there. */ + start() { + this.t0 = Date.now(); + } + + private now() { + return (Date.now() - this.t0) / 1000; + } + + /** + * Run `fn`, and mark however long it took as fast-forwarded. Only marks the + * span if the wait is long enough to be worth cutting — a fast LLM response + * shouldn't produce a jarring 0.3s speed blip. + */ + async fastForward(label: string, minSeconds: number, fn: () => Promise): Promise { + const start = this.now(); + const result = await fn(); + const end = this.now(); + if (end - start >= minSeconds) { + this.spans.push({ start, end, speed: SPEED, label }); + } + return result; + } + + write() { + fs.mkdirSync(path.dirname(TIMELINE_FILE), { recursive: true }); + fs.writeFileSync(TIMELINE_FILE, JSON.stringify({ spans: this.spans }, null, 2)); + } +} diff --git a/frontend/e2e/demo.spec.ts b/frontend/e2e/demo.spec.ts new file mode 100644 index 00000000..6d9988a5 --- /dev/null +++ b/frontend/e2e/demo.spec.ts @@ -0,0 +1,1166 @@ +import { test, expect, Page, Locator } from '@playwright/test'; +import { + NETWORK_NAME, + demoFilePath, + clearDemoFile, + seedWeekFiles, + seedRoleLabels, + assertNetworkInsights, + seedNetwork, +} from './demo-fixture'; +import { Timeline } from './demo-timeline'; + +/** + * Records the README demo: a full pass over the product, analysis first and + * Monitor second. + * + * The arc is one capture's life — upload it, read what the analysis found, ask + * the LLM to narrate it, filter it, look at its topology and geography — and + * then the Monitor view, where eight weekly captures of a different network turn + * the same machinery into change detection over time. + * + * Not a smoke test: it asserts only enough to fail loudly rather than record a + * broken or empty UI. Run via `npm run demo:record`, or scripts/record-demo.sh + * which also encodes the GIF and fast-forwards the LLM waits this logs. + */ + +// Pauses are what the viewer reads as pacing, so they are deliberate, not +// arbitrary waits. They are also the main lever on GIF size — the encoded GIF +// costs ~100KB per second of runtime even on a static page, so keep each hold +// as short as still reads clearly. +// +// PACE divides every hold. The tour visits ten sections, so pauses sized to feel +// natural in isolation add up to a GIF nobody watches to the end; at 2.5x the +// whole thing still reads, because the viewer is skimming, not studying. +// Override with DEMO_PACE=1 to get the original, slower timing back. +const PACE = Number(process.env.DEMO_PACE ?? 2.5); +const ms = (n: number) => Math.round(n / PACE); + +/** + * Two pause lengths, in real milliseconds of finished GIF. + * + * Note these are NOT divided by PACE. PACE shortens the waits *while recording*, + * so the recording is already the finished timing — there is no speed-up pass on + * top. A 600ms wait is 600ms on screen, which turned out to be too quick to take + * a feature in. + * + * BEAT — transitions and steps within a feature: opening a filter, switching a + * tab, moving between panels. Enough to follow, not enough to dwell. + * FEATURE — landing on the thing the section exists to show. A full second is + * about the floor for recognising a chart, a diagram or a table before + * it cuts away. + * + * Override either with DEMO_BEAT / DEMO_FEATURE (milliseconds, as filmed). + */ +const BEAT = Number(process.env.DEMO_BEAT ?? 600); +const FEATURE = Number(process.env.DEMO_FEATURE ?? 1_100); + +/** Default for beat() — most calls are the in-between steps, not the payoff. */ +const HOLD = BEAT; + +/** + * Content that has to be read word by word rather than recognised — currently + * just the LLM's answer in the Story tab. + */ +const READ_HOLD = FEATURE * 2; + +/** Cursor travel before a click. Short enough to read as intent, not hesitation. */ +const CURSOR = ms(300); + +const timeline = new Timeline(); + +/** + * The slice of Sigma's API this recording uses, via the window.__sigma seam + * NetworkGraph installs. Structural, not imported: sigma's own types would pull + * the graph library into the e2e tsconfig for three method signatures. + */ +interface SigmaLike { + getContainer(): HTMLElement; + getGraph(): { forEachNode(cb: (n: string) => void): void }; + getNodeDisplayData(n: string): { x: number; y: number; size: number } | null; + framedGraphToViewport(c: { x: number; y: number }): { x: number; y: number }; +} + +/** Hold on the current view so a viewer can take it in before the next step. */ +async function beat(page: Page, ms = HOLD) { + await page.waitForTimeout(ms); +} + +/** + * Bring an element into view, centred, and wait for the scroll to land. + * + * The app sets html { scroll-behavior: smooth } globally, so this animates over + * ~1s whether or not the call asks it to — that costs GIF bytes (a full-frame + * repaint per frame of the pan) but it cannot be opted out of from here, so the + * thing that matters is not filming the hold before the scroll has arrived. + */ +async function reveal(target: Locator) { + await target.evaluate(el => el.scrollIntoView({ block: 'center' })); + await settleScroll(target.page()); +} + +/** + * Wait for a scroll to actually land. + * + * html { scroll-behavior: smooth } is set globally, so every scrollIntoView and + * scrollBy on the window animates over ~1s regardless of the flag passed to it. + * A fixed wait samples that mid-flight, and the hold then films the page still + * gliding toward its target. + * + * Two consecutive equal samples at 40ms. disableSmoothScroll makes scrolls + * instant, so this normally returns after one confirmation; it stays a poll + * rather than a fixed wait because a few scrolls here are driven by the app's + * own handlers (the section nav, the filter results autoscroll) rather than by + * this file, and those still animate. + */ +async function settleScroll(page: Page) { + let previous = -1; + let stable = 0; + for (let i = 0; i < 60 && stable < 2; i++) { + const y = await page.evaluate(() => Math.round(window.scrollY)); + stable = y === previous ? stable + 1 : 0; + previous = y; + await page.waitForTimeout(40); + } +} + +/** + * Turn off the app's smooth scrolling for the recording. + * + * html { scroll-behavior: smooth } makes every jump animate for ~1s. That is + * good product behaviour and bad recording behaviour, twice over: the animation + * dominates the runtime (measured on the Story tab, ~17s of scrolling against + * ~8s of deliberate holds), and a pan repaints the whole frame on every frame, + * which is the worst case for GIF inter-frame compression — a smooth scroll + * costs more bytes than the view it lands on. + * + * With this, a scroll is a hard cut: one changed frame, and settleScroll returns + * almost immediately. + * + * addInitScript, not addStyleTag: the recording does two full page loads (/ and + * /monitor), and a style tag is dropped on navigation. This re-applies itself on + * every document, before the app's own CSS has a chance to matter. + */ +async function disableSmoothScroll(page: Page) { + await page.addInitScript(() => { + const apply = () => { + const style = document.createElement('style'); + style.textContent = 'html { scroll-behavior: auto !important; }'; + document.head.appendChild(style); + }; + if (document.head) apply(); + else document.addEventListener('DOMContentLoaded', apply, { once: true }); + }); +} + +/** Height of the sticky navbar — content scrolled to y=0 hides underneath it. */ +const NAVBAR = 110; + +/** + * Put a card's top edge just below the sticky navbar, so its header reads as the + * top of the screen and the body gets the rest of the viewport. + * + * Distinct from reveal(): centring a card leaves its header mid-screen with dead + * space above, and for a card taller than the fold it pushes the header off the + * top entirely. Takes the element whose top edge should land — pass the card, + * not its header, or the header's own offset inside the card is lost. + */ +async function frameCardTop(target: Locator) { + const page = target.page(); + await target.evaluate( + (el, nav) => window.scrollBy(0, el.getBoundingClientRect().top - nav), + NAVBAR, + ); + await settleScroll(page); +} + +/** + * The enclosing .card of an element inside it (a header, a heading, a label). + * + * Matches the class token exactly. contains(@class,"card") also matches + * "card-header" and "card-body", so it returned the header — a 57px element + * whose top is the card's top only by coincidence, and whose height never grows + * when the card expands. That silently mis-framed the heatmap by ~130px and + * broke a height check that assumed it had the real card. + */ +function cardOf(target: Locator) { + return target.locator( + 'xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " card ")][1]', + ); +} + +/** + * Scroll the window by a screenful. Used where the script says "scroll down to + * show X" and X is a region rather than one element — reveal() needs a target, + * this just moves the viewport. + */ +async function scrollBy(page: Page, dy: number) { + await page.evaluate(y => window.scrollBy(0, y), dy); + await settleScroll(page); +} + +/** + * Drive the cursor to an element and click it. Playwright's default click + * teleports the pointer, which reads as a jump cut on video; a stepped + * mouse.move makes the interaction legible. + */ +async function showClick(page: Page, target: Locator) { + // Short timeouts: every call here targets something already asserted visible, + // so a hang means the element went away. Fail in seconds rather than sitting + // out the whole test timeout. + await target.scrollIntoViewIfNeeded({ timeout: 10_000 }); + const box = await target.boundingBox({ timeout: 10_000 }); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 12 }); + await page.waitForTimeout(CURSOR); + } + await target.click({ timeout: 10_000 }); +} + +/** + * Dismiss every open modal and wait for them to actually go. Modals stack here, + * and a lingering backdrop silently swallows the next click on the page + * underneath. + */ +async function closeAllModals(page: Page) { + for (let i = 0; i < 4; i++) { + if (!(await page.getByRole('dialog').first().isVisible().catch(() => false))) break; + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + } + await expect(page.getByRole('dialog')).toHaveCount(0, { timeout: 5_000 }); +} + +/** Type at human speed — an instant fill() reads as a glitch on video. */ +async function showType(target: Locator, text: string) { + await target.click(); + await target.fill(''); + await target.pressSequentially(text, { delay: 24 }); +} + +/** + * Switch analysis tabs by their visible label, and wait for the route to settle. + * + * Scoped to the tab bar: several tab labels are substrings of buttons on the + * page they open ("Story" vs "Generate Story", "Filter Generator" vs "Generate + * Filter"), so an unscoped lookup is ambiguous the moment the tab renders. + */ +async function openTab(page: Page, label: RegExp) { + await showClick(page, page.locator('ul.nav-tabs').getByRole('button', { name: label }).first()); + // Let the route swap commit, but do not pad the pause: the caller's HOLD is + // the pause. settleScroll also lands the new tab's scroll position. + await settleScroll(page); +} + +/** + * Where the biggest nodes are on screen, in viewport coordinates, largest first. + * + * Sigma draws to WebGL, so a node has no DOM element, no addressable pixel and + * no selector — an earlier version of this recording swept blind fractions of + * the plot area and mostly hovered empty space. NetworkGraph exposes its Sigma + * instance on window.__sigma for exactly this: ask where the nodes actually are + * rather than guessing a coordinate and silently filming a miss. + * + * Off-screen nodes are dropped. The hierarchical graph is taller than the + * viewport, and clicking at a negative y lands on whatever is scrolled up there + * instead — so scroll the graph into view before calling this. + */ +async function graphNodePoints(page: Page, count: number) { + return page.evaluate(n => { + const s = (window as unknown as { __sigma?: SigmaLike }).__sigma; + if (!s) return []; + const rect = s.getContainer().getBoundingClientRect(); + const pts: { x: number; y: number; size: number }[] = []; + s.getGraph().forEachNode((node: string) => { + const d = s.getNodeDisplayData(node); + if (!d) return; + const v = s.framedGraphToViewport({ x: d.x, y: d.y }); + const x = rect.left + v.x; + const y = rect.top + v.y; + if (x > 20 && x < 1260 && y > 20 && y < 700) pts.push({ x, y, size: d.size }); + }); + // Biggest node = most connected = the most interesting one to open. + pts.sort((a, b) => b.size - a.size); + return pts.slice(0, n); + }, count); +} + +/** + * Jump to a section of the Monitor network-detail page via its sticky side nav. + * + * Preferred over scrolling the window: it is how the page is meant to be + * navigated, it keeps the nav (and its active-link highlight) on camera, and it + * lands each section consistently regardless of how tall the ones above it grew. + * The nav scrolls smoothly, so wait for it to settle before filming. + */ +async function gotoSection(page: Page, label: string) { + const link = page.locator('.tp-section-nav').getByRole('link', { name: label }).first(); + await expect(link, `no "${label}" link in the section nav`).toBeVisible({ timeout: 10_000 }); + await showClick(page, link); + // The section nav scrolls the page itself; wait for that to land rather than + // adding a fixed delay on top of the caller's HOLD. + await settleScroll(page); +} + +// Setup is not story. The Monitor half needs eight analysed captures, a network, +// and generated insights — minutes of progress bars nobody should watch. It runs +// in beforeAll with its own timeout so the recorded test stays short. +let networkId: string; + +test.beforeAll(async ({ request }) => { + test.setTimeout(20 * 60_000); + + // The demo uploads this one on camera, so it must NOT already exist — the + // backend would dedup it and show the "already uploaded" branch instead. + await clearDemoFile(request); + + const fileIds = await seedWeekFiles(request); + networkId = await seedNetwork(request, fileIds); + + // Roles make the drift panels look like an audit in progress rather than a + // wall of unlabelled IPs. + await seedRoleLabels(request, fileIds.get('week2_personal_laptop_vpn.pcap')!); + + // Insights are filmed, not generated on camera (step 11) — fail now if absent. + await assertNetworkInsights(request, networkId); +}); + +test('README demo walkthrough', async ({ page }) => { + await disableSmoothScroll(page); + timeline.start(); + + // ── 1. Upload ─────────────────────────────────────────────────────────── + // Open on the upload screen: the product's actual front door, and the only + // frame that needs no explanation. + await page.goto('/'); + // Two headings carry this text — the page title and the file-list card. + await expect(page.getByRole('heading', { name: /Upload PCAP Files/i }).first()).toBeVisible(); + await beat(page, HOLD); + + // The drop zone's input is visually hidden, so set files on it directly — + // there is no OS file picker to drive, and none would be recorded anyway. + await page.locator('input[type="file"]').setInputFiles(demoFilePath()); + + // ── 2. Analysis options ──────────────────────────────────────────────── + // nDPI and file extraction default on; Suricata is the deliberate opt-in, so + // ticking it is the beat worth filming. + const optionsModal = page.getByRole('dialog'); + await expect(optionsModal.getByText(/Analysis options/i)).toBeVisible({ timeout: 10_000 }); + // The modal is a feature in its own right — what the analysis will actually + // run — so give it the full landing hold before acting on it. + await beat(page, FEATURE); + + // Checkboxes carry no accessible name (their text lives in sibling divs), so + // target the Suricata one via the label block that owns it. + const suricata = optionsModal + .locator('label.tp-suricata-option input[type="checkbox"]'); + await expect(suricata).not.toBeChecked(); + await showClick(page, suricata); + await expect(suricata).toBeChecked(); + // Just long enough for the tick to register on camera before moving on. + await beat(page, HOLD); + + await showClick(page, optionsModal.getByRole('button', { name: /Start upload/i })); + + // Show the upload/analysis wait briefly, then race through the rest. Suricata + // makes this the longest wait in the demo (~50s+); the spinner stays on + // screen, just at 10x. + await beat(page, HOLD); + await timeline.fastForward('Upload + analysis (Suricata enabled)', 2, async () => { + // A single-file upload auto-navigates to the analysis page on success. + await page.waitForURL(/\/analysis\/[0-9a-f-]{36}/, { timeout: 300_000 }); + await expect(page.getByRole('heading', { name: /Network Traffic Analysis/i })).toBeVisible({ + timeout: 300_000, + }); + // The page mounts on a loading view while the summary fetches — wait for + // real content, or the "completed" cut lands on a spinner. + await expect(page.getByRole('button', { name: /Conversations/i })).toBeVisible({ + timeout: 300_000, + }); + }); + await beat(page, HOLD); + + // ── 3. Overview ──────────────────────────────────────────────────────── + // The analysis summary first — file, packet count, duration, the headline + // counts the rest of the tab elaborates on. Framed at the top rather than + // scrolled past by a fixed distance. + const summary = page.locator('.analysis-summary').first(); + await expect(summary, 'analysis summary not found').toBeVisible({ timeout: 15_000 }); + await frameCardTop(summary); + await beat(page, FEATURE); + + // Then the protocol and category pie charts, each with its own header at the + // top of the screen. Anchored on the breakdown container, not the

and not + // the chart: framing the heading alone clips it against the navbar, and + // centring the chart (what reveal did) pushes the header off the top, so the + // pies read as unlabelled. + // Both charts share .protocol-breakdown — the category one reuses the class. + const breakdowns = page.locator('.protocol-breakdown'); + await expect(breakdowns.first(), 'no breakdown charts on the overview').toBeVisible({ + timeout: 15_000, + }); + const pieCount = await breakdowns.count(); + for (let i = 0; i < pieCount; i++) { + await frameCardTop(breakdowns.nth(i)); + await beat(page, FEATURE); + } + + await page.evaluate(() => window.scrollTo(0, 0)); + await beat(page, HOLD); + + // ── 4. Conversations: filter to Telegram, then inspect one ───────────── + await openTab(page, /Conversations/i); + await expect(page.getByRole('heading', { name: /Network Conversations/i })).toBeVisible({ + timeout: 20_000, + }); + await beat(page, HOLD); + + // The filter panel is collapsed by default — frame it at the top of the + // screen, open it, and hold on the range of filters available before narrowing + // to one. Framing first means the expanded panel opens into an empty viewport + // rather than unfolding off the bottom edge. + // Scoped to the panel, not the page: "Filter Generator" is also a button here. + // Not exact-matched either — the toggle wraps its label in two decorative + // icons, so its accessible name carries whitespace and `exact: true` misses it. + const filterPanel = page.locator('.conversation-filter-panel').first(); + await expect(filterPanel, 'conversation filter panel not found').toBeVisible({ + timeout: 15_000, + }); + await frameCardTop(filterPanel); + await beat(page, FEATURE); + await showClick(page, filterPanel.getByRole('button', { name: 'Filters' }).first()); + // Re-frame: expanding pushes the panel's own height down, and the badges the + // next step clicks are in the part that just appeared. + await frameCardTop(filterPanel); + await beat(page, FEATURE); + + // App badges are buttons, one per detected application. Telegram is the most + // recognisable app in this capture. + const telegram = page.getByRole('button', { name: 'Telegram', exact: true }).first(); + await expect(telegram, 'no Telegram badge in the filter panel').toBeVisible({ timeout: 15_000 }); + await beat(page); + await showClick(page, telegram); + // Wait for the filtered refetch rather than a fixed sleep. + await expect(page.getByRole('heading', { name: /Network Conversations/i })).toBeVisible(); + await beat(page, HOLD); + + // First row of the filtered set. Targeted positionally, not by address: the + // demo capture's default sort order is data, not contract, and hardcoding + // "91.108.16.1:527" broke the moment the ordering shifted. + const firstRow = page.locator('tbody tr').first(); + await expect(firstRow).toBeVisible({ timeout: 15_000 }); + await expect(firstRow).toContainText('192.168.1.77'); + await showClick(page, firstRow); + + const convModal = page.getByRole('dialog'); + await expect(convModal).toBeVisible({ timeout: 15_000 }); + await beat(page, FEATURE); + + // The destination host opens its full detail modal: what the host was judged + // to be, and the signals behind that judgement. + // + // The host card, not the IP text beside it — the address itself is inert. This + // used to be a device-type badge with title="Click for details"; #575/#578 + // collapsed that popup into the shared EntityDetailModal, so the affordance is + // now the whole host card, matched on its aria-label. + const hostCard = convModal.getByRole('button', { name: /^Open full details for / }).last(); + await expect(hostCard, 'destination host card not found').toBeVisible({ timeout: 10_000 }); + await showClick(page, hostCard); + + // The host modal stacks on top of the conversation modal, so scope to the last + // dialog — getByRole('dialog') alone is a strict-mode violation with two open. + const hostModal = page.getByRole('dialog').last(); + await expect(hostModal, 'host detail modal did not open').toBeVisible({ timeout: 15_000 }); + await beat(page, FEATURE); + + // "Evidence weighed" is the heart of the panel: the independent measured + // signals (hardware fingerprint, ports/services, behaviour) that the + // adjudicator combined into one verdict. Scroll it into view inside the modal + // body — the modal scrolls, the page behind it does not. + const evidence = hostModal.getByText(/Evidence weighed/i).first(); + if (await evidence.isVisible().catch(() => false)) { + await evidence.evaluate(el => el.scrollIntoView({ block: 'center' })); + await beat(page, FEATURE); + + // Expand the hardware axis — the MAC OUI match that anchors the verdict. + const hardware = hostModal.getByRole('button', { name: /Hardware/i }).first(); + if (await hardware.isVisible().catch(() => false)) { + await showClick(page, hardware); + await beat(page, FEATURE); + } + } + // Back to the conversation modal — the host modal stacked on top of it, so + // close only that one rather than both. + // + // Asserted on the host modal's own id and the dialog count, not on + // expect(hostModal).toBeHidden(): hostModal is getByRole('dialog').last(), + // which re-resolves after the close to the conversation modal underneath — so + // a toBeHidden() on it would be checking the wrong element and fail even + // though the right thing happened. + await page.keyboard.press('Escape'); + await expect(page.locator('#entity-detail-title')).toHaveCount(0, { timeout: 10_000 }); + await expect(page.getByRole('dialog')).toHaveCount(1); + await beat(page, HOLD); + + + const convBody = convModal.locator('.modal-body'); + const packetTabs = convModal.locator('ul.card-header-tabs'); + + // Packet list + session reconstruction live below the modal's fold. Scroll to + // frame that card rather than to the bottom of the modal: the tabs sit at the + // card's top, so bottoming out puts the thing being demonstrated off-screen. + await expect(packetTabs, 'packet/session tabs not found').toBeVisible({ timeout: 10_000 }); + await packetTabs.evaluate(el => el.scrollIntoView({ block: 'start' })); + // Nudge back up so the tab strip isn't flush against the modal's top edge. + await convBody.evaluate(el => el.scrollBy(0, -70)); + await beat(page, FEATURE); + + // Both views of the same flow: the packet-by-packet table, then the + // reassembled session. + for (const tab of [/^Packets/, /^Session/]) { + await showClick(page, packetTabs.getByRole('button', { name: tab }).first()); + // Session reconstruction is fetched on demand, so the tab opens on a + // "Reconstructing session…" spinner. Hold until the real content is there — + // otherwise the demo films the spinner and moves on before it resolves. + await expect(convModal.getByText(/Reconstructing session/i)).toBeHidden({ + timeout: 60_000, + }); + // Re-frame after the content lands: the pre-click framing leaves the tab + // strip at y≈438 of a 720px viewport, pushing the table off the bottom edge. + // + // Position the strip near the modal's top edge, so the table below it gets + // the rest of the height. Anchoring on the strip rather than scrolling + // to the bottom matters: the session panel finishes growing *after* the + // payload first renders, so scrollHeight read at this moment is stale and a + // scrollTo(scrollHeight) stops ~54px short — which is what kept filming the + // tab strip at y≈400 with the table hanging off the bottom edge. Scrolling + // by the strip's own offset overshoots that stale extent, and the browser + // honours it once the content has grown. + let settled = 0; + let lastMax = -1; + for (let i = 0; i < 40 && settled < 4; i++) { + const max = await convBody.evaluate(el => el.scrollHeight - el.clientHeight); + settled = max === lastMax ? settled + 1 : 0; + lastMax = max; + await page.waitForTimeout(150); + } + await convBody.evaluate(el => { + const strip = el.querySelector('ul.card-header-tabs'); + if (strip) { + el.scrollBy(0, strip.getBoundingClientRect().top - el.getBoundingClientRect().top - 40); + } + }); + await beat(page, HOLD); + } + + await closeAllModals(page); + + // ── 5. Story ─────────────────────────────────────────────────────────── + await openTab(page, /Story/i); + // Not end-anchored: these buttons wrap their label in decorative icons, + // which pad the computed accessible name — /^Generate Story$/ never matches. + const generateStory = page.getByRole('button', { name: /Generate Story/i }).first(); + await expect(generateStory, 'Generate Story button not found').toBeVisible({ timeout: 15_000 }); + // No hold before this click: the tab has nothing to read yet — it is an empty + // "No Story Generated Yet" prompt — so pausing here reads as hesitation. + // showClick's own cursor travel is the only beat it needs. + await showClick(page, generateStory); + + // Show the loading state, then race the LLM. + // + // Wait for the finished story to render, not for the spinner to disappear: + // the loading view unmounts a beat before the story paints, and cutting on the + // spinner filmed the demo tabbing away from a blank Story page while the LLM + // was still working. "Network Traffic Story" is the heading of the rendered + // story, so it only exists once there is one. + await beat(page, HOLD); + await timeline.fastForward('Generate Story (LLM)', 2, async () => { + await expect(page.getByRole('heading', { name: /Network Traffic Story/i })).toBeVisible({ + timeout: 300_000, + }); + }); + + // Fail loudly if the LLM errored — a recording that films an error banner and + // calls it a story is worse than no recording. + const storyError = page.getByText(/Failed to Generate Story/i); + await expect(storyError, 'story generation failed — check the LLM server').toBeHidden(); + // Short: the very next thing is a scroll to the chat card, so a full hold here + // just delays the section on a view the panel walk returns to anyway. + await beat(page, HOLD); + + // Ask the LLM first — it is the one genuinely interactive thing on this tab, + // and it is also first in page order, so leading with it means the section + // reads top-to-bottom rather than doubling back. + // + // A suggested question rather than typed prose: it is one click, it is + // guaranteed answerable against this story, and it puts the affordance itself + // on camera. + const chatCard = page.locator('.card').filter({ hasText: 'Ask the LLM' }).first(); + await expect(chatCard, 'Ask the LLM card not found').toBeVisible({ timeout: 15_000 }); + await frameCardTop(chatCard); + await beat(page, FEATURE); + + // A suggested question only *fills* the box — it does not send. So click one + // to show the affordance, hold while the text lands in the input, then send. + const suggestion = chatCard.getByRole('button').filter({ hasText: /\?/ }).first(); + const chatInput = chatCard.getByPlaceholder(/Ask a question about this story/i); + await expect(suggestion, 'no suggested question to click').toBeVisible({ timeout: 10_000 }); + await showClick(page, suggestion); + await beat(page, HOLD); + await expect(chatInput).not.toHaveValue(''); + await chatInput.press('Enter'); + + // Another LLM round-trip, so race it. Wait on the "Thinking..." bubble going + // away rather than on an answer selector: the assistant bubble carries only + // layout classes, so there is nothing stable to match on it, and the spinner + // is unambiguous while it is up. + await beat(page, HOLD); + await timeline.fastForward('Story Q&A (LLM)', 2, async () => { + await expect(chatCard.getByText(/Thinking\.\.\./)).toBeHidden({ timeout: 300_000 }); + + // "Thinking..." disappearing means the first token landed, not that the + // answer is done — the reply streams in, so re-framing on that signal filmed + // a half-written sentence scrolling away. Hold until the text stops growing + // (two equal samples). Inside the fast-forward span: this is still waiting + // on the model, so it should race like the rest of it. + let previous = -1; + for (let i = 0; i < 40; i++) { + const length = (await chatCard.innerText()).length; + if (length === previous) break; + previous = length; + await page.waitForTimeout(500); + } + }); + + // Then hold on the answer, properly — this is the payoff of the section, and + // the reply is prose that has to actually be read. Re-frame first: the answer + // grows the card, so the pre-send position no longer has the reply in shot. + await frameCardTop(chatCard); + await beat(page, READ_HOLD); + + // The rest of the Story page is stacked panels, not one blob of prose, and the + // point of the tab is how they relate: deterministic findings and full-dataset + // aggregates are computed, the LLM only writes prose over them. A single + // scrollBy past the lot showed none of it, so stop on each in page order with + // its header at the top of the screen. + // + // Anchored on each panel's own heading rather than by scroll distance — the + // panels are conditional (aggregates, findings, and investigation each render + // only when the story has them), so any fixed offset lands somewhere different + // depending on what this capture produced. + for (const [label, heading] of [ + // The narrative itself, with its Key Events rail alongside. + ['narrative', /^Narrative/], + // What the LLM was given and what it's allowed to do with it. + ['generation settings', /How stories are generated/i], + // Pre-computed analytics over the whole capture, not a sample. + ['traffic intelligence', /Traffic Intelligence/i], + // The detector output the narrative is required to cover. + ['deterministic findings', /Deterministic Findings/i], + // What the LLM went back and looked up while writing. + ['investigation steps', /Investigation/i], + ] as const) { + const panelHeading = page.getByText(heading).first(); + if (!(await panelHeading.isVisible().catch(() => false))) continue; + // Frame the enclosing card, not the heading: scrolling the heading itself to + // the top clips the card's own header padding and border, so the panel reads + // as starting mid-way through. + const card = cardOf(panelHeading); + await frameCardTop((await card.count()) ? card.first() : panelHeading); + // A brief stop, not a reading pause: these panels are dense enough that + // nobody reads them off a GIF, so the job here is to show that they exist + // and what shape they are. The Ask-the-LLM answer above is the one thing on + // this tab that has to be read, and it keeps its long hold. + await beat(page, FEATURE); + // Findings is the exception worth an extra look — it is the evidence the + // narrative is accountable to, and the densest panel on the page. + if (label === 'deterministic findings') await beat(page, HOLD); + } + + + await page.evaluate(() => window.scrollTo(0, 0)); + + // ── 6. Filter generator ──────────────────────────────────────────────── + await openTab(page, /Filter Generator/i); + const prompt = page.getByPlaceholder(/Show me all HTTP traffic/i); + await expect(prompt, 'filter prompt box not found').toBeVisible({ timeout: 15_000 }); + // Frame "Ask in Natural Language" at the top before typing, so the prompt box, + // the button and the generated filter below it all stay in one shot — the tab + // otherwise opens with the card halfway down and the result lands off-screen. + await frameCardTop(cardOf(prompt)); + await beat(page, FEATURE); + await showType(prompt, 'Find any traffic to database, cache, or file-sharing ports'); + await beat(page); + + await showClick(page, page.getByRole('button', { name: /Generate Filter/i })); + await timeline.fastForward('Generate Filter (LLM)', 2, async () => { + await expect(page.getByRole('button', { name: /Execute Filter/i })).toBeVisible({ + timeout: 300_000, + }); + }); + + // The generated BPF expression is the payload of this section — the whole + // point is that plain English became a real filter. Frame it and hold before + // executing, or it flashes past on the way to the results table. + // #filter, not a /
: the generated expression renders into an
+  // *editable* Form.Control so the analyst can tweak it before running. A
+  // previous version matched 'code, pre' behind an isVisible() guard, which
+  // never matched and silently skipped this whole step.
+  const generated = page.locator('#filter');
+  await expect(generated, 'no generated filter expression to show').toBeVisible({
+    timeout: 20_000,
+  });
+  await expect(generated, 'generated filter is empty').not.toHaveValue('');
+  // Frame the card rather than centring the expression: the explanation above it
+  // and the Execute button below are both part of what just happened, and
+  // centring the  alone crops one or the other.
+  await frameCardTop(cardOf(generated));
+  await beat(page, FEATURE);
+
+  await showClick(page, page.getByRole('button', { name: /Execute Filter/i }));
+  await timeline.fastForward('Execute Filter', 2, async () => {
+    await expect(page.getByRole('button', { name: /Execute Filter/i })).toBeEnabled({
+      timeout: 120_000,
+    });
+  });
+
+  // The page smooth-scrolls itself to the results, landing the "Matching
+  // Packets" heading at y≈32 — underneath the ~100px sticky navbar, so the
+  // recording showed anonymous packet rows with no heading and no match count.
+  //
+  // The fix is a small scroll *up*, not a re-scroll: scrollIntoView({block:
+  // 'center'}) pushes the heading to mid-viewport and the table's own height
+  // then carries the rows off-screen. Wait for the page's animation to settle
+  // first, or the nudge is applied mid-flight and undone.
+  const matching = page.getByRole('heading', { name: /Matching Packets/i }).first();
+  await expect(matching, 'filter returned no results to show').toBeVisible({ timeout: 30_000 });
+  // The page smooth-scrolls itself here regardless of disableSmoothScroll (it is
+  // the app's own scrollIntoView), so wait for it to land before nudging.
+  await settleScroll(page);
+  await scrollBy(page, -90);
+  await beat(page, FEATURE);
+
+  // Then the matched packets themselves.
+  await scrollBy(page, 300);
+  await beat(page, FEATURE);
+
+  // ── 7. Extracted files ─────────────────────────────────────────────────
+  await openTab(page, /Extracted Files/i);
+
+  // The Files card is the section — carrier files reassembled out of the packet
+  // stream. Frame its header at the top and stop there: no second scroll, since
+  // the rows below already fill the shot and carrying on only reached footer
+  // whitespace.
+  const filesHeading = page.getByRole('heading', { name: /^Files$/ }).first();
+  await expect(filesHeading, 'extracted files card not found').toBeVisible({ timeout: 20_000 });
+  await frameCardTop(cardOf(filesHeading));
+  await beat(page, FEATURE);
+
+  // ── 8. Network visualization ───────────────────────────────────────────
+  await openTab(page, /Network Visualization/i);
+  const topology = page.getByText('Topology Diagram').first();
+  await expect(topology, 'topology diagram not rendered').toBeVisible({ timeout: 30_000 });
+
+  // Frame the graph before doing anything to it. Anchor on the card *header*
+  // rather than centring the graph body: centring pushes the header off the top
+  // and the diagram then reads as a floating canvas with no title. Pinning the
+  // header just under the sticky navbar keeps "Topology Diagram" on screen with
+  // the whole plot below it.
+  const graphBody = page.locator('.network-diagram-graph-body').first();
+  await expect(graphBody).toBeVisible({ timeout: 30_000 });
+  // The card has no class of its own, so reach it structurally — but note it is
+  // the graph body's *grandparent*: the body is a .card-body, and the header
+  // ("Topology Diagram") is its sibling, so xpath=.. lands inside the card and
+  // excludes the very header this is trying to keep on screen.
+  //
+  // Position it explicitly rather than via scrollIntoView + a nudge: the card
+  // sits near the top of the page, so block:'start' is already clamped at scroll
+  // 0 and a corrective scrollBy is silently a no-op. The card is ~570px against
+  // a 720px viewport, so anchoring its top just under the ~100px sticky navbar
+  // fits the header and the whole diagram in one frame.
+  const topologyCard = graphBody.locator('xpath=../..');
+  const frameTopology = () =>
+    topologyCard.evaluate(el => window.scrollBy(0, el.getBoundingClientRect().top - 110));
+  await frameTopology();
+  await expect(page.getByText(/Computing layout/i)).toBeHidden({ timeout: 120_000 });
+  await beat(page, HOLD);
+  await showClick(page, page.locator('[title="Fit view"]').first());
+  // Re-frame after the layout settles and the camera has fitted: both change the
+  // card's height, so the position set before them no longer holds the header
+  // under the navbar. Framing only once filmed a headerless canvas.
+  await frameTopology();
+  await beat(page, FEATURE);
+
+  // Cycle the edge-colour modes. Same topology, recoloured three ways: by
+  // transport protocol, by detected application, then by volume — which also
+  // swaps in a magnitude legend. Held on each so the recolour is legible rather
+  // than a flicker, and re-framed because the volume mode adds a legend row that
+  // grows the card and pushes the header back off the top.
+  // Anchored on the option values it must contain, not on its rendered text: the
+  // "Color edges by" Form.Label has no htmlFor, so getByLabel cannot reach it,
+  // and filtering a