#!/usr/bin/env node /** * ux-gate · warm-interaction (the hard interaction gate, Playwright) * ──────────────────────────────────────────────────────────────────────────── * path-to-9 v3 §Pillar 2 + §3. The instant-power-feel pillar has a HARD gate: * a returning user's warm launch must land on INTERACTIVE content fast, and a * cold launch (sidecar down) must still paint from cache and accept typing. This * script measures both against a running dev server and mirrors the capture-kit * convention of a SEEDED RETURNING USER (onboarding-complete + already-booted + * briefing-dismissed in localStorage, no bypass query params — the authentic * day-30 morning launch, not the E2E `?skipOnboarding` path). The seed is * disclosed in the output so a reader knows exactly what user state was measured. * * ── WARM gate (v3 §Pillar 2.1/2.2, §3 hard gate) ─ measured against a healthy * sidecar. app-start → * (a) home content visible — FAIL if > 1000ms (time-to-content) * (b) brand flash — FAIL if > 500ms (boot-screen dwell; a * correctly-seeded warm return skips boot → 0) * (c) composer accepts a keystroke — FAIL if the first keystroke is rejected * (input-during-warmup: the composer is typable * the moment it renders, never gated on connect) * Prints a timing table. Exit 1 on any breach (RED until Pillar 2 lands — * intended; this is the contract written as a test). `--report-only` prints * the table and exits 0 (for the "report the timing table" verify step). * * ── COLD-START variant (v3 §Pillar 2.2 cold path) ─ warm visit to populate any * disk cache, then ALL `/api/**` aborted (sidecar "down"), reload: * cachedPaint — cached home content still renders without the sidecar * typingQueues — the composer still accepts typing with the sidecar down * These need Lane H (cache-first paint) + Lane C (input-during-warmup) landed. * Until then the script PROBES and reports which contracts hold; it exits 1 * only on a REGRESSION of a contract the baseline records as landed. `--strict` * enforces every cold contract (flip once H+C merge). Ratchet baseline: * `warm-interaction-baseline.json`, seeded with `--update-baseline`. * * Requires `playwright` (dev dep) + a reachable dev server. Defaults to the vite * dev server (WAGGLE_UX_BASE_URL, default http://127.0.0.1:8080) — the live-source * app the arc is validated on; point it at the built app (single-origin :3333) for * production-representative timing. NOT wired into CI yet (see README). * * npm run ux:warm-gate * node scripts/ux-gates/warm-interaction-gate.mjs --report-only * node scripts/ux-gates/warm-interaction-gate.mjs --strict * node scripts/ux-gates/warm-interaction-gate.mjs --update-baseline * node scripts/ux-gates/warm-interaction-gate.mjs --warm-only --json * WAGGLE_UX_BASE_URL=http://127.0.0.1:3333 npm run ux:warm-gate * * Exit codes: 0 clean · 1 warm breach / cold regression (or any cold fail under * --strict) · 2 infra (no server / no playwright / content never reached). */ import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); const BASELINE = path.join(ROOT, 'scripts/ux-gates/warm-interaction-baseline.json'); const REPORT = path.join(ROOT, 'scripts/ux-gates/.warm-interaction-report.json'); const BASE = process.env.WAGGLE_UX_BASE_URL ?? 'http://127.0.0.1:8080'; // Budgets — the v3 §3 hard-gate thresholds (env-overridable for a built-app run). const HOME_BUDGET_MS = Number(process.env.WARM_HOME_BUDGET_MS ?? 1000); const BRAND_BUDGET_MS = Number(process.env.WARM_BRAND_BUDGET_MS ?? 500); // Selectors (verified in HomeCockpit.tsx / ChatApp.tsx). const SEL = { boot: '[data-testid="boot-screen"]', homeContent: '[data-testid="home-cockpit"],[data-testid="home-cockpit-empty"]', homeLoaded: '[data-testid="home-cockpit"]', // real content (not the skeleton/empty) wsTile: '[data-testid^="home-cockpit-ws-"]', composer: 'textarea[placeholder^="Reply, or ask Waggle"]', }; /** The seeded RETURNING power user — pure localStorage, no bypass query params. * `waggle-booted` → AppShell skips the boot screen (initialBooted); * `waggle_onboarding_complete` + `waggle:onboarding` → isOnboardingStatusKnownSync * resolves synchronously so no wizard/boot flash; the briefing is dismissed so it * can't interpose. This is the exact state a day-30 desktop launch carries. */ const SEED_DISCLOSURE = { 'waggle-booted': 'true', 'waggle_onboarding_complete': 'true', 'waggle:onboarding': '{completed:true,step:7,tier:"power",tooltipsDismissed:true}', 'waggle:login-briefing-dismissed': 'true', 'waggle-theme': '', }; function seedReturningUser(theme) { try { localStorage.setItem('waggle-booted', 'true'); localStorage.setItem('waggle_onboarding_complete', 'true'); localStorage.setItem('waggle:onboarding', JSON.stringify({ completed: true, step: 7, tier: 'power', tooltipsDismissed: true })); localStorage.setItem('waggle:login-briefing-dismissed', 'true'); localStorage.setItem('waggle-theme', theme); } catch { /* pre-navigation origin — retried on the real origin by the next initScript run */ } } // ── CLI ─────────────────────────────────────────────────────────────────────── const args = process.argv.slice(2); const UPDATE = args.includes('--update-baseline'); const STRICT = args.includes('--strict'); const REPORT_ONLY = args.includes('--report-only'); const JSON_OUT = args.includes('--json'); const WARM_ONLY = args.includes('--warm-only'); const COLD_ONLY = args.includes('--cold-only'); // ── Page-timing helper ───────────────────────────────────────────────────────── /** Resolve with the in-page `performance.now()` captured in the SAME frame the * predicate first matches (accurate to element appearance, independent of the * Node-side CDP roundtrip). Returns null on timeout. performance.now() is ms * since the document's time origin === navigation start, so the value IS the * time-since-app-start we want. */ async function perfWhen(page, predicate, timeoutMs) { try { const handle = await page.waitForFunction(predicate, undefined, { timeout: timeoutMs, polling: 'raf' }); const value = await handle.jsonValue(); await handle.dispose(); return typeof value === 'number' ? value : null; } catch { return null; } } // ── WARM pass ─────────────────────────────────────────────────────────────────── async function measureWarm(browser) { const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const page = await context.newPage(); await page.addInitScript(seedReturningUser, 'dark'); const out = { homeMs: null, homeState: null, brandMs: 0, bootSeen: false, wsId: null, composerAccepted: null, composerMs: null }; try { // PRIME the payload cache first (V1-instant catch): "warm" means the day-30 // returning launch — localStorage (including waggle:home-cache:*) persists // across launches, so the measured run must start with the cache POPULATED. // The unprimed first visit is the day-0 path; recorded as info, not gated. await page.goto(`${BASE}/home`, { waitUntil: 'commit', timeout: 30000 }); const day0 = await perfWhen(page, () => { const el = document.querySelector('[data-testid="home-cockpit"],[data-testid="home-cockpit-empty"]'); return el ? performance.now() : false; }, 30000); out.day0Ms = day0 != null ? Math.round(day0) : null; out.cachePrimed = await page.evaluate(() => Object.keys(localStorage).some(k => k.startsWith('waggle:home-cache:'))); await page.goto('about:blank'); // One retry absorbs a transient dev-proxy drop (the vite→sidecar proxy can // NetworkError under back-to-back context churn). A reload restarts the perf // clock, so we measure the retry's paint honestly. let homeMs = null; for (let attempt = 0; attempt < 2 && homeMs == null; attempt++) { await page.goto(`${BASE}/home`, { waitUntil: 'commit', timeout: 30000 }); const homeP = perfWhen(page, () => { const el = document.querySelector('[data-testid="home-cockpit"],[data-testid="home-cockpit-empty"]'); return el ? performance.now() : false; }, 20000); // Brand flash: only if a boot screen actually shows for this (warm) user — // a correctly-seeded return skips it entirely → 0. If it appears, the flash // is how long it dwells. Measured on the first attempt only. if (attempt === 0) { const bootAppeared = await perfWhen(page, () => document.querySelector('[data-testid="boot-screen"]') ? performance.now() : false, 700); if (bootAppeared != null) { out.bootSeen = true; const gone = await perfWhen(page, () => document.querySelector('[data-testid="boot-screen"]') ? false : performance.now(), 8000); out.brandMs = Math.round(gone ?? bootAppeared); } } homeMs = await homeP; } if (homeMs != null) { out.homeMs = Math.round(homeMs); out.homeState = await page.evaluate(() => { const el = document.querySelector('[data-testid="home-cockpit"],[data-testid="home-cockpit-empty"]'); return el ? el.getAttribute('data-testid') : null; }); out.wsId = await page.evaluate((sel) => { const el = document.querySelector(sel); return el ? (el.getAttribute('data-testid') || '').replace('home-cockpit-ws-', '') : null; }, SEL.wsTile); } // Composer: navigate to the first workspace chat and type the moment the // textarea attaches (input-during-warmup — never wait for connect). if (out.wsId) { const c = await measureComposer(page, out.wsId, /* apiBlocked */ false); out.composerAccepted = c.accepted; out.composerMs = c.ms; } } finally { await context.close(); } return out; } /** Navigate to a workspace chat, wait for the composer to ATTACH, type a unique * token immediately, and assert it stuck. Returns { accepted, ms }. A disabled/ * gated textarea makes pressSequentially throw → accepted:false (the regression * we guard). `ms` is time-since-chat-nav the composer became interactive. */ async function measureComposer(page, wsId, apiBlocked) { const out = { accepted: false, ms: null }; try { await page.goto(`${BASE}/workspaces/${wsId}/chat`, { waitUntil: 'commit', timeout: 30000 }); } catch { return out; // navigation itself failed (only expected when apiBlocked bricks routing) } const composerAttachMs = await perfWhen(page, () => { const t = document.querySelector('textarea[placeholder^="Reply, or ask Waggle"]'); return t ? performance.now() : false; }, apiBlocked ? 12000 : 20000); if (composerAttachMs == null) return out; out.ms = Math.round(composerAttachMs); const token = `gate-${Date.now().toString(36)}`; try { const box = page.locator(SEL.composer).first(); await box.click({ timeout: 4000 }); await box.pressSequentially(token, { delay: 0, timeout: 4000 }); const val = await box.inputValue(); out.accepted = typeof val === 'string' && val.includes(token); } catch { out.accepted = false; // not editable / disabled → the keystroke was rejected } return out; } // ── COLD pass ─────────────────────────────────────────────────────────────────── async function measureCold(browser) { const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const page = await context.newPage(); await page.addInitScript(seedReturningUser, 'dark'); const out = { cachedPaint: false, cachedPaintMs: null, typingQueues: null, composerRendered: null, wsId: null, reachedWarm: false }; try { // 1) Warm visit — lets any disk cache (Lane H) and the seed settle. One // retry absorbs a transient dev-proxy drop (same as the warm pass). let warmHome = null; for (let attempt = 0; attempt < 2 && warmHome == null; attempt++) { await page.goto(`${BASE}/home`, { waitUntil: 'commit', timeout: 30000 }); warmHome = await perfWhen(page, () => document.querySelector('[data-testid="home-cockpit"],[data-testid="home-cockpit-empty"]') ? performance.now() : false, 20000); } out.reachedWarm = warmHome != null; out.wsId = await page.evaluate((sel) => { const el = document.querySelector(sel); return el ? (el.getAttribute('data-testid') || '').replace('home-cockpit-ws-', '') : null; }, SEL.wsTile); // 2) Sidecar "down": abort every API call from here on. await context.route('**/api/**', (route) => route.abort()); // 3) Reload home — does cached content still paint without the sidecar? await page.goto(`${BASE}/home`, { waitUntil: 'commit', timeout: 30000 }); const cachedMs = await perfWhen(page, () => document.querySelector('[data-testid="home-cockpit"]') ? performance.now() : false, 6000); out.cachedPaint = cachedMs != null; out.cachedPaintMs = cachedMs == null ? null : Math.round(cachedMs); // 4) Chat with the sidecar down — does the composer still accept typing? // `composerRendered` disambiguates "workspace unreachable, no usable // composer" (ms == null) from "composer present but keystroke rejected". if (out.wsId) { const c = await measureComposer(page, out.wsId, /* apiBlocked */ true); out.typingQueues = c.accepted; out.composerRendered = c.ms != null; } } finally { await context.close(); } return out; } // ── Reporting ──────────────────────────────────────────────────────────────────── const pad = (s, n) => String(s).padEnd(n); function fmtMs(v) { return v == null ? ' n/a' : `${v}ms`; } function printWarmTable(warm) { const rows = [ { name: 'home content visible', measured: fmtMs(warm.homeMs), budget: `≤${HOME_BUDGET_MS}ms`, pass: warm.homeMs != null && warm.homeMs <= HOME_BUDGET_MS, note: warm.homeState || 'not reached' }, { name: 'brand flash (boot dwell)', measured: warm.bootSeen ? fmtMs(warm.brandMs) : '0ms', budget: `≤${BRAND_BUDGET_MS}ms`, pass: (warm.bootSeen ? warm.brandMs : 0) <= BRAND_BUDGET_MS, note: warm.bootSeen ? 'boot shown' : 'boot skipped (warm)' }, { name: 'composer accepts keystroke', measured: warm.composerAccepted == null ? 'skipped' : (warm.composerAccepted ? 'accepted' : 'REJECTED'), budget: 'accepted', pass: warm.composerAccepted === true, note: warm.composerMs == null ? (warm.wsId ? 'no composer' : 'no workspace') : `interactive @ ${warm.composerMs}ms` }, ]; console.log(`\n WARM launch — seeded returning power user @ ${BASE}\n`); console.log(` ${pad('check', 30)}${pad('measured', 12)}${pad('budget', 12)}result`); console.log(' ' + '─'.repeat(66)); for (const r of rows) { console.log(` ${pad(r.name, 30)}${pad(r.measured, 12)}${pad(r.budget, 12)}${r.pass ? '✓' : '✗'} ${r.note}`); } console.log(` ${pad('day-0 first paint (info)', 30)}${pad(fmtMs(warm.day0Ms), 12)}${pad('—', 12)}ⓘ unprimed cache; not gated (cachePrimed=${warm.cachePrimed})`); return rows; } function printColdTable(cold, baseline) { const contracts = [ { key: 'cachedPaint', name: 'cached paint renders content (sidecar down)', holds: cold.cachedPaint }, { key: 'typingQueues', name: 'composer accepts typing (sidecar down)', holds: cold.typingQueues === true }, ]; console.log(`\n COLD start — sidecar down (all /api/** aborted)\n`); console.log(` ${pad('contract', 46)}${pad('holds', 8)}baseline`); console.log(' ' + '─'.repeat(66)); for (const c of contracts) { const wasLanded = baseline?.[c.key] === true; const regressed = wasLanded && !c.holds; let tag; if (c.key === 'typingQueues' && cold.typingQueues == null) tag = 'skipped (no workspace)'; else if (regressed) tag = 'REGRESSED (was landed)'; else if (wasLanded) tag = 'landed'; else if (c.key === 'typingQueues' && !c.holds) tag = cold.composerRendered ? 'not landed (composer present, keystroke rejected)' : 'not landed (workspace unreachable offline)'; else tag = 'not landed'; console.log(` ${pad(c.name, 46)}${pad(c.holds ? 'yes' : 'no', 8)}${tag}`); } return contracts; } // ── Main ───────────────────────────────────────────────────────────────────────── async function main() { // Reachability. try { const res = await fetch(`${BASE}/`, { signal: AbortSignal.timeout(4000) }); if (!res.ok && res.status >= 500) throw new Error(`status ${res.status}`); } catch (e) { console.error(`\n✗ warm-interaction — dev server not reachable at ${BASE} (${e.message}).`); console.error(' Start it (npm run dev on :8080, sidecar on :3333) then re-run. Exit 2.\n'); process.exit(2); } let chromium; try { ({ chromium } = await import('playwright')); } catch { console.error('\n✗ warm-interaction — `playwright` is not installed. Exit 2.\n'); process.exit(2); } const browser = await chromium.launch(); let warm = null, cold = null; try { // A discarded warm-up navigation compiles the vite modules server-side so the // measured pass reflects the PRODUCT's warm feel (React mount + sidecar), not // vite's one-time on-demand compile. No-op cost on the built app (:3333). { const warmup = await browser.newContext(); const wp = await warmup.newPage(); await wp.addInitScript(seedReturningUser, 'dark'); await wp.goto(`${BASE}/home`, { waitUntil: 'domcontentloaded', timeout: 40000 }).catch(() => {}); await wp.waitForSelector(SEL.homeContent, { timeout: 25000 }).catch(() => {}); await warmup.close(); } if (!COLD_ONLY) warm = await measureWarm(browser); if (!WARM_ONLY) cold = await measureCold(browser); } finally { await browser.close(); } const baseline = existsSync(BASELINE) ? JSON.parse(readFileSync(BASELINE, 'utf8')) : {}; // ── Update baseline (freeze the cold contract hold-state) ───────────────────── if (UPDATE) { if (!cold) { console.error('\n✗ --update-baseline needs the cold pass (do not combine with --warm-only). Exit 2.\n'); process.exit(2); } const next = { generatedAt: new Date().toISOString().slice(0, 10), base: BASE, note: 'Frozen cold-start contract hold-state. The gate fails on a contract that REGRESSES from true→false (or, under --strict, any false).', cachedPaint: cold.cachedPaint, typingQueues: cold.typingQueues === true, }; writeFileSync(BASELINE, JSON.stringify(next, null, 2) + '\n'); console.log(`\nux-gate · warm-interaction — baseline written (cachedPaint=${next.cachedPaint}, typingQueues=${next.typingQueues}).`); process.exit(0); } // ── Report ──────────────────────────────────────────────────────────────────── let warmRows = [], coldContracts = []; console.log(`\nux-gate · warm-interaction`); console.log(` seed (returning user, no bypass params): ${Object.keys(SEED_DISCLOSURE).join(', ')}`); if (warm) warmRows = printWarmTable(warm); if (cold) coldContracts = printColdTable(cold, baseline); writeFileSync(REPORT, JSON.stringify({ generatedAt: new Date().toISOString(), base: BASE, budgets: { HOME_BUDGET_MS, BRAND_BUDGET_MS }, warm, cold }, null, 2) + '\n'); console.log(`\n full report → ${path.relative(ROOT, REPORT)}`); // ── Verdict ───────────────────────────────────────────────────────────────────── const warmBreaches = warm ? warmRows.filter((r) => !r.pass) : []; // Infra: the app never reached home content at all → we cannot measure. const warmInfra = warm && warm.homeMs == null; const coldRegressions = cold ? coldContracts.filter((c) => baseline?.[c.key] === true && !c.holds) : []; const coldStrictFails = cold && STRICT ? coldContracts.filter((c) => !c.holds && !(c.key === 'typingQueues' && cold.typingQueues == null)) : []; // --report-only never gates: print the table + summary and exit 0, even if the // app never reached content (the orchestrator's "report the timing table" use). if (REPORT_ONLY) { const infraNote = warmInfra ? ' (home content never reached — auth/sidecar)' : ''; console.log(`\nⓘ --report-only: ${warmBreaches.length} warm breach(es), ${coldRegressions.length} cold regression(s)${infraNote}. Exit 0 (not gating).\n`); process.exit(0); } if (warmInfra) { console.error(`\n✗ warm-interaction — home content never rendered at ${BASE} (auth/sidecar problem). Cannot measure. Exit 2.\n`); process.exit(2); } if (!existsSync(BASELINE) && cold) { console.log('\n ⓘ No cold baseline yet. Review the contracts above, then seed the ratchet with'); console.log(' `node scripts/ux-gates/warm-interaction-gate.mjs --update-baseline`.'); } const fails = warmBreaches.length + coldRegressions.length + coldStrictFails.length; if (fails > 0) { const parts = []; if (warmBreaches.length) parts.push(`${warmBreaches.length} warm threshold breach(es): ${warmBreaches.map((r) => r.name).join(', ')}`); if (coldRegressions.length) parts.push(`${coldRegressions.length} cold contract regression(s): ${coldRegressions.map((c) => c.key).join(', ')}`); if (coldStrictFails.length) parts.push(`${coldStrictFails.length} cold contract not holding (--strict): ${coldStrictFails.map((c) => c.key).join(', ')}`); console.error(`\n✗ warm-interaction FAILED — ${parts.join('; ')}.\n`); process.exit(1); } console.log('\n✓ warm-interaction PASSED — warm thresholds met, no cold-contract regressions.\n'); } main().catch((e) => { console.error('warm-interaction crashed:', e); process.exit(2); });