import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { request as httpRequest } from "node:http"; import { join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import * as wizard from "../assets/install-wizard/wizard.js"; import { planPreflight, requirements } from "../scripts/installer-preflight.mjs"; import { blockedReasons, failedSteps } from "../scripts/installer-runner.mjs"; import { createInstallerServer, guidance } from "../scripts/installer-server.mjs"; import { scenarioNames, startPreview } from "../scripts/install-wizard-preview.mjs"; import { prefixLayout } from "../scripts/bundled-install.mjs"; import { bundledFailedSteps, bundledPlan, runBundledInstall } from "../scripts/bundled-wizard.mjs"; const assetsDir = fileURLToPath(new URL("../assets/install-wizard/", import.meta.url)); const read = (name: string) => readFileSync(join(assetsDir, name), "utf8"); const API = { "x-gentle-install": "1" }; const BIN = "/home/u/.local/share/pnpm/bin"; // Backtick segments render as elements, so rendered text has no backticks. const plain = (value: string) => value.replaceAll("`", ""); // --------------------------------------------------------------------------- // Real HTTP helpers (the real host serving the real assets) // --------------------------------------------------------------------------- type Response = { status: number; headers: Record; body: string }; function send(port: number, { method = "GET", path = "/", headers = {} as Record, body = undefined as string | undefined } = {}): Promise { return new Promise((resolve, reject) => { const all: Record = { host: `127.0.0.1:${port}`, ...headers }; if (body !== undefined) all["content-length"] = Buffer.byteLength(body); const req = httpRequest({ host: "127.0.0.1", port, method, path, headers: all, setHost: false, agent: false }, (res) => { const chunks: Buffer[] = []; res.on("data", (chunk) => chunks.push(chunk)); res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks).toString("utf8") })); }); req.on("error", reject); if (body !== undefined) req.write(body); req.end(); }); } async function login(port: number, url: string) { const code = new URL(url).searchParams.get("code") ?? ""; const response = await send(port, { path: `/session?code=${code}` }); assert.equal(response.status, 200); return String(response.headers["set-cookie"]).split(";")[0]; } function post(port: number, path: string, cookie: string, body: unknown) { return send(port, { method: "POST", path, body: JSON.stringify(body), headers: { cookie, origin: `http://127.0.0.1:${port}`, "content-type": "application/json", ...API } }); } async function getJson(port: number, path: string, cookie: string) { const response = await send(port, { path, headers: { cookie, ...API } }); return { status: response.status, body: JSON.parse(response.body) }; } const cleanInventory = { platform: "linux", arch: "x64", node: { available: true, version: "24.18.0", usable: true, persistent: false, npm: false }, pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false }, pi: { available: false }, shell: { available: false }, gentleAi: { available: false }, go: { available: false }, globalBin: { available: true, path: BIN, writable: true, onPath: false }, setup: false, }; /** The real host's /api/plan view for an inventory, so model tests never drift from the server. */ async function serverPlanView(changes: Record = {}, channel = "release") { const inventory = { ...cleanInventory, ...changes }; const host = createInstallerServer({ assetsDir, collectPlan: async (requested: string) => ({ inventory, plan: planPreflight(inventory, { channel: requested }) }), runInstall: async () => ({ outcome: "ready", completed: [] }) }); const { port, url } = await host.listen(); try { const cookie = await login(port, url); const { status, body } = await getJson(port, `/api/plan?channel=${channel}`, cookie); assert.equal(status, 200); return body; } finally { await host.close("test"); } } // --------------------------------------------------------------------------- // Minimal fake DOM: only the APIs wizard.js may use. innerHTML throws. // --------------------------------------------------------------------------- class FakeText { data: string; parent: FakeElement | null = null; constructor(data: string) { this.data = data; } get textContent() { return this.data; } } class FakeElement { tagName: string; ownerDocument: FakeDocument; attributes = new Map(); children: Array = []; listeners = new Map unknown>>(); parent: FakeElement | null = null; checked = false; disabled = false; constructor(ownerDocument: FakeDocument, tag: string) { this.ownerDocument = ownerDocument; this.tagName = tag.toUpperCase(); } get textContent(): string { return this.children.map((child) => child.textContent).join(""); } set textContent(value: string) { this.replaceChildren(String(value)); } set innerHTML(_value: string) { throw new Error("innerHTML is forbidden"); } set outerHTML(_value: string) { throw new Error("outerHTML is forbidden"); } insertAdjacentHTML() { throw new Error("insertAdjacentHTML is forbidden"); } get id() { return this.attributes.get("id") ?? ""; } append(...nodes: Array) { for (const node of nodes) { const child = typeof node === "string" ? new FakeText(node) : node; child.parent?.children.splice(child.parent.children.indexOf(child), 1); child.parent = this; this.children.push(child); } } appendChild(node: FakeElement | FakeText) { this.append(node); return node; } replaceChildren(...nodes: Array) { for (const child of this.children) child.parent = null; this.children = []; this.append(...nodes); } remove() { this.parent?.children.splice(this.parent.children.indexOf(this), 1); this.parent = null; } setAttribute(name: string, value: string) { if (name === "style" || /^on/i.test(name)) throw new Error(`forbidden attribute ${name}`); this.attributes.set(name, String(value)); } getAttribute(name: string) { return this.attributes.get(name) ?? null; } hasAttribute(name: string) { return this.attributes.has(name); } removeAttribute(name: string) { this.attributes.delete(name); } addEventListener(type: string, listener: (event: unknown) => unknown) { this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); } focus() { this.ownerDocument.activeElement = this; } } class FakeDocument { activeElement: FakeElement | null = null; roots = new Map(); constructor() { for (const id of ["view", "stages", "alert", "status"]) { const root = new FakeElement(this, "div"); root.setAttribute("id", id); this.roots.set(id, root); } } createElement(tag: string) { return new FakeElement(this, tag); } createTextNode(text: string) { return new FakeText(text); } getElementById(id: string) { return this.roots.get(id) ?? null; } } function walk(node: FakeElement | FakeText, out: FakeElement[] = []) { if (node instanceof FakeElement) { out.push(node); for (const child of node.children) walk(child, out); } return out; } const all = (root: FakeElement, tag: string) => walk(root).filter((element) => element.tagName === tag.toUpperCase()); const button = (root: FakeElement, text: string) => all(root, "button").find((element) => element.textContent.includes(text)); const headings = (root: FakeElement) => all(root, "h1"); function dispatch(element: FakeElement, type: string) { for (const listener of element.listeners.get(type) ?? []) listener({ type, target: element, preventDefault() {} }); } async function flush() { for (let i = 0; i < 30; i += 1) await new Promise((done) => setImmediate(done)); } type Timer = { fn: () => void; ms: number; cleared: boolean; ran: boolean }; function fakeTimers() { const queue: Timer[] = []; const pending = () => queue.filter((timer) => !timer.cleared && !timer.ran); return { setTimeout: (fn: () => void, ms: number) => { const timer = { fn, ms, cleared: false, ran: false }; queue.push(timer); return timer; }, clearTimeout: (timer: Timer | undefined) => { if (timer) timer.cleared = true; }, pending, async runNext() { const [timer] = pending(); assert.ok(timer, "a timer is scheduled"); timer.ran = true; timer.fn(); await flush(); return timer; }, }; } type Call = { url: string; method: string; headers: Record; body: unknown }; type Reply = [number, unknown] | Error; function fakeFetch(handlers: Record Reply | Promise>) { const calls: Call[] = []; const fetch = async (url: string, init: { method?: string; headers?: Record; body?: string } = {}) => { const call = { url, method: init.method ?? "GET", headers: init.headers ?? {}, body: init.body === undefined ? undefined : JSON.parse(init.body) }; calls.push(call); const handler = handlers[`${call.method} ${url.split("?")[0]}`]; assert.ok(handler, `unexpected request ${call.method} ${url}`); const result = await handler(call); if (result instanceof Error) throw result; const [status, body] = result; return { status, ok: status >= 200 && status < 300, json: async () => body }; }; return { fetch, calls }; } type Clipboard = { writeText: (value: string) => Promise } | null; function mount(handlers: Record Reply | Promise>, { clipboard = null as Clipboard } = {}) { const document = new FakeDocument(); const timers = fakeTimers(); const server = fakeFetch(handlers); const app = wizard.createWizard({ document, fetch: server.fetch, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout, clipboard }); const view = document.roots.get("view") as FakeElement; return { document, timers, server, app, view, alert: document.roots.get("alert") as FakeElement, status: document.roots.get("status") as FakeElement }; } const idleProgress = (): Reply => [200, { entries: [], running: false, outcome: null }]; const outcomeProgress = (outcome: Record): Reply => [200, { entries: [], running: false, outcome }]; const readyOutcome = { outcome: "ready", completed: ["install-global"], guidance: guidance.outcomes.ready }; /** Ticks the consent checkbox on the review screen and presses Install. */ async function consentAndInstall(ui: ReturnType) { const consent = all(ui.view, "input").find((input) => input.getAttribute("type") === "checkbox") as FakeElement; consent.checked = true; dispatch(consent, "change"); dispatch(button(ui.view, "Install") as FakeElement, "click"); await flush(); } const posts = (ui: ReturnType, path: string) => ui.server.calls.filter((call) => call.method === "POST" && call.url === path).length; const progressChecks = (ui: ReturnType) => ui.server.calls.filter((call) => call.url.startsWith("/api/progress")).length; // --------------------------------------------------------------------------- // Static assets under the strict CSP // --------------------------------------------------------------------------- test("index.html loads only same-origin wizard.js and wizard.css, with no inline script, style or handlers", () => { const html = read("index.html"); assert.match(html, /