From ac17455ee3d5c5a3d2cf38de0810b01a0a4f5180 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:17:16 -0400 Subject: [PATCH 01/22] Add declarative journey schema validation --- packages/cli/src/journey-schema.js | 184 +++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 packages/cli/src/journey-schema.js diff --git a/packages/cli/src/journey-schema.js b/packages/cli/src/journey-schema.js new file mode 100644 index 0000000..e924399 --- /dev/null +++ b/packages/cli/src/journey-schema.js @@ -0,0 +1,184 @@ +const ACTIONS = new Set([ + "goto", + "click", + "fill", + "press", + "viewport", + "expect-visible", + "expect-hidden", + "expect-text", + "expect-url", + "checkpoint", + "audit" +]); + +const KEYS = new Set([ + "Enter", + "Escape", + "Tab", + "Space", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Home", + "End", + "PageUp", + "PageDown" +]); + +const ROLES = new Set([ + "alert", "banner", "button", "cell", "checkbox", "combobox", "complementary", + "contentinfo", "dialog", "form", "heading", "img", "link", "list", "listbox", + "listitem", "main", "menu", "menuitem", "navigation", "option", "progressbar", + "radio", "region", "row", "search", "slider", "spinbutton", "status", "switch", + "tab", "table", "tablist", "textbox" +]); + +export class JourneyValidationError extends Error { + constructor(issues) { + super(`Invalid journey (${issues.length} issue${issues.length === 1 ? "" : "s"})`); + this.name = "JourneyValidationError"; + this.issues = issues; + } +} + +function isObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function allowedKeys(value, path, allowed, issues) { + if (!isObject(value)) return; + for (const key of Object.keys(value)) if (!allowed.has(key)) issues.push(`${path}.${key}: unknown property`); +} + +function string(value, path, issues, { min = 1, max = 2_000 } = {}) { + if (typeof value !== "string") return issues.push(`${path}: must be a string`); + if (value.length < min || value.length > max) issues.push(`${path}: length must be between ${min} and ${max}`); +} + +function integer(value, path, issues, min, max) { + if (!Number.isSafeInteger(value) || value < min || value > max) issues.push(`${path}: must be an integer between ${min} and ${max}`); +} + +function score(value, path, issues) { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) issues.push(`${path}: must be a number between 0 and 100`); +} + +function route(value, path, issues) { + string(value, path, issues, { max: 2_048 }); + if (typeof value === "string" && (!value.startsWith("/") || value.startsWith("//") || value.includes("\\") || value.includes("\0"))) { + issues.push(`${path}: must be a same-origin route beginning with a single /`); + } +} + +function validateViewport(value, path, issues) { + if (!isObject(value)) return issues.push(`${path}: must be an object`); + allowedKeys(value, path, new Set(["width", "height"]), issues); + integer(value.width, `${path}.width`, issues, 320, 3_840); + integer(value.height, `${path}.height`, issues, 320, 2_160); +} + +function validateTarget(value, path, issues) { + if (!isObject(value)) return issues.push(`${path}: must be an object`); + allowedKeys(value, path, new Set(["role", "name", "label", "placeholder", "text", "testId", "exact"]), issues); + const strategies = ["role", "label", "placeholder", "text", "testId"].filter((key) => value[key] !== undefined); + if (strategies.length !== 1) issues.push(`${path}: exactly one locator strategy is required`); + if (value.role !== undefined) { + string(value.role, `${path}.role`, issues, { max: 50 }); + if (typeof value.role === "string" && !ROLES.has(value.role)) issues.push(`${path}.role: unsupported role`); + if (value.name !== undefined) string(value.name, `${path}.name`, issues, { min: 0, max: 500 }); + } else if (value.name !== undefined) { + issues.push(`${path}.name: only valid with role`); + } + for (const key of ["label", "placeholder", "text", "testId"]) { + if (value[key] !== undefined) string(value[key], `${path}.${key}`, issues, { min: 0, max: 500 }); + } + if (value.exact !== undefined && typeof value.exact !== "boolean") issues.push(`${path}.exact: must be boolean`); +} + +function validateStep(step, index, issues, checkpointNames) { + const path = `$.steps[${index}]`; + if (!isObject(step)) return issues.push(`${path}: must be an object`); + allowedKeys(step, path, new Set(["action", "route", "target", "value", "key", "width", "height", "text", "exact", "name", "minScore", "timeoutMs"]), issues); + string(step.action, `${path}.action`, issues, { max: 50 }); + if (typeof step.action !== "string" || !ACTIONS.has(step.action)) { + issues.push(`${path}.action: unsupported action`); + return; + } + if (step.timeoutMs !== undefined) integer(step.timeoutMs, `${path}.timeoutMs`, issues, 100, 120_000); + + switch (step.action) { + case "goto": + case "expect-url": + route(step.route, `${path}.route`, issues); + break; + case "click": + case "expect-visible": + case "expect-hidden": + validateTarget(step.target, `${path}.target`, issues); + break; + case "fill": + validateTarget(step.target, `${path}.target`, issues); + string(step.value, `${path}.value`, issues, { min: 0, max: 10_000 }); + break; + case "press": + string(step.key, `${path}.key`, issues, { max: 50 }); + if (typeof step.key === "string" && !KEYS.has(step.key)) issues.push(`${path}.key: unsupported key`); + break; + case "viewport": + integer(step.width, `${path}.width`, issues, 320, 3_840); + integer(step.height, `${path}.height`, issues, 320, 2_160); + break; + case "expect-text": + string(step.text, `${path}.text`, issues, { min: 0, max: 2_000 }); + if (step.exact !== undefined && typeof step.exact !== "boolean") issues.push(`${path}.exact: must be boolean`); + break; + case "checkpoint": + string(step.name, `${path}.name`, issues, { max: 120 }); + if (typeof step.name === "string") { + if (!/^[a-z0-9][a-z0-9._-]{0,119}$/i.test(step.name)) issues.push(`${path}.name: invalid checkpoint name`); + if (checkpointNames.has(step.name)) issues.push(`${path}.name: duplicate checkpoint name`); + checkpointNames.add(step.name); + } + break; + case "audit": + if (step.minScore !== undefined) score(step.minScore, `${path}.minScore`, issues); + break; + } +} + +export function validateJourney(input) { + const issues = []; + if (!isObject(input)) issues.push("$: must be an object"); + if (issues.length === 0) { + allowedKeys(input, "$", new Set(["version", "name", "startRoute", "viewport", "steps", "continueOnFailure", "minimumAccessibilityScore"]), issues); + if (input.version !== 1) issues.push("$.version: expected 1"); + string(input.name, "$.name", issues, { max: 300 }); + route(input.startRoute ?? "/", "$.startRoute", issues); + if (input.viewport !== undefined) validateViewport(input.viewport, "$.viewport", issues); + if (input.continueOnFailure !== undefined && typeof input.continueOnFailure !== "boolean") issues.push("$.continueOnFailure: must be boolean"); + if (input.minimumAccessibilityScore !== undefined) score(input.minimumAccessibilityScore, "$.minimumAccessibilityScore", issues); + if (!Array.isArray(input.steps)) issues.push("$.steps: must be an array"); + else { + if (input.steps.length < 1 || input.steps.length > 200) issues.push("$.steps: length must be between 1 and 200"); + const checkpointNames = new Set(); + input.steps.forEach((step, index) => validateStep(step, index, issues, checkpointNames)); + } + } + if (issues.length) throw new JourneyValidationError(issues); + return { + ...input, + startRoute: input.startRoute ?? "/", + viewport: input.viewport ?? { width: 1440, height: 1000 }, + continueOnFailure: input.continueOnFailure === true, + minimumAccessibilityScore: input.minimumAccessibilityScore ?? 80 + }; +} + +export function safeJourneyStep(step) { + if (step.action !== "fill") return structuredClone(step); + return { ...step, value: "[REDACTED]", valueLength: String(step.value ?? "").length }; +} + +export const JOURNEY_ACTIONS = Object.freeze([...ACTIONS]); From e096aff70d956e69d10d7a6c3f32e3fc9353b9be Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:17:39 -0400 Subject: [PATCH 02/22] Add bounded accessibility audit for journey checkpoints --- packages/cli/src/journey-a11y.js | 109 +++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 packages/cli/src/journey-a11y.js diff --git a/packages/cli/src/journey-a11y.js b/packages/cli/src/journey-a11y.js new file mode 100644 index 0000000..a885649 --- /dev/null +++ b/packages/cli/src/journey-a11y.js @@ -0,0 +1,109 @@ +const PENALTIES = Object.freeze({ critical: 25, serious: 10, moderate: 3, minor: 1 }); + +function round(value) { + return Math.round(Math.max(0, Math.min(100, value)) * 100) / 100; +} + +export function scoreAccessibilityFindings(findings) { + const penalty = findings.reduce((sum, finding) => sum + (PENALTIES[finding.severity] ?? 1) * Math.max(1, finding.count ?? 1), 0); + return round(100 - penalty); +} + +export async function auditAccessibility(page) { + const findings = await page.evaluate(() => { + const result = []; + const clean = (value, max = 160) => String(value ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, max); + const visible = (element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; + }; + const accessibleName = (element) => { + const labelledBy = clean(element.getAttribute("aria-labelledby"), 200); + if (labelledBy) { + const text = labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.textContent || "").join(" "); + if (clean(text)) return clean(text); + } + const aria = clean(element.getAttribute("aria-label"), 300); + if (aria) return aria; + if (element.id) { + const label = document.querySelector(`label[for="${CSS.escape(element.id)}"]`); + if (label && clean(label.textContent)) return clean(label.textContent); + } + const wrapped = element.closest("label"); + if (wrapped && clean(wrapped.textContent)) return clean(wrapped.textContent); + const alt = clean(element.getAttribute("alt"), 300); + if (alt) return alt; + const title = clean(element.getAttribute("title"), 300); + if (title) return title; + return clean(element.textContent, 300); + }; + const push = (severity, category, message, elements = []) => { + if (!elements.length) return; + result.push({ + severity, + category, + message, + count: elements.length, + examples: elements.slice(0, 5).map((element) => { + if (typeof element === "string") return clean(element); + const tag = element.tagName?.toLowerCase() || "element"; + const id = element.id ? `#${clean(element.id, 80)}` : ""; + return `${tag}${id}${accessibleName(element) ? ` (${accessibleName(element)})` : ""}`; + }) + }); + }; + + if (!clean(document.title)) result.push({ severity: "serious", category: "document", message: "Document title is missing.", count: 1, examples: [] }); + if (!clean(document.documentElement.lang)) result.push({ severity: "moderate", category: "document", message: "Document language is missing.", count: 1, examples: [] }); + + const mains = [...document.querySelectorAll("main,[role=main]")].filter(visible); + if (mains.length === 0) result.push({ severity: "serious", category: "landmark", message: "No visible main landmark was found.", count: 1, examples: [] }); + if (mains.length > 1) result.push({ severity: "moderate", category: "landmark", message: "Multiple visible main landmarks were found.", count: mains.length, examples: mains.slice(0, 5).map((item) => item.tagName.toLowerCase()) }); + + const headings = [...document.querySelectorAll("h1,h2,h3,h4,h5,h6")].filter(visible); + const h1s = headings.filter((item) => item.tagName === "H1"); + if (h1s.length === 0) result.push({ severity: "serious", category: "heading", message: "No visible level-one heading was found.", count: 1, examples: [] }); + if (h1s.length > 1) push("moderate", "heading", "Multiple visible level-one headings were found.", h1s); + const skipped = []; + let previous = 0; + for (const heading of headings) { + const level = Number(heading.tagName.slice(1)); + if (previous && level > previous + 1) skipped.push(`${heading.tagName.toLowerCase()}: ${clean(heading.textContent)}`); + previous = level; + } + push("moderate", "heading", "Heading levels skip one or more levels.", skipped); + + const imagesWithoutAlt = [...document.querySelectorAll("img")].filter((image) => !image.hasAttribute("alt")); + push("serious", "image", "Images without an alt attribute were found.", imagesWithoutAlt); + + const controls = [...document.querySelectorAll("input,select,textarea")].filter((element) => { + const type = (element.getAttribute("type") || "").toLowerCase(); + return visible(element) && type !== "hidden" && !accessibleName(element); + }); + push("serious", "control", "Visible form controls without an accessible name were found.", controls); + + const namelessInteractive = [...document.querySelectorAll("button,a[href],[role=button],[role=link]")].filter((element) => visible(element) && !accessibleName(element)); + push("serious", "interactive", "Visible interactive elements without an accessible name were found.", namelessInteractive); + + const positiveTabIndex = [...document.querySelectorAll("[tabindex]")].filter((element) => Number(element.getAttribute("tabindex")) > 0); + push("moderate", "focus", "Positive tabindex values were found.", positiveTabIndex); + + const ids = new Map(); + for (const element of document.querySelectorAll("[id]")) { + if (!ids.has(element.id)) ids.set(element.id, []); + ids.get(element.id).push(element); + } + const duplicates = [...ids.entries()].filter(([, elements]) => elements.length > 1).map(([id]) => id); + push("moderate", "document", "Duplicate element IDs were found.", duplicates); + + const autoFocus = [...document.querySelectorAll("[autofocus]")]; + push("minor", "focus", "Autofocus is present and may move focus unexpectedly.", autoFocus); + + return result.slice(0, 100); + }); + + const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 }; + for (const finding of findings) counts[finding.severity] = (counts[finding.severity] ?? 0) + (finding.count ?? 1); + return { score: scoreAccessibilityFindings(findings), counts, findings }; +} From 4e399c57973bebbba36262ca3db12ea1848c9345 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:19:41 -0400 Subject: [PATCH 03/22] Add journey report module --- packages/cli/src/journey-report.js | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/cli/src/journey-report.js diff --git a/packages/cli/src/journey-report.js b/packages/cli/src/journey-report.js new file mode 100644 index 0000000..b7224ab --- /dev/null +++ b/packages/cli/src/journey-report.js @@ -0,0 +1 @@ +export const JOURNEY_REPORT_VERSION = 1; From 73986eb785fe32ea7214f2f0911586e80f3f3c76 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:21:06 -0400 Subject: [PATCH 04/22] Add journey report utility functions --- packages/cli/src/journey-report.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/cli/src/journey-report.js b/packages/cli/src/journey-report.js index b7224ab..9529f6f 100644 --- a/packages/cli/src/journey-report.js +++ b/packages/cli/src/journey-report.js @@ -1 +1,18 @@ export const JOURNEY_REPORT_VERSION = 1; + +export function roundJourneyScore(value) { + return Math.round(Math.max(0, Math.min(100, value)) * 100) / 100; +} + +export function routeOf(value) { + const url = new URL(value); + return `${url.pathname}${url.search}`; +} + +export function artifactName(value) { + return String(value).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-|-$/g, "").slice(0, 120) || "checkpoint"; +} + +export function compactText(value, max = 1000) { + return String(value ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, max); +} From 1787c73f645e03a89678cde6351e6be0212d4950 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:21:26 -0400 Subject: [PATCH 05/22] Add semantic journey action runtime --- packages/cli/src/journey-runtime.js | 90 +++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 packages/cli/src/journey-runtime.js diff --git a/packages/cli/src/journey-runtime.js b/packages/cli/src/journey-runtime.js new file mode 100644 index 0000000..94118b6 --- /dev/null +++ b/packages/cli/src/journey-runtime.js @@ -0,0 +1,90 @@ +import { assertAllowedUrl } from "./security.js"; +import { routeOf } from "./journey-report.js"; + +export function locatorFor(page, target) { + const exact = target.exact === true; + if (target.role !== undefined) { + return page.getByRole(target.role, { + ...(target.name !== undefined ? { name: target.name } : {}), + exact + }); + } + if (target.label !== undefined) return page.getByLabel(target.label, { exact }); + if (target.placeholder !== undefined) return page.getByPlaceholder(target.placeholder, { exact }); + if (target.text !== undefined) return page.getByText(target.text, { exact }); + return page.getByTestId(target.testId); +} + +export async function uniqueLocator(page, target) { + const locator = locatorFor(page, target); + const count = await locator.count(); + if (count !== 1) throw new Error(`Expected exactly one matching element, found ${count}`); + return locator.first(); +} + +export async function assertJourneyLocation(page, candidateOrigin, allowPrivateNetwork) { + const current = await assertAllowedUrl(page.url(), { allowPrivateNetwork }); + if (current.origin !== candidateOrigin) throw new Error(`Journey left candidate origin and reached ${current.origin}`); + return current; +} + +export async function executeJourneyStep(page, step, context) { + const timeout = step.timeoutMs ?? context.timeoutMs; + switch (step.action) { + case "goto": { + const target = new URL(step.route, context.candidateBase); + if (target.origin !== context.candidateBase.origin) throw new Error("Cross-origin navigation is not allowed"); + await page.goto(target.toString(), { waitUntil: "domcontentloaded", timeout }); + break; + } + case "click": { + const locator = await uniqueLocator(page, step.target); + await locator.click({ timeout }); + break; + } + case "fill": { + const locator = await uniqueLocator(page, step.target); + await locator.fill(step.value, { timeout }); + break; + } + case "press": + await page.keyboard.press(step.key); + break; + case "viewport": + await page.setViewportSize({ width: step.width, height: step.height }); + break; + case "expect-visible": { + const locator = await uniqueLocator(page, step.target); + await locator.waitFor({ state: "visible", timeout }); + break; + } + case "expect-hidden": { + const locator = locatorFor(page, step.target); + const count = await locator.count(); + for (let index = 0; index < count; index += 1) { + if (await locator.nth(index).isVisible()) throw new Error(`Expected hidden state, but match ${index + 1} is visible`); + } + break; + } + case "expect-text": + await page.getByText(step.text, { exact: step.exact === true }).first().waitFor({ state: "visible", timeout }); + break; + case "expect-url": { + const current = await assertJourneyLocation(page, context.candidateBase.origin, context.allowPrivateNetwork); + const observed = routeOf(current); + if (observed !== step.route) throw new Error(`Expected route ${step.route}, observed ${observed}`); + break; + } + case "checkpoint": + await context.captureCheckpoint(step.name, context.stepIndex); + break; + case "audit": { + const result = await context.audit(); + const required = step.minScore ?? context.minimumAccessibilityScore; + if (result.score < required) throw new Error(`Accessibility score ${result.score.toFixed(2)} is below required ${required.toFixed(2)}`); + break; + } + } + await page.waitForTimeout(50); + return assertJourneyLocation(page, context.candidateBase.origin, context.allowPrivateNetwork); +} From 559dbcdb58d46f3f34ef829f29dd58911699ab6d Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:21:44 -0400 Subject: [PATCH 06/22] Add journey checkpoint artifact helpers --- packages/cli/src/journey-artifacts.js | 46 +++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 packages/cli/src/journey-artifacts.js diff --git a/packages/cli/src/journey-artifacts.js b/packages/cli/src/journey-artifacts.js new file mode 100644 index 0000000..84f03ec --- /dev/null +++ b/packages/cli/src/journey-artifacts.js @@ -0,0 +1,46 @@ +import { extractPageData } from "./evaluate-browser.js"; +import { auditAccessibility } from "./journey-a11y.js"; +import { artifactName, routeOf } from "./journey-report.js"; +import { assertJourneyLocation } from "./journey-runtime.js"; + +export function createJourneyArtifactWriter({ page, candidateOrigin, allowPrivateNetwork, timeoutMs, writeTracked }) { + return async function captureCheckpoint(name, stepIndex) { + const safeName = `${String(stepIndex + 1).padStart(3, "0")}-${artifactName(name)}`; + const screenshotPath = `checkpoints/${safeName}.png`; + const domPath = `checkpoints/${safeName}.json`; + const screenshot = await page.screenshot({ + type: "png", + animations: "disabled", + caret: "hide", + fullPage: false, + mask: page.locator("input,textarea,select"), + timeout: Math.min(timeoutMs, 10_000) + }); + const dom = await extractPageData(page); + const accessibility = await auditAccessibility(page); + const current = await assertJourneyLocation(page, candidateOrigin, allowPrivateNetwork); + await writeTracked(screenshotPath, screenshot); + await writeTracked(domPath, `${JSON.stringify(dom, null, 2)}\n`); + return { + name, + stepIndex, + route: routeOf(current), + viewport: page.viewportSize(), + screenshot: screenshotPath, + dom: domPath, + accessibility + }; + }; +} + +export function summarizeAccessibility(audits) { + const score = audits.length + ? audits.reduce((sum, audit) => sum + audit.score, 0) / audits.length + : 100; + return { + score: Math.round(score * 100) / 100, + findings: audits.flatMap((audit) => + audit.findings.map((finding) => ({ source: audit.source, ...finding })) + ).slice(0, 500) + }; +} From e42aad9a6d892af797d5e9ece069069bc611db07 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:22:59 -0400 Subject: [PATCH 07/22] Add declarative journey orchestration --- packages/cli/src/journey.js | 207 ++++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 packages/cli/src/journey.js diff --git a/packages/cli/src/journey.js b/packages/cli/src/journey.js new file mode 100644 index 0000000..81f881a --- /dev/null +++ b/packages/cli/src/journey.js @@ -0,0 +1,207 @@ +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { chromium } from "playwright"; +import { atomicWriteFile, createStagingDirectory, readJsonFile, sha256 } from "./fs.js"; +import { assertAllowedUrl, createRequestGuard } from "./security.js"; +import { createJourneyArtifactWriter, summarizeAccessibility } from "./journey-artifacts.js"; +import { auditAccessibility } from "./journey-a11y.js"; +import { compactText, roundJourneyScore, routeOf, stepDescription } from "./journey-report.js"; +import { assertJourneyLocation, executeJourneyStep } from "./journey-runtime.js"; +import { safeJourneyStep, validateJourney } from "./journey-schema.js"; +import { RECONSTRUCT_VERSION } from "./version.js"; + +const DEFAULTS = Object.freeze({ timeoutMs: 10_000, maxRequests: 1_000, minScore: 90 }); + +function boundedInteger(value, name, min, max) { + if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`${name} must be an integer between ${min} and ${max}`); + return value; +} + +function boundedScore(value, name) { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) throw new Error(`${name} must be between 0 and 100`); + return value; +} + +export async function runJourney(journeyFile, candidateBaseUrl, outDir, options = {}) { + const journeyPath = resolve(journeyFile); + const journeyBytes = await readFile(journeyPath); + const journey = validateJourney(await readJsonFile(journeyPath, { maxBytes: 1_000_000 })); + const allowPrivateNetwork = options.allowPrivateNetwork === true; + const timeoutMs = boundedInteger(options.timeoutMs ?? DEFAULTS.timeoutMs, "timeout", 1_000, 120_000); + const maxRequests = boundedInteger(options.maxRequests ?? DEFAULTS.maxRequests, "max requests", 1, 20_000); + const minimumScore = boundedScore(options.minScore ?? DEFAULTS.minScore, "minimum score"); + const candidateInput = await assertAllowedUrl(candidateBaseUrl, { allowPrivateNetwork }); + const candidateBase = new URL("/", candidateInput); + const transaction = await createStagingDirectory(outDir); + const tracked = []; + let browser; + let context; + + const writeTracked = async (relativePath, data) => { + const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8"); + await atomicWriteFile(join(transaction.staging, relativePath), buffer); + tracked.push({ path: relativePath, sha256: sha256(buffer), bytes: buffer.length }); + }; + + try { + const launchOptions = { headless: true, chromiumSandbox: true }; + const executablePath = options.executablePath ?? process.env.RECONSTRUCT_CHROMIUM_EXECUTABLE_PATH; + if (executablePath) launchOptions.executablePath = executablePath; + browser = await chromium.launch(launchOptions); + context = await browser.newContext({ + viewport: journey.viewport, + acceptDownloads: false, + serviceWorkers: "block", + bypassCSP: false, + ignoreHTTPSErrors: false, + javaScriptEnabled: true, + locale: "en-US", + timezoneId: "UTC", + userAgent: `Reconstruct-Journey/${RECONSTRUCT_VERSION} (+https://github.com/XioAISolutions/Reconstruct)` + }); + const guard = createRequestGuard({ allowPrivateNetwork, maxRequests }); + await context.route("**/*", (route) => guard.handle(route)); + await context.routeWebSocket("**/*", (webSocket) => webSocket.close({ code: 1008, reason: "Disabled by journey policy" })); + const page = await context.newPage(); + context.on("page", (popup) => { if (popup !== page) popup.close().catch(() => {}); }); + page.setDefaultTimeout(timeoutMs); + page.setDefaultNavigationTimeout(timeoutMs); + page.on("dialog", (dialog) => dialog.dismiss().catch(() => {})); + + await page.goto(new URL(journey.startRoute, candidateBase).toString(), { waitUntil: "domcontentloaded", timeout: timeoutMs }); + await assertJourneyLocation(page, candidateBase.origin, allowPrivateNetwork); + + const steps = []; + const checkpoints = []; + const audits = []; + const capture = createJourneyArtifactWriter({ + page, + candidateOrigin: candidateBase.origin, + allowPrivateNetwork, + timeoutMs, + writeTracked + }); + const captureCheckpoint = async (name, stepIndex) => { + const checkpoint = await capture(name, stepIndex); + checkpoints.push(checkpoint); + audits.push({ source: `checkpoint:${name}`, ...checkpoint.accessibility }); + return checkpoint; + }; + const audit = async () => { + const result = await auditAccessibility(page); + audits.push({ source: `step:${steps.length + 1}`, ...result }); + return result; + }; + + let stop = false; + for (let index = 0; index < journey.steps.length; index += 1) { + const step = journey.steps[index]; + if (stop) { + steps.push({ + index, + action: step.action, + step: safeJourneyStep(step), + status: "skipped", + durationMs: 0, + description: stepDescription(step), + error: "Skipped after an earlier failure" + }); + continue; + } + const started = Date.now(); + try { + const current = await executeJourneyStep(page, step, { + timeoutMs, + candidateBase, + allowPrivateNetwork, + minimumAccessibilityScore: journey.minimumAccessibilityScore, + captureCheckpoint, + audit, + stepIndex: index + }); + steps.push({ + index, + action: step.action, + step: safeJourneyStep(step), + status: "passed", + durationMs: Date.now() - started, + description: stepDescription(step), + route: routeOf(current) + }); + } catch (error) { + steps.push({ + index, + action: step.action, + step: safeJourneyStep(step), + status: "failed", + durationMs: Date.now() - started, + description: stepDescription(step), + error: compactText(error instanceof Error ? error.message : String(error)), + route: (() => { try { return routeOf(page.url()); } catch { return null; } })() + }); + await captureCheckpoint(`failure-${index + 1}`, index).catch(() => {}); + if (!journey.continueOnFailure) stop = true; + } + } + + const finalAudit = await auditAccessibility(page); + audits.push({ source: "final", ...finalAudit }); + const accessibility = summarizeAccessibility(audits); + const passedSteps = steps.filter((step) => step.status === "passed").length; + const failedSteps = steps.filter((step) => step.status === "failed").length; + const skippedSteps = steps.filter((step) => step.status === "skipped").length; + const stepScore = roundJourneyScore((passedSteps / Math.max(1, steps.length)) * 100); + const score = roundJourneyScore(stepScore * 0.8 + accessibility.score * 0.2); + const guardState = guard.snapshot(); + const result = { + version: 1, + toolVersion: RECONSTRUCT_VERSION, + generatedAt: new Date().toISOString(), + name: journey.name, + candidateBaseUrl: candidateBase.toString(), + minimumScore, + minimumAccessibilityScore: journey.minimumAccessibilityScore, + score, + stepScore, + accessibility, + passed: failedSteps === 0 && skippedSteps === 0 && score >= minimumScore && accessibility.score >= journey.minimumAccessibilityScore, + stats: { + totalSteps: steps.length, + passedSteps, + failedSteps, + skippedSteps, + checkpoints: checkpoints.length, + requestCount: guardState.requestCount, + blockedRequestCount: guardState.blockedCount, + truncated: guardState.truncated, + observedHosts: guardState.hosts + }, + steps, + checkpoints + }; + + await writeTracked("journey.json", `${JSON.stringify(result, null, 2)}\n`); + await writeTracked("accessibility.json", `${JSON.stringify({ audits }, null, 2)}\n`); + const manifest = { + version: 1, + algorithm: "sha256", + createdAt: result.generatedAt, + scenarioSha256: sha256(journeyBytes), + candidateBaseUrl: result.candidateBaseUrl, + entries: [...tracked].sort((a, b) => a.path.localeCompare(b.path)) + }; + await atomicWriteFile(join(transaction.staging, "JOURNEY_MANIFEST.json"), `${JSON.stringify(manifest, null, 2)}\n`); + + await context.close(); + context = null; + await browser.close(); + browser = null; + await transaction.commit(); + return result; + } catch (error) { + if (context) await context.close().catch(() => {}); + if (browser) await browser.close().catch(() => {}); + await transaction.rollback(); + throw error; + } +} From 4bba1a1970275ec4b5bec413aac1bf1f6428a3d2 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:23:47 -0400 Subject: [PATCH 08/22] Block journey form submissions by default --- packages/cli/src/journey-runtime.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/cli/src/journey-runtime.js b/packages/cli/src/journey-runtime.js index 94118b6..a4ec216 100644 --- a/packages/cli/src/journey-runtime.js +++ b/packages/cli/src/journey-runtime.js @@ -28,6 +28,24 @@ export async function assertJourneyLocation(page, candidateOrigin, allowPrivateN return current; } +async function assertClickAllowed(locator, allowWriteActions) { + if (allowWriteActions) return; + const submitsForm = await locator.evaluate((element) => { + const form = element.closest("form"); + if (!form) return false; + const tag = element.tagName.toLowerCase(); + const type = (element.getAttribute("type") || (tag === "button" ? "submit" : "")).toLowerCase(); + return (tag === "button" && type === "submit") || (tag === "input" && ["submit", "image"].includes(type)); + }); + if (submitsForm) throw new Error("Form submission requires the allow-write-actions option"); +} + +async function assertKeyAllowed(page, key, allowWriteActions) { + if (allowWriteActions || key !== "Enter") return; + const insideForm = await page.evaluate(() => Boolean(document.activeElement?.closest?.("form"))); + if (insideForm) throw new Error("Pressing Enter inside a form requires the allow-write-actions option"); +} + export async function executeJourneyStep(page, step, context) { const timeout = step.timeoutMs ?? context.timeoutMs; switch (step.action) { @@ -39,6 +57,7 @@ export async function executeJourneyStep(page, step, context) { } case "click": { const locator = await uniqueLocator(page, step.target); + await assertClickAllowed(locator, context.allowWriteActions); await locator.click({ timeout }); break; } @@ -48,6 +67,7 @@ export async function executeJourneyStep(page, step, context) { break; } case "press": + await assertKeyAllowed(page, step.key, context.allowWriteActions); await page.keyboard.press(step.key); break; case "viewport": From 2a6a294acdae5dab2fb4fb6693769cb80cc033e0 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:25:03 -0400 Subject: [PATCH 09/22] Expose declarative journey replay in the CLI --- packages/cli/src/index.js | 47 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/index.js b/packages/cli/src/index.js index 95b2339..0c4067e 100755 --- a/packages/cli/src/index.js +++ b/packages/cli/src/index.js @@ -8,6 +8,8 @@ import { evaluateCandidate } from "./evaluate.js"; import { EXPORT_TARGETS, exportAppSpec } from "./export.js"; import { readJsonFile, UserInputError } from "./fs.js"; import { verifyAppSpecProject } from "./integrity.js"; +import { runJourney } from "./journey.js"; +import { JourneyValidationError } from "./journey-schema.js"; import { RECONSTRUCT_VERSION } from "./version.js"; process.umask(0o077); @@ -196,11 +198,52 @@ program.command("evaluate") if (!result.passed) process.exitCode = 3; }); +program.command("journey") + .alias("replay") + .description("Replay a declarative responsive and interaction journey against a candidate application") + .argument("", "journey JSON file") + .requiredOption("-c, --candidate ", "candidate application base URL") + .option("-o, --out ", "new journey output directory") + .option("--min-score ", "minimum passing score", numberOption(0, 100), 90) + .option("--timeout ", "timeout per journey step", integerOption(1_000, 120_000), 10_000) + .option("--max-requests ", "maximum browser requests across the journey", integerOption(1, 20_000), 1_000) + .option("--allow-private-network", "allow loopback and private-network candidate URLs", false) + .option("--json", "emit machine-readable output", false) + .action(async (file, options) => { + const filePath = resolve(file); + const outDir = resolve(options.out || join(dirname(filePath), "journey-output")); + const result = await runJourney(filePath, options.candidate, outDir, { + minScore: options.minScore, + timeoutMs: options.timeout, + maxRequests: options.maxRequests, + allowPrivateNetwork: options.allowPrivateNetwork + }); + printResult({ + ok: result.passed, + command: "journey", + name: result.name, + candidate: result.candidateBaseUrl, + score: result.score, + stepScore: result.stepScore, + accessibilityScore: result.accessibility.score, + passed: result.passed, + outDir, + resultFile: join(outDir, "journey.json"), + lines: [ + `${result.passed ? "PASS" : "CORRECTION REQUIRED"}: ${result.score.toFixed(2)} / 100`, + `Steps: ${result.stats.passedSteps}/${result.stats.totalSteps} passed`, + `Artifacts: ${outDir}` + ] + }, options.json); + if (!result.passed) process.exitCode = 3; + }); + program.parseAsync(process.argv).catch((error) => { + const validationError = error instanceof AppSpecValidationError || error instanceof JourneyValidationError; const payload = { ok: false, error: error instanceof Error ? error.message : String(error), - ...(error instanceof AppSpecValidationError ? { issues: error.issues } : {}) + ...(validationError ? { issues: error.issues } : {}) }; const jsonRequested = process.argv.includes("--json"); if (jsonRequested) console.error(JSON.stringify(payload)); @@ -208,5 +251,5 @@ program.parseAsync(process.argv).catch((error) => { console.error(`reconstruct: ${payload.error}`); if (payload.issues) payload.issues.forEach((issue) => console.error(` - ${issue}`)); } - process.exitCode = error instanceof UserInputError || error instanceof AppSpecValidationError || error instanceof InvalidArgumentError ? 2 : 1; + process.exitCode = error instanceof UserInputError || validationError || error instanceof InvalidArgumentError ? 2 : 1; }); From e97db8700e2bd50dcb1e37b5ab15f8ffaab761d4 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:25:34 -0400 Subject: [PATCH 10/22] Verify journey modules and release CLI 0.5.0 --- packages/cli/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 8711db5..b9606c2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,12 +1,12 @@ { "name": "@reconstruct/cli", - "version": "0.4.0", + "version": "0.5.0", "type": "module", "bin": {"reconstruct": "./src/index.js"}, "scripts": { - "build": "node --check src/index.js && node --check src/capture.js && node --check src/capture-page.js && node --check src/capture-utils.js && node --check src/crawl.js && node --check src/evaluate.js && node --check src/evaluate-browser.js && node --check src/evaluate-utils.js && node --check src/export.js && node --check src/security.js && node --check src/fs.js && node --check src/integrity.js && node --check src/version.js", + "build": "node --check src/index.js && node --check src/capture.js && node --check src/capture-page.js && node --check src/capture-utils.js && node --check src/crawl.js && node --check src/evaluate.js && node --check src/evaluate-browser.js && node --check src/evaluate-utils.js && node --check src/export.js && node --check src/security.js && node --check src/fs.js && node --check src/integrity.js && node --check src/journey.js && node --check src/journey-a11y.js && node --check src/journey-artifacts.js && node --check src/journey-report.js && node --check src/journey-runtime.js && node --check src/journey-schema.js && node --check src/version.js", "test": "node --test test/*.test.js", - "typecheck": "node --check src/index.js && node --check src/capture.js && node --check src/capture-page.js && node --check src/capture-utils.js && node --check src/crawl.js && node --check src/evaluate.js && node --check src/evaluate-browser.js && node --check src/evaluate-utils.js && node --check src/export.js && node --check src/security.js && node --check src/fs.js && node --check src/integrity.js && node --check src/version.js" + "typecheck": "node --check src/index.js && node --check src/capture.js && node --check src/capture-page.js && node --check src/capture-utils.js && node --check src/crawl.js && node --check src/evaluate.js && node --check src/evaluate-browser.js && node --check src/evaluate-utils.js && node --check src/export.js && node --check src/security.js && node --check src/fs.js && node --check src/integrity.js && node --check src/journey.js && node --check src/journey-a11y.js && node --check src/journey-artifacts.js && node --check src/journey-report.js && node --check src/journey-runtime.js && node --check src/journey-schema.js && node --check src/version.js" }, "dependencies": {"@reconstruct/appspec": "0.3.0", "commander": "14.0.1", "playwright": "1.61.1"} } From b781dac91254e917421fa249b83aca74cae5f4e4 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:25:55 -0400 Subject: [PATCH 11/22] Add journey schema and accessibility unit tests --- packages/cli/test/journey.test.js | 54 +++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 packages/cli/test/journey.test.js diff --git a/packages/cli/test/journey.test.js b/packages/cli/test/journey.test.js new file mode 100644 index 0000000..a2b5d13 --- /dev/null +++ b/packages/cli/test/journey.test.js @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { scoreAccessibilityFindings } from "../src/journey-a11y.js"; +import { JourneyValidationError, safeJourneyStep, validateJourney } from "../src/journey-schema.js"; + +test("validates responsive semantic journeys", () => { + const journey = validateJourney({ + version: 1, + name: "Mobile navigation", + startRoute: "/", + viewport: { width: 390, height: 844 }, + minimumAccessibilityScore: 75, + steps: [ + { action: "expect-visible", target: { role: "button", name: "Menu" } }, + { action: "click", target: { role: "button", name: "Menu" } }, + { action: "checkpoint", name: "menu-open" }, + { action: "viewport", width: 1440, height: 1000 } + ] + }); + assert.deepEqual(journey.viewport, { width: 390, height: 844 }); + assert.equal(journey.continueOnFailure, false); +}); + +test("rejects arbitrary selectors and unsafe routes", () => { + assert.throws(() => validateJourney({ + version: 1, + name: "Invalid", + startRoute: "https://outside.example/", + steps: [{ action: "click", target: { selector: "#danger" } }] + }), (error) => { + assert.ok(error instanceof JourneyValidationError); + assert.ok(error.issues.some((issue) => issue.includes("same-origin route"))); + assert.ok(error.issues.some((issue) => issue.includes("unknown property"))); + return true; + }); +}); + +test("redacts filled values in stored results", () => { + const step = safeJourneyStep({ + action: "fill", + target: { label: "Email" }, + value: "private@example.com" + }); + assert.equal(step.value, "[REDACTED]"); + assert.equal(step.valueLength, 19); +}); + +test("scores accessibility findings by severity and count", () => { + assert.equal(scoreAccessibilityFindings([]), 100); + assert.equal(scoreAccessibilityFindings([ + { severity: "serious", count: 2 }, + { severity: "minor", count: 1 } + ]), 79); +}); From f75afc0684f0585cfc4c6e826c0e230d95eeb381 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:26:40 -0400 Subject: [PATCH 12/22] Add responsive journey browser regression test --- packages/cli/integration/journey.test.js | 116 +++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 packages/cli/integration/journey.test.js diff --git a/packages/cli/integration/journey.test.js b/packages/cli/integration/journey.test.js new file mode 100644 index 0000000..66616ba --- /dev/null +++ b/packages/cli/integration/journey.test.js @@ -0,0 +1,116 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runJourney } from "../src/journey.js"; + +function pageHtml(path, broken) { + const pricing = path === "/pricing"; + const script = broken ? "" : ` + const button = document.getElementById("menu"); + const nav = document.getElementById("primary-nav"); + button.addEventListener("click", () => { + const open = button.getAttribute("aria-expanded") !== "true"; + button.setAttribute("aria-expanded", String(open)); + nav.dataset.open = String(open); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + button.setAttribute("aria-expanded", "false"); + nav.dataset.open = "false"; + } + });`; + return ` + ${pricing ? "Pricing" : "Home"} + +
+
+

${pricing ? "Plans" : "Reconstruct"}

+

${pricing ? "Choose a plan." : "Replay observable behavior."}

+
+
`; +} + +test("replays responsive interaction journeys and reports broken behavior", async () => { + let broken = false; + const server = createServer((request, response) => { + const url = new URL(request.url, "http://localhost"); + response.setHeader("content-type", "text/html; charset=utf-8"); + response.end(pageHtml(url.pathname, broken)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const baseUrl = `http://127.0.0.1:${address.port}`; + const root = await mkdtemp(join(tmpdir(), "reconstruct-journey-")); + const scenarioPath = join(root, "journey.json"); + const scenario = { + version: 1, + name: "Responsive navigation", + startRoute: "/", + viewport: { width: 390, height: 844 }, + minimumAccessibilityScore: 80, + steps: [ + { action: "expect-visible", target: { role: "button", name: "Menu" } }, + { action: "expect-hidden", target: { role: "link", name: "Pricing" } }, + { action: "click", target: { role: "button", name: "Menu" } }, + { action: "expect-visible", target: { role: "link", name: "Pricing" } }, + { action: "fill", target: { label: "Email" }, value: "private@example.com" }, + { action: "checkpoint", name: "mobile-menu-open" }, + { action: "press", key: "Escape" }, + { action: "expect-hidden", target: { role: "link", name: "Pricing" } }, + { action: "viewport", width: 1280, height: 800 }, + { action: "expect-visible", target: { role: "link", name: "Pricing" } }, + { action: "click", target: { role: "link", name: "Pricing" } }, + { action: "expect-url", route: "/pricing" }, + { action: "expect-text", text: "Plans", exact: true }, + { action: "audit", minScore: 80 }, + { action: "checkpoint", name: "pricing-desktop" } + ] + }; + await writeFile(scenarioPath, `${JSON.stringify(scenario, null, 2)}\n`); + + try { + const passing = await runJourney(scenarioPath, baseUrl, join(root, "passing"), { + allowPrivateNetwork: true, + minScore: 90, + maxRequests: 100, + timeoutMs: 10_000, + executablePath: process.env.RECONSTRUCT_CHROMIUM_EXECUTABLE_PATH + }); + assert.equal(passing.passed, true); + assert.equal(passing.stats.failedSteps, 0); + assert.equal(passing.checkpoints.length, 2); + assert.ok(passing.accessibility.score >= 80); + const stored = JSON.parse(await readFile(join(root, "passing", "journey.json"), "utf8")); + const fillStep = stored.steps.find((step) => step.action === "fill"); + assert.equal(fillStep.step.value, "[REDACTED]"); + assert.equal(fillStep.step.valueLength, 19); + const manifest = JSON.parse(await readFile(join(root, "passing", "JOURNEY_MANIFEST.json"), "utf8")); + assert.equal(typeof manifest.scenarioSha256, "string"); + assert.ok(manifest.entries.some((entry) => entry.path.endsWith(".png"))); + + broken = true; + const failing = await runJourney(scenarioPath, baseUrl, join(root, "failing"), { + allowPrivateNetwork: true, + minScore: 90, + maxRequests: 100, + timeoutMs: 2_000, + executablePath: process.env.RECONSTRUCT_CHROMIUM_EXECUTABLE_PATH + }); + assert.equal(failing.passed, false); + assert.ok(failing.stats.failedSteps >= 1); + assert.ok(failing.steps.some((step) => step.status === "skipped")); + assert.ok(failing.checkpoints.some((checkpoint) => checkpoint.name.startsWith("failure-"))); + } finally { + await new Promise((resolve) => server.close(resolve)); + await rm(root, { recursive: true, force: true }); + } +}); From 4946833fd1fca97374ef92da2fe40923b13458b9 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:27:09 -0400 Subject: [PATCH 13/22] Add human-readable journey reports --- packages/cli/src/journey-report.js | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/cli/src/journey-report.js b/packages/cli/src/journey-report.js index 9529f6f..e31b8e8 100644 --- a/packages/cli/src/journey-report.js +++ b/packages/cli/src/journey-report.js @@ -16,3 +16,45 @@ export function artifactName(value) { export function compactText(value, max = 1000) { return String(value ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, max); } + +export function stepDescription(step) { + switch (step.action) { + case "goto": return `Navigate to ${step.route}`; + case "click": return "Click semantic target"; + case "fill": return `Fill semantic target with ${String(step.value ?? "").length} redacted characters`; + case "press": return `Press ${step.key}`; + case "viewport": return `Set viewport to ${step.width}x${step.height}`; + case "expect-visible": return "Expect semantic target to be visible"; + case "expect-hidden": return "Expect semantic target to be hidden"; + case "expect-text": return `Expect text ${step.text}`; + case "expect-url": return `Expect route ${step.route}`; + case "checkpoint": return `Capture checkpoint ${step.name}`; + case "audit": return "Run accessibility audit"; + default: return compactText(step.action); + } +} + +export function renderJourneyReport(result) { + const rows = result.steps.map((step) => + `| ${step.index + 1} | ${compactText(step.action)} | ${compactText(step.status)} | ${step.durationMs} | ${compactText(step.error || "")} |` + ).join("\n"); + const checkpoints = result.checkpoints.length + ? result.checkpoints.map((checkpoint) => + `- ${compactText(checkpoint.name)}: ${compactText(checkpoint.route)} (${checkpoint.viewport.width}x${checkpoint.viewport.height}), accessibility ${checkpoint.accessibility.score.toFixed(2)}, ${checkpoint.screenshot}` + ).join("\n") + : "No checkpoints were captured."; + return `# Reconstruct journey report\n\n- Journey: ${compactText(result.name)}\n- Result: ${result.passed ? "PASS" : "CORRECTION REQUIRED"}\n- Overall score: ${result.score.toFixed(2)} / 100\n- Step score: ${result.stepScore.toFixed(2)}\n- Accessibility score: ${result.accessibility.score.toFixed(2)}\n\n## Steps\n\n| # | Action | Status | ms | Error |\n|---:|---|---|---:|---|\n${rows}\n\n## Checkpoints\n\n${checkpoints}\n`; +} + +export function renderJourneyCorrections(result) { + const failures = result.steps.filter((step) => step.status === "failed"); + const failureLines = failures.length + ? failures.map((step) => `- Step ${step.index + 1} (${compactText(step.action)}): ${compactText(step.error)}`).join("\n") + : "- No failed steps."; + const accessibilityLines = result.accessibility.findings.length + ? result.accessibility.findings.slice(0, 40).map((finding) => + `- ${finding.severity.toUpperCase()} ${compactText(finding.category)}: ${compactText(finding.message)} (${finding.count})` + ).join("\n") + : "- No accessibility findings."; + return `# Journey correction brief\n\nCorrect failed behavior without weakening passing assertions. Prefer semantic roles and accessible names.\n\n## Failed steps\n\n${failureLines}\n\n## Accessibility findings\n\n${accessibilityLines}\n`; +} From 30304584bcb09f2f51152b3bf41fcf1a076f4d1c Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:27:46 -0400 Subject: [PATCH 14/22] Write journey reports and correction briefs --- packages/cli/src/journey.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/journey.js b/packages/cli/src/journey.js index 81f881a..8011823 100644 --- a/packages/cli/src/journey.js +++ b/packages/cli/src/journey.js @@ -5,7 +5,7 @@ import { atomicWriteFile, createStagingDirectory, readJsonFile, sha256 } from ". import { assertAllowedUrl, createRequestGuard } from "./security.js"; import { createJourneyArtifactWriter, summarizeAccessibility } from "./journey-artifacts.js"; import { auditAccessibility } from "./journey-a11y.js"; -import { compactText, roundJourneyScore, routeOf, stepDescription } from "./journey-report.js"; +import { compactText, renderJourneyCorrections, renderJourneyReport, roundJourneyScore, routeOf, stepDescription } from "./journey-report.js"; import { assertJourneyLocation, executeJourneyStep } from "./journey-runtime.js"; import { safeJourneyStep, validateJourney } from "./journey-schema.js"; import { RECONSTRUCT_VERSION } from "./version.js"; @@ -182,6 +182,8 @@ export async function runJourney(journeyFile, candidateBaseUrl, outDir, options await writeTracked("journey.json", `${JSON.stringify(result, null, 2)}\n`); await writeTracked("accessibility.json", `${JSON.stringify({ audits }, null, 2)}\n`); + await writeTracked("JOURNEY_REPORT.md", renderJourneyReport(result)); + await writeTracked("JOURNEY_CORRECTIONS.md", renderJourneyCorrections(result)); const manifest = { version: 1, algorithm: "sha256", From 0ab56354557ba12b8f34ce69296975507bac8193 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:29:10 -0400 Subject: [PATCH 15/22] Release Reconstruct 0.5.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4baa156..dbda0c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reconstruct", - "version": "0.4.0", + "version": "0.5.0", "private": true, "description": "Open-source application reconstruction engine for AI coding agents", "license": "Apache-2.0", From 0f28b99bb47eea36970046ffd14ffe2318ee8eda Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:29:22 -0400 Subject: [PATCH 16/22] Set CLI version to 0.5.0 --- packages/cli/src/version.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/version.js b/packages/cli/src/version.js index b671275..77f28f5 100644 --- a/packages/cli/src/version.js +++ b/packages/cli/src/version.js @@ -1 +1 @@ -export const RECONSTRUCT_VERSION = "0.4.0"; +export const RECONSTRUCT_VERSION = "0.5.0"; From 8f821574a133a8a48080c279a32ebf191643fc4a Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:29:40 -0400 Subject: [PATCH 17/22] Update lockfile for Reconstruct 0.5.0 --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bfe7f06..cb70235 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "reconstruct", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "reconstruct", - "version": "0.4.0", + "version": "0.5.0", "license": "Apache-2.0", "workspaces": [ "packages/*" @@ -80,7 +80,7 @@ }, "packages/cli": { "name": "@reconstruct/cli", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "@reconstruct/appspec": "0.3.0", "commander": "14.0.1", From 2a07accfd2b1d8280680f06f874acbc0b45f759a Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:30:06 -0400 Subject: [PATCH 18/22] Document responsive and interaction journeys --- docs/JOURNEYS.md | 133 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/JOURNEYS.md diff --git a/docs/JOURNEYS.md b/docs/JOURNEYS.md new file mode 100644 index 0000000..428d036 --- /dev/null +++ b/docs/JOURNEYS.md @@ -0,0 +1,133 @@ +# Declarative journeys + +Reconstruct 0.5 can replay responsive and interaction behavior against a running candidate application. + +Journeys are JSON documents. They use semantic locators and a small allowlist of actions instead of CSS selectors or arbitrary browser scripts. + +## Run a journey + +```bash +npm run reconstruct -- journey ./journeys/mobile-navigation.json \ + --candidate http://127.0.0.1:3000 \ + --allow-private-network \ + --out ./journey-results/mobile-navigation +``` + +`replay` is an alias for `journey`. + +The command exits with code `0` when the journey passes, `3` when execution completes but the required score is missed, `2` for invalid input, and `1` for an operational failure. + +## Example + +```json +{ + "version": 1, + "name": "Responsive navigation", + "startRoute": "/", + "viewport": { "width": 390, "height": 844 }, + "minimumAccessibilityScore": 80, + "steps": [ + { + "action": "expect-visible", + "target": { "role": "button", "name": "Menu" } + }, + { + "action": "click", + "target": { "role": "button", "name": "Menu" } + }, + { + "action": "expect-visible", + "target": { "role": "link", "name": "Pricing" } + }, + { "action": "checkpoint", "name": "mobile-menu-open" }, + { "action": "press", "key": "Escape" }, + { + "action": "expect-hidden", + "target": { "role": "link", "name": "Pricing" } + }, + { "action": "viewport", "width": 1280, "height": 800 }, + { + "action": "click", + "target": { "role": "link", "name": "Pricing" } + }, + { "action": "expect-url", "route": "/pricing" }, + { "action": "audit", "minScore": 80 }, + { "action": "checkpoint", "name": "pricing-desktop" } + ] +} +``` + +## Supported actions + +- `goto`: navigate to a same-origin route +- `click`: click one semantic target +- `fill`: fill one semantically named field +- `press`: press an allowlisted keyboard key +- `viewport`: change the browser viewport +- `expect-visible`: require one semantic target to be visible +- `expect-hidden`: require matching semantic targets to be hidden or absent +- `expect-text`: require visible text +- `expect-url`: require the exact route and query +- `checkpoint`: capture a screenshot, DOM evidence, viewport, route, and accessibility result +- `audit`: require a minimum accessibility score at the current state + +## Semantic targets + +A target must use exactly one strategy: + +```json +{ "role": "button", "name": "Menu" } +{ "label": "Email" } +{ "placeholder": "Search" } +{ "text": "Pricing" } +{ "testId": "account-menu" } +``` + +`exact` may be added when exact accessible-name or text matching is required. CSS selectors and XPath are not accepted. + +## Accessibility signal + +The built-in bounded audit checks observable issues including: + +- missing document title or language +- missing or duplicate main landmarks +- missing or duplicate level-one headings +- skipped heading levels +- images without `alt` +- form controls and interactive elements without accessible names +- positive `tabindex` +- duplicate IDs +- autofocus + +This is a fast deterministic signal, not a complete accessibility certification. + +## Output + +```text +journey-output/ +├── journey.json +├── accessibility.json +├── JOURNEY_REPORT.md +├── JOURNEY_CORRECTIONS.md +├── JOURNEY_MANIFEST.json +└── checkpoints/ + ├── 004-mobile-menu-open.json + ├── 004-mobile-menu-open.png + └── ... +``` + +The overall score is 80% passed journey steps and 20% accessibility. By default the overall score must reach 90 and the journey accessibility score must reach 80. + +Filled values are replaced with `[REDACTED]` in results. Checkpoint screenshots mask inputs, textareas, and selects. + +## Security boundaries + +- candidate and browser requests use the existing network guard +- private and loopback addresses require `--allow-private-network` +- every completed action is checked for same-origin location +- popups and dialogs are closed +- WebSockets, downloads, and service workers remain restricted +- form-submit controls and Enter inside forms are blocked by default +- journey documents cannot execute arbitrary JavaScript, shell commands, CSS selectors, XPath, uploads, or page-provided instructions + +A hosted deployment should still isolate browser workers and enforce network egress outside the application process. From 81bb04be145226dc9762bf3fcd210fe89512a134 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:31:00 -0400 Subject: [PATCH 19/22] Document Reconstruct 0.5 behavioral verification --- README.md | 76 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 54 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index db042cc..4bcc6dc 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,26 @@ # Reconstruct -**Turn a web application into a build-ready specification, evaluate the implementation, and generate the corrections.** +**Turn a web application into a build-ready specification, evaluate the implementation, replay its behavior, and generate the corrections.** -Reconstruct captures observable product evidence, converts it into a provider-neutral AppSpec, exports focused implementation packages for coding agents, and scores a candidate application against the verified source evidence. +Reconstruct captures observable product evidence, converts it into a provider-neutral AppSpec, exports focused implementation packages for coding agents, scores a candidate against verified source evidence, and replays declarative responsive journeys. -> Observe → Map → Specify → Build → Compare → Correct +> Observe → Map → Specify → Build → Compare → Replay → Correct -## Reconstruct 0.4 +## Reconstruct 0.5 -The evaluation loop is now complete: +The behavioral verification layer is now available: -- render every recorded candidate route -- compare candidate screenshots against verified reference PNGs -- generate route-level visual heatmaps -- score observable titles, headings, links, buttons, forms, landmarks, and design tokens -- verify recorded navigation targets and trigger text -- produce machine-readable and human-readable evaluation reports -- generate a prioritized correction plan and coding-agent fix prompt -- return exit code `3` when a completed evaluation fails its required score +- replay click, fill, keyboard, route, text, visibility, and viewport steps +- locate controls by semantic role, accessible name, label, text, placeholder, or test ID +- verify mobile menus, responsive states, keyboard dismissal, and route transitions +- capture masked checkpoint screenshots and structured DOM evidence +- run bounded accessibility audits at checkpoints or explicit audit steps +- redact filled values from stored results +- block form submission and cross-origin movement by default +- generate machine-readable results, human reports, and correction briefs +- return exit code `3` when a completed journey misses its required score + +The 0.4 visual and structural evaluation loop remains fully supported. ## Quick start @@ -62,7 +65,34 @@ npm run reconstruct -- evaluate ./example-app/appspec.json \ --out ./example-app/evaluation ``` -`compare` is an alias for `evaluate`. +Replay a responsive interaction journey: + +```bash +npm run reconstruct -- journey ./journeys/mobile-navigation.json \ + --candidate http://127.0.0.1:3000 \ + --allow-private-network \ + --out ./journey-results/mobile-navigation +``` + +`compare` aliases `evaluate`; `replay` aliases `journey`. + +## Journey output + +```text +journey-output/ +├── journey.json +├── accessibility.json +├── JOURNEY_REPORT.md +├── JOURNEY_CORRECTIONS.md +├── JOURNEY_MANIFEST.json +└── checkpoints/ + ├── *.json + └── *.png +``` + +Journey scoring is 80% passed behavioral steps and 20% accessibility. The default overall requirement is 90; the default accessibility requirement is 80. + +See [docs/JOURNEYS.md](docs/JOURNEYS.md) for the JSON contract, semantic targets, actions, scoring, outputs, and security boundaries. ## Evaluation output @@ -79,7 +109,7 @@ evaluation/ └── diffs/ ``` -The route score is weighted from visual similarity, observable interface structure, and recorded navigation behaviour. The default minimum passing score is 85. +The route score combines visual similarity, observable interface structure, and recorded navigation behavior. The default minimum passing score is 85. See [docs/EVALUATION.md](docs/EVALUATION.md) for scoring, output, exit codes, and boundaries. @@ -99,17 +129,19 @@ Every evidence artifact is content-addressed with a SHA-256 digest and byte coun ## Security-first design -- public-network capture and evaluation by default +- public-network capture, evaluation, and journeys by default - private, loopback, link-local, metadata, multicast, reserved, and special-use destinations denied unless explicitly enabled -- every browser request revalidated and bounded +- every browser request and completed journey action revalidated and bounded +- semantic journey locators only; arbitrary scripts, CSS selectors, and XPath are rejected +- filled values redacted and form fields masked in screenshots +- form submission and Enter inside forms blocked by default - Chromium sandbox, TLS verification, and CSP enforcement remain enabled - downloads, service workers, popups, dialogs, WebSockets, and media resources restricted - output written through restrictive staging directories and atomic commits - captured page content treated as untrusted evidence, never trusted agent instructions -- evaluation verifies source evidence before rendering a candidate - generated manifests avoid local absolute paths -Application checks reduce risk but do not replace worker-level network isolation in a hosted deployment. Read [SECURITY.md](SECURITY.md), [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md), [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md), and [docs/EVALUATION.md](docs/EVALUATION.md). +Application checks reduce risk but do not replace worker-level network isolation in a hosted deployment. Read [SECURITY.md](SECURITY.md), [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md), [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md), [docs/EVALUATION.md](docs/EVALUATION.md), and [docs/JOURNEYS.md](docs/JOURNEYS.md). ## Development @@ -125,14 +157,14 @@ Repository layout: ```text packages/ ├── appspec/ # schema, validation, serialization -└── cli/ # capture, crawl, export, evaluation, correction +└── cli/ # capture, crawl, export, evaluation, journeys, correction ``` ## Scope -Reconstruct documents and compares observable interfaces and behaviour. It does not recover private source code, hidden system prompts, proprietary backend logic, credentials, or authorization rules that are not visible in supplied evidence. +Reconstruct documents and compares observable interfaces and behavior. It does not recover private source code, hidden system prompts, proprietary backend logic, credentials, or authorization rules that are not visible in supplied evidence. -A high evaluation score does not prove backend correctness, accessibility compliance, data integrity, or production security. +A high evaluation or journey score does not prove backend correctness, complete accessibility compliance, data integrity, or production security. Use Reconstruct only for public pages and applications you own or are authorized to evaluate. Respect applicable terms, privacy, intellectual property, and access controls. From 46da08ff0614a0d5f00d480c12e0cbf167552534 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:31:22 -0400 Subject: [PATCH 20/22] Document Reconstruct 0.5 journey release --- CHANGELOG.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9fe5c6..1ccd40c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,32 @@ All notable changes to this project are documented here. -## 0.4.0 - Unreleased +## 0.5.0 - Unreleased + +### Added + +- `journey` command with `replay` alias +- declarative responsive and interaction scenarios +- semantic locators by role, accessible name, label, placeholder, text, or test ID +- allowlisted click, fill, keyboard, viewport, route, text, visibility, checkpoint, and audit actions +- responsive viewport transitions and keyboard-state verification +- bounded accessibility scoring at checkpoints and explicit audit steps +- masked checkpoint screenshots and structured DOM evidence +- `journey.json`, `accessibility.json`, `JOURNEY_REPORT.md`, and `JOURNEY_CORRECTIONS.md` +- content-addressed journey manifests +- journey unit tests and real Chromium passing/failing regression coverage + +### Security + +- arbitrary JavaScript, CSS selectors, XPath, uploads, and page-provided instructions are not supported by journey files +- candidate location is revalidated after every completed action +- cross-origin movement is rejected +- form-submit controls and Enter inside forms are blocked by default +- filled values are redacted in stored results +- form fields are masked in checkpoint screenshots +- browser request ceilings, Chromium sandboxing, TLS verification, CSP enforcement, popup blocking, dialog dismissal, download blocking, service-worker blocking, and WebSocket restrictions remain active + +## 0.4.0 - 2026-07-02 ### Added From 3fa155c7f83b25d4facb8dcab2f039a7c8ab29e3 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:35:56 -0400 Subject: [PATCH 21/22] Expose sanitized journey result on integration failure --- packages/cli/integration/journey.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/integration/journey.test.js b/packages/cli/integration/journey.test.js index 66616ba..9285939 100644 --- a/packages/cli/integration/journey.test.js +++ b/packages/cli/integration/journey.test.js @@ -85,7 +85,7 @@ test("replays responsive interaction journeys and reports broken behavior", asyn timeoutMs: 10_000, executablePath: process.env.RECONSTRUCT_CHROMIUM_EXECUTABLE_PATH }); - assert.equal(passing.passed, true); + assert.equal(passing.passed, true, JSON.stringify(passing, null, 2)); assert.equal(passing.stats.failedSteps, 0); assert.equal(passing.checkpoints.length, 2); assert.ok(passing.accessibility.score >= 80); From f247aa457210940b0d70d63eade866b89ed243b2 Mon Sep 17 00:00:00 2001 From: PenguineWalkOS Date: Thu, 2 Jul 2026 19:37:36 -0400 Subject: [PATCH 22/22] Pass checkpoint masks as a Playwright locator array --- packages/cli/src/journey-artifacts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/journey-artifacts.js b/packages/cli/src/journey-artifacts.js index 84f03ec..85a56d2 100644 --- a/packages/cli/src/journey-artifacts.js +++ b/packages/cli/src/journey-artifacts.js @@ -13,7 +13,7 @@ export function createJourneyArtifactWriter({ page, candidateOrigin, allowPrivat animations: "disabled", caret: "hide", fullPage: false, - mask: page.locator("input,textarea,select"), + mask: [page.locator("input,textarea,select")], timeout: Math.min(timeoutMs, 10_000) }); const dom = await extractPageData(page);