diff --git a/.gitignore b/.gitignore index 36d2e6aee9..97350f9e50 100644 --- a/.gitignore +++ b/.gitignore @@ -450,3 +450,6 @@ fedac/native/upload.env.gpg # Stray GPG key backups (canonical copies live in aesthetic-computer-vault/gpg/) /gpg/ + +# Browser e2e screenshots (tests/browser/) +tests/browser/__screens__/ diff --git a/package.json b/package.json index 95df63b83e..3d37cf64b5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "test:perf": "node tests/performance/boot-performance.test.mjs", "test:perf:chrome": "node tests/performance/chrome-devtools-test.mjs", "test:perf:lighthouse": "RUN_LIGHTHOUSE=true node tests/performance/chrome-devtools-test.mjs", + "test:browser": "node tests/browser/prompt-search.test.mjs", + "test:browser:headed": "AC_HEADED=1 AC_SLOWMO=60 node tests/browser/prompt-search.test.mjs", + "test:browser:local": "AC_TEST_URL=https://localhost:8888 node tests/browser/prompt-search.test.mjs", "test:kidlisp": "nodemon --watch spec --watch '**/*.lisp' --exec 'jasmine'", "test:kidlisp:direct": "jasmine", "test:kidlisp-wasm:audio": "jasmine spec/kidlisp-wasm-sonic-spec.mjs", diff --git a/system/public/aesthetic.computer/disks/prompt.mjs b/system/public/aesthetic.computer/disks/prompt.mjs index 12fdbd2cc9..af3f9f2940 100644 --- a/system/public/aesthetic.computer/disks/prompt.mjs +++ b/system/public/aesthetic.computer/disks/prompt.mjs @@ -988,6 +988,38 @@ async function boot({ universal: { fetch: universalSearch, minChars: 2, cache: false }, }); + // ๐Ÿงช Read-only test hook for the browser harness (tests/browser/). + // Gated on window.acDEBUG so it never attaches for normal users; the + // harness sets acDEBUG via evaluateOnNewDocument before navigation. + if (typeof window !== "undefined" && window.acDEBUG) { + window.__acPromptTest = () => { + const input = system?.prompt?.input; + const ac = handleAutocomplete; + return { + input: input?.text ?? null, + kidlispMode: !!system?.prompt?.kidlispMode, + deprecateUniticker: DEPRECATE_UNITICKER, + ac: ac + ? { + visible: !!ac.visible, + loading: !!ac.loading, + activeTrigger: ac.activeTrigger, + navigated: !!ac.navigated, + selectedIndex: ac.selectedIndex, + items: (ac.items || []).map((i) => ({ + text: i.text, + display: i.display || null, + color: i.color || null, + })), + } + : null, + histScrub: histScrub + ? { engaged: !!histScrub.engaged } + : null, + }; + }; + } + // ๏ฟฝ๐Ÿ“ฆ Load product images (DISABLED for now) await products.boot(api); diff --git a/tests/browser/README.md b/tests/browser/README.md new file mode 100644 index 0000000000..880dcd5dad --- /dev/null +++ b/tests/browser/README.md @@ -0,0 +1,38 @@ +# Browser e2e (`tests/browser/`) + +Drives a real local Chrome (puppeteer, already a repo dep) against a live AC +URL โ€” types, presses keys, drags, screenshots, and asserts prompt state. + +## Run + +```bash +npm run test:browser # headless, production (https://aesthetic.computer) +npm run test:browser:headed # watch it (visible + slow-mo) +npm run test:browser:local # against a local `npm run site` (localhost:8888) +``` + +Env overrides: `AC_TEST_URL`, `AC_HEADED=1`, `AC_SLOWMO=`, `AC_SHOT_DIR`. + +Screenshots land in `tests/browser/__screens__/` (gitignored). + +## How it works + +The AC prompt renders to ``, so there are no DOM nodes to assert on. +The harness sets `window.acDEBUG` before navigation; `prompt.mjs` then exposes +a **read-only** `window.__acPromptTest()` snapshot (autocomplete visibility, +active trigger, items + colors, `navigated`, input text, rolodex state). The +harness reads that for assertions and always captures screenshots. + +Against a target **without** the hook (e.g. current production, before the +`universal-search` branch ships) state assertions soft-skip and the run +degrades to screenshot-only smoke โ€” so point `AC_TEST_URL` at a local dev +server running this branch to get the full validation. + +## Model + +- `ac-harness.mjs` โ€” `ACSession` (launch/boot/type/press/drag/shot/state) + + a dependency-free `scenario()` / `report()` runner. One Chrome instance + only (this laptop is 8 GB โ€” never parallel). +- `prompt-search.test.mjs` โ€” scenarios: boot, sigil `$`, universal bare-word + search (color-typed), Enter-discipline + Tab-complete, rolodex drag, + UNITICKER deprecation. diff --git a/tests/browser/ac-harness.mjs b/tests/browser/ac-harness.mjs new file mode 100644 index 0000000000..170a4159a3 --- /dev/null +++ b/tests/browser/ac-harness.mjs @@ -0,0 +1,196 @@ +// ac-harness, 2026.05.18 +// A small reusable model for driving Aesthetic Computer in a real local +// Chrome (puppeteer) โ€” types, presses keys, drags, screenshots, and reads +// prompt state via the window.__acPromptTest hook. Mirrors the launch +// conventions already used in tests/performance/chrome-devtools-test.mjs. +// +// Env: +// AC_TEST_URL base URL (default production https://aesthetic.computer) +// AC_HEADED=1 show the browser (default headless) +// AC_SLOWMO ms slow-mo between input ops (default 0) +// AC_SHOT_DIR screenshot output dir (default tests/browser/__screens__) +// +// The AC prompt renders to , so DOM queries don't work โ€” assertions +// read window.__acPromptTest() (present only when the page was booted with +// window.acDEBUG, which this harness sets before navigation). When the hook +// is absent (e.g. current production, before this branch ships) scenarios +// degrade to screenshot-only smoke and `state()` returns null. + +import puppeteer from "puppeteer"; +import { mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +export const CONFIG = { + baseURL: process.env.AC_TEST_URL || "https://aesthetic.computer", + headed: process.env.AC_HEADED === "1", + slowMo: parseInt(process.env.AC_SLOWMO || "0", 10) || 0, + shotDir: process.env.AC_SHOT_DIR || join(HERE, "__screens__"), +}; + +export class ACSession { + browser = null; + page = null; + shots = []; + + // One Chrome instance only (this laptop is 8 GB โ€” never run parallel). + static async open() { + const s = new ACSession(); + mkdirSync(CONFIG.shotDir, { recursive: true }); + s.browser = await puppeteer.launch({ + headless: !CONFIG.headed, + slowMo: CONFIG.slowMo, + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--ignore-certificate-errors", // local https://localhost:8888 + "--disable-web-security", + "--window-size=900,1200", + ], + }); + s.page = await s.browser.newPage(); + await s.page.setViewport({ width: 900, height: 1200 }); + // Surface the test hook at boot (prompt.mjs gates it on acDEBUG). + await s.page.evaluateOnNewDocument(() => { + window.acDEBUG = true; + }); + s.page.on("pageerror", (e) => console.warn(" โš ๏ธ pageerror:", e.message)); + return s; + } + + // Navigate to a piece (default `prompt`) and wait for boot. + async boot(piece = "prompt") { + const url = `${CONFIG.baseURL}/${piece}`; + console.log(` โ†’ ${url}`); + await this.page.goto(url, { waitUntil: "networkidle2", timeout: 45000 }); + // Prefer the prompt test hook; fall back to the boot marker; then a + // fixed settle so the canvas has painted. + try { + await this.page.waitForFunction( + () => + typeof window.__acPromptTest === "function" || + window.acBOOT_START_TIME, + { timeout: 20000 }, + ); + } catch { + console.warn(" โš ๏ธ no boot/test marker โ€” continuing on a timer"); + } + await this.wait(2500); + return this; + } + + wait(ms) { + return new Promise((r) => setTimeout(r, ms)); + } + + // AC captures keys globally, but a tap focuses/activates the keyboard + // (mobile-style). Click low-center where the prompt input sits. + async focusPrompt() { + const vp = this.page.viewport(); + await this.page.mouse.click(vp.width / 2, Math.round(vp.height * 0.82)); + await this.wait(400); + return this; + } + + async type(text, { delay = 45 } = {}) { + await this.page.keyboard.type(text, { delay }); + return this; + } + + async press(key) { + await this.page.keyboard.press(key); + await this.wait(120); + return this; + } + + async clearInput() { + // Select-all + delete works whether or not text is present. + await this.page.keyboard.down("Meta"); + await this.page.keyboard.press("KeyA"); + await this.page.keyboard.up("Meta"); + await this.page.keyboard.press("Backspace"); + await this.wait(150); + return this; + } + + // Vertical drag (rolodex history scrub). x defaults to mid-screen. + async dragVertical({ fromY, toY, x, steps = 24 }) { + const vp = this.page.viewport(); + const px = x ?? vp.width / 2; + await this.page.mouse.move(px, fromY); + await this.page.mouse.down(); + for (let i = 1; i <= steps; i++) { + await this.page.mouse.move(px, fromY + ((toY - fromY) * i) / steps); + await this.wait(12); + } + await this.page.mouse.up(); + await this.wait(300); + return this; + } + + // Wait out the autocomplete debounce (250ms) + best-effort network. + async settleSearch(ms = 1400) { + await this.wait(ms); + return this; + } + + // Prompt state via the window hook, or null if the hook is absent. + async state() { + return await this.page.evaluate(() => + typeof window.__acPromptTest === "function" + ? window.__acPromptTest() + : null, + ); + } + + async shot(name) { + const path = join(CONFIG.shotDir, `${name}.png`); + await this.page.screenshot({ path }); + this.shots.push(path); + console.log(` ๐Ÿ“ธ ${path}`); + return path; + } + + async close() { + if (this.browser) await this.browser.close(); + } +} + +// โ”€โ”€โ”€ tiny dependency-free scenario runner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const results = []; + +export async function scenario(name, fn) { + const checks = []; + const expect = (cond, msg) => { + checks.push({ ok: !!cond, msg }); + console.log(` ${cond ? "โœ“" : "โœ—"} ${msg}`); + }; + process.stdout.write(`\nโ–ถ ${name}\n`); + let error = null; + try { + await fn(expect); + } catch (e) { + error = e; + console.error(" ๐Ÿ’ฅ", e.message); + } + const failed = checks.filter((c) => !c.ok).length; + results.push({ name, total: checks.length, failed, error }); +} + +export function report() { + console.log("\nโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ summary โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€"); + let bad = 0; + for (const r of results) { + const status = r.error + ? "ERROR" + : r.failed > 0 + ? `FAIL ${r.failed}/${r.total}` + : `ok ${r.total}`; + if (r.error || r.failed > 0) bad++; + console.log(` ${r.error || r.failed ? "โœ—" : "โœ“"} ${r.name} โ€” ${status}`); + } + console.log(`\n${bad === 0 ? "โœ… all green" : `โŒ ${bad} scenario(s) bad`}`); + return bad === 0 ? 0 : 1; +} diff --git a/tests/browser/prompt-search.test.mjs b/tests/browser/prompt-search.test.mjs new file mode 100644 index 0000000000..538d5154e3 --- /dev/null +++ b/tests/browser/prompt-search.test.mjs @@ -0,0 +1,121 @@ +// prompt-search.test, 2026.05.18 +// End-to-end browser validation of the universal AC search box, sigil +// autocomplete, Enter-discipline, and the rolodex history scrub โ€” driven +// through a real local Chrome against a live URL. +// +// npm run test:browser # headless, production +// AC_TEST_URL=https://localhost:8888 npm run test:browser # local branch +// AC_HEADED=1 AC_SLOWMO=60 npm run test:browser # watch it +// +// Assertions that need internals use window.__acPromptTest (present only +// when the target was built from the universal-search branch). Against a +// target without it, those checks soft-skip and screenshots still capture. + +import { ACSession, scenario, report } from "./ac-harness.mjs"; + +const ac = await ACSession.open(); +let hookSeen = false; + +try { + await scenario("boot prompt", async (expect) => { + await ac.boot("prompt"); + await ac.shot("01-boot"); + const st = await ac.state(); + hookSeen = st !== null; + if (!hookSeen) { + console.log( + " โ„น๏ธ no __acPromptTest on this target โ€” screenshot-only mode " + + "(deploy the universal-search branch or point AC_TEST_URL at a " + + "local `npm run site` to get state assertions).", + ); + } + expect(true, "page booted without throwing"); + }); + + await scenario("sigil autocomplete: $", async (expect) => { + await ac.focusPrompt(); + await ac.clearInput(); + await ac.type("$"); + await ac.settleSearch(); + await ac.shot("02-sigil-dollar"); + const st = await ac.state(); + if (st?.ac) { + expect(st.ac.activeTrigger === "$", `activeTrigger is "$" (got ${JSON.stringify(st.ac.activeTrigger)})`); + expect(st.ac.visible === true, "dropdown visible"); + } else if (hookSeen) { + expect(false, "state hook returned no ac"); + } + }); + + await scenario("universal search: bare word", async (expect) => { + await ac.focusPrompt(); + await ac.clearInput(); + await ac.type("line"); + await ac.settleSearch(1800); + await ac.shot("03-universal-line"); + const st = await ac.state(); + if (st?.ac) { + expect(st.ac.activeTrigger === "", `universal mode (activeTrigger === "", got ${JSON.stringify(st.ac.activeTrigger)})`); + expect(st.ac.items.length > 0, `has results (${st.ac.items.length})`); + const colored = st.ac.items.some((i) => Array.isArray(i.color)); + expect(colored, "results are color-typed"); + expect(st.ac.navigated === false, "not navigated yet (Enter would still RUN the command)"); + } + }); + + await scenario("Enter discipline + arrow-to-complete", async (expect) => { + // From the universal results, arrowing in must flip `navigated`. + await ac.press("ArrowDown"); + const st = await ac.state(); + if (st?.ac) { + expect(st.ac.navigated === true, "ArrowDown set navigated=true"); + } + await ac.shot("04-universal-navigated"); + // Tab completes the selected result wholesale (universal getCompletedText). + await ac.press("Tab"); + await ac.settleSearch(600); + const st2 = await ac.state(); + if (st2) { + expect( + typeof st2.input === "string" && st2.input.length > 0, + `Tab completed the input (got ${JSON.stringify(st2.input)})`, + ); + } + await ac.shot("05-after-tab-complete"); + }); + + await scenario("rolodex: vertical drag scrubs history", async (expect) => { + // Seed one history entry for the prompt slug, then return to prompt. + await ac.focusPrompt(); + await ac.clearInput(); + await ac.type("notepat"); + await ac.press("Enter"); + await ac.wait(2500); + await ac.boot("prompt"); // back to a fresh prompt + await ac.focusPrompt(); + await ac.clearInput(); + await ac.shot("06-rolodex-before"); + const vp = { h: 1200 }; + // Swipe up from low on the screen toward the middle. + await ac.dragVertical({ fromY: Math.round(vp.h * 0.8), toY: Math.round(vp.h * 0.45) }); + await ac.shot("07-rolodex-after"); + const st = await ac.state(); + if (st) { + expect( + typeof st.input === "string" && st.input.length > 0, + `drag pulled a prior command into the input (got ${JSON.stringify(st.input)})`, + ); + } + }); + + await scenario("UNITICKER deprecated", async (expect) => { + const st = await ac.state(); + if (st) { + expect(st.deprecateUniticker === true, "DEPRECATE_UNITICKER is on"); + } + }); +} finally { + await ac.close(); +} + +process.exit(report());