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
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.
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.
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",
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",
diff --git a/packages/cli/integration/journey.test.js b/packages/cli/integration/journey.test.js
new file mode 100644
index 0000000..9285939
--- /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, JSON.stringify(passing, null, 2));
+ 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 });
+ }
+});
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"}
}
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;
});
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 };
+}
diff --git a/packages/cli/src/journey-artifacts.js b/packages/cli/src/journey-artifacts.js
new file mode 100644
index 0000000..85a56d2
--- /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)
+ };
+}
diff --git a/packages/cli/src/journey-report.js b/packages/cli/src/journey-report.js
new file mode 100644
index 0000000..e31b8e8
--- /dev/null
+++ b/packages/cli/src/journey-report.js
@@ -0,0 +1,60 @@
+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);
+}
+
+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`;
+}
diff --git a/packages/cli/src/journey-runtime.js b/packages/cli/src/journey-runtime.js
new file mode 100644
index 0000000..a4ec216
--- /dev/null
+++ b/packages/cli/src/journey-runtime.js
@@ -0,0 +1,110 @@
+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;
+}
+
+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) {
+ 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 assertClickAllowed(locator, context.allowWriteActions);
+ await locator.click({ timeout });
+ break;
+ }
+ case "fill": {
+ const locator = await uniqueLocator(page, step.target);
+ await locator.fill(step.value, { timeout });
+ break;
+ }
+ case "press":
+ await assertKeyAllowed(page, step.key, context.allowWriteActions);
+ 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);
+}
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]);
diff --git a/packages/cli/src/journey.js b/packages/cli/src/journey.js
new file mode 100644
index 0000000..8011823
--- /dev/null
+++ b/packages/cli/src/journey.js
@@ -0,0 +1,209 @@
+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, 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";
+
+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`);
+ await writeTracked("JOURNEY_REPORT.md", renderJourneyReport(result));
+ await writeTracked("JOURNEY_CORRECTIONS.md", renderJourneyCorrections(result));
+ 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;
+ }
+}
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";
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);
+});