autopilot-web 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jason M. Schwefel / Cold Bore Ballistics LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # autopilot-web
2
+
3
+ The **web backend** for [AutoPilot](https://github.com/jschwefel-CBB/autopilot-core) —
4
+ runs the same declarative JSON test plans against a **browser**, via
5
+ [Playwright](https://playwright.dev). It is a first-class **test backend** (headless,
6
+ for CI parity, like the macOS/iOS/Android backends) *and* a **demo/screencast driver**
7
+ (headed, with on-page highlight/caption overlays and paced typing).
8
+
9
+ AutoPilot plans are plain JSON, designed to be authored by hand or by an AI agent. The
10
+ same plan the native runners execute drives the browser here — the JSON plan schema is
11
+ the single cross-platform contract. This runner reimplements the plan-execution loop
12
+ over Playwright, exactly as the Android runner reimplements it over UiAutomator and the
13
+ iOS runner over XCUITest.
14
+
15
+ > AutoPilot **is** a web testing tool. (Earlier AutoPilot docs said it was not — that is
16
+ > no longer true; web is a supported platform.)
17
+
18
+ ## What runs on web, and what is skipped
19
+
20
+ Everything with a browser equivalent is exercised and asserted:
21
+
22
+ - **Actions:** `launch` (open the URL), `click`, `doubleClick`, `rightClick`, `press`,
23
+ `type` (with `clear`/`commit`/paced typing), `setValue`, `keyPress`, `scroll`, `drag`,
24
+ `waitFor`, `screenshot`, `wait`, `assert`, and the demo actions `highlight` /
25
+ `caption` / `pace`.
26
+ - **Assert properties:** `value`, `title`, `enabled`, `focused`, `marked` (checkbox/
27
+ radio), `position`, `size`, `count` — with ops `equals` / `notEquals` / `contains` /
28
+ `matches` / `exists` / `notExists` / `greaterThan` / `lessThan`.
29
+
30
+ Steps with **no web equivalent are skipped** (reported as `skipped`, never failed —
31
+ the same "skip-don't-branch" contract the mobile backends use):
32
+
33
+ - `menu` (native menu bar), `assertPixel` / `assertRegion` / `snapshot` (native pixel
34
+ capture), `exec` (host command), and the assert properties `clipboard` / `stdout` /
35
+ `stderr` / `exitCode`.
36
+
37
+ ## Prerequisites — install Node.js (18+)
38
+
39
+ You need **Node.js 18 or newer**. Install it for your OS:
40
+
41
+ - **Debian:** `sudo apt-get update && sudo apt-get install -y nodejs npm`
42
+ (for a current version, use [NodeSource](https://github.com/nodesource/distributions):
43
+ `curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && sudo apt-get install -y nodejs`)
44
+ - **Ubuntu:** same as Debian (NodeSource recommended for a current version).
45
+ - **RHEL / Fedora:** `sudo dnf install -y nodejs npm`
46
+ (or NodeSource: `curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash - && sudo dnf install -y nodejs`)
47
+ - **Windows:** download the LTS installer from <https://nodejs.org/> and run it, or
48
+ `winget install OpenJS.NodeJS.LTS`.
49
+ - **macOS:** `brew install node`, or download the installer from <https://nodejs.org/>.
50
+
51
+ Verify: `node --version` (should print `v18…` or higher) and `npm --version`.
52
+
53
+ ## Install
54
+
55
+ From the repo directory:
56
+
57
+ ```bash
58
+ npm install # install dependencies (Playwright + TypeScript)
59
+ npx playwright install chromium # download the Chromium browser Playwright drives
60
+ npm run build # compile the TypeScript to dist/
61
+ ```
62
+
63
+ ## Run a plan
64
+
65
+ ```bash
66
+ # Headless (the normal test path) against the bundled TestHost page:
67
+ node dist/src/index.js run plans/test-web-capabilities.json --url testhost/index.html
68
+
69
+ # Against a live site:
70
+ node dist/src/index.js run plans/my-plan.json --url https://example.com
71
+
72
+ # Print the report as JSON (for tooling / CI):
73
+ node dist/src/index.js run plans/my-plan.json --url https://example.com --json
74
+ ```
75
+
76
+ `--url` accepts an `http(s)://` URL or a local file path (a bare path is opened as a
77
+ `file://` URL). A plan may also set `target.url`; the `--url` flag overrides it.
78
+
79
+ The process exits non-zero if the plan does not pass, so CI can gate on it.
80
+
81
+ ### Demo / screencast mode
82
+
83
+ ```bash
84
+ # Headed browser, with on-page highlight + caption overlays and paced typing,
85
+ # recording a video of the run:
86
+ node dist/src/index.js run plans/my-plan.json --url https://example.com \
87
+ --headed --demo --record ./recordings
88
+ ```
89
+
90
+ - `--headed` shows the browser window (omit for headless).
91
+ - `--demo` renders `highlight`/`caption` overlays and applies `pace` cadence
92
+ (per-char typing + post-step delays). **Without `--demo`, the demo actions are
93
+ passing no-ops** — a plan runs as a fast, clean test.
94
+ - `--record <dir>` writes a `.webm` video of the run into `<dir>`.
95
+
96
+ ## Test (the CI parity gate)
97
+
98
+ ```bash
99
+ npm test
100
+ ```
101
+
102
+ This builds, then runs the `web-capabilities` plan against the bundled
103
+ `testhost/index.html` and asserts the expected pass/skip counts — the gate that makes
104
+ web a real test backend. It uses Node's built-in test runner (`node:test`); no extra
105
+ test framework is required.
106
+
107
+ ## Layout
108
+
109
+ - `src/planModel.ts` — the Plan/Step/Selector/Assert types, mirroring the core schema.
110
+ - `src/runner.ts` — the plan-execution loop over Playwright (selector mapping, actions,
111
+ assertions, demo overlays, skip-don't-branch).
112
+ - `src/index.ts` — the CLI.
113
+ - `testhost/index.html` — the web TestHost (mirrors the native TestHostApp surface).
114
+ - `plans/test-web-capabilities.json` — the plan run by the parity gate.
115
+ - `test/runner.test.ts` — the parity-gate tests.
116
+
117
+ ## Selector mapping
118
+
119
+ AutoPilot selectors map onto Playwright locators:
120
+
121
+ | AutoPilot selector | Playwright locator |
122
+ | --- | --- |
123
+ | `identifier: "foo"` | `page.locator('#foo')` |
124
+ | `role: "button", title: "Save"` | `page.getByRole('button', { name: 'Save' })` |
125
+ | `role: "list"` | `page.getByRole('list')` |
126
+ | `title: "Save"` (no role) | `page.getByText('Save')` |
127
+ | `index: 2` | `.nth(2)` |
128
+ | `within: {...}` | resolve the parent, then locate the child inside it |
129
+
130
+ Native AX roles (e.g. `AXButton`, `AXTextField`) are mapped to their ARIA equivalents
131
+ (`button`, `textbox`); plain ARIA roles pass through unchanged.
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ // AutoPilot web runner CLI.
3
+ // autopilot-web run <plan.json> --url <target> [--headed] [--demo] [--record <dir>]
4
+ //
5
+ // Runs the same declarative JSON plan AutoPilot uses on macOS/iOS/Android, against a
6
+ // browser via Playwright. A test backend (headless, for CI parity) AND a
7
+ // demo/screencast driver (--headed --demo).
8
+ import { readFileSync } from "node:fs";
9
+ import { resolve, isAbsolute } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+ import { parsePlan } from "./planModel.js";
12
+ import { runPlan } from "./runner.js";
13
+ function usage() {
14
+ process.stderr.write("usage: autopilot-web run <plan.json> --url <target> [--headed] [--demo] [--record <dir>] [--json]\n");
15
+ process.exit(2);
16
+ }
17
+ function parseArgs(argv) {
18
+ if (argv[0] !== "run" || !argv[1])
19
+ usage();
20
+ const out = { planPath: argv[1], headed: false, demo: false, json: false };
21
+ for (let i = 2; i < argv.length; i++) {
22
+ const a = argv[i];
23
+ switch (a) {
24
+ case "--url":
25
+ out.url = argv[++i];
26
+ break;
27
+ case "--headed":
28
+ out.headed = true;
29
+ break;
30
+ case "--demo":
31
+ out.demo = true;
32
+ break;
33
+ case "--record":
34
+ out.recordDir = argv[++i];
35
+ break;
36
+ case "--json":
37
+ out.json = true;
38
+ break;
39
+ default:
40
+ process.stderr.write(`unknown argument: ${a}\n`);
41
+ usage();
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+ /** Resolve a --url that may be a local file path or an http(s) URL to something
47
+ * page.goto accepts. A bare path becomes a file:// URL. */
48
+ function resolveUrl(url) {
49
+ if (/^https?:\/\//i.test(url) || /^file:\/\//i.test(url))
50
+ return url;
51
+ return pathToFileURL(isAbsolute(url) ? url : resolve(process.cwd(), url)).href;
52
+ }
53
+ async function main() {
54
+ const args = parseArgs(process.argv.slice(2));
55
+ let planRaw;
56
+ try {
57
+ planRaw = JSON.parse(readFileSync(args.planPath, "utf8"));
58
+ }
59
+ catch (e) {
60
+ process.stderr.write(`Cannot read plan: ${args.planPath} (${String(e)})\n`);
61
+ process.exit(2);
62
+ }
63
+ const plan = parsePlan(planRaw);
64
+ // The URL: --url wins, else the plan's target.url, else error.
65
+ const rawUrl = args.url ?? plan.target.url;
66
+ if (!rawUrl) {
67
+ process.stderr.write("no target URL — pass --url <target> or set target.url in the plan\n");
68
+ process.exit(2);
69
+ }
70
+ const url = resolveUrl(rawUrl);
71
+ const report = await runPlan(plan, {
72
+ url, headed: args.headed, demo: args.demo, recordDir: args.recordDir,
73
+ });
74
+ if (args.json) {
75
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
76
+ }
77
+ else {
78
+ printReport(report);
79
+ }
80
+ // Exit non-zero if the plan did not pass, so CI gates on it.
81
+ process.exit(report.result === "pass" ? 0 : 1);
82
+ }
83
+ function printReport(r) {
84
+ const c = r.counts;
85
+ process.stdout.write(`RESULT ${r.result} — ${c.pass} pass, ${c.fail} fail, ${c.error} error, ${c.skipped} skipped\n`);
86
+ process.stdout.write(`Plan: ${r.plan}\n`);
87
+ for (const s of r.steps) {
88
+ const detail = s.message ? ` (${s.message})` : (s.expected != null ? ` expected=${s.expected} actual=${s.actual}` : "");
89
+ process.stdout.write(` [${s.result}] ${s.id} — ${s.action} (${s.durationMs}ms)${detail}\n`);
90
+ }
91
+ }
92
+ main().catch((e) => {
93
+ process.stderr.write(`autopilot-web error: ${String(e)}\n`);
94
+ process.exit(1);
95
+ });
@@ -0,0 +1,76 @@
1
+ // Plan model for the web runner. Mirrors autopilot-core's schema v1.2 (the JSON
2
+ // plan is the single cross-platform contract). Like Android's PlanModel.kt and
3
+ // iOS's PlanModel.swift, this hand-mirrors the schema rather than importing the
4
+ // Swift package.
5
+ const ACCEPTED_SCHEMA_VERSIONS = new Set(["1.1", "1.2"]);
6
+ const VALID_LEVELS = new Set(["happyPath", "integrationSuite", "tryToBreakIt"]);
7
+ /** Parse + validate a plan, mirroring the core parser's headline rules. Throws on
8
+ * anything the Swift parser would reject (unsupported version, missing level,
9
+ * duplicate ids, missing required per-action args). */
10
+ export function parsePlan(raw) {
11
+ if (typeof raw !== "object" || raw === null)
12
+ throw new Error("plan is not an object");
13
+ const plan = raw;
14
+ if (!ACCEPTED_SCHEMA_VERSIONS.has(plan.schemaVersion)) {
15
+ throw new Error(`unsupported schemaVersion '${plan.schemaVersion}' (accepted: 1.1, 1.2)`);
16
+ }
17
+ if (!Array.isArray(plan.steps))
18
+ throw new Error("plan.steps must be an array");
19
+ const seen = new Set();
20
+ for (const step of plan.steps) {
21
+ if (!step.id)
22
+ throw new Error("a step is missing its id");
23
+ if (seen.has(step.id))
24
+ throw new Error(`duplicate step id: ${step.id}`);
25
+ seen.add(step.id);
26
+ if (!step.level || !VALID_LEVELS.has(step.level)) {
27
+ throw new Error(`step ${step.id}: missing/invalid level (use happyPath, integrationSuite, tryToBreakIt)`);
28
+ }
29
+ validateStep(step);
30
+ }
31
+ return plan;
32
+ }
33
+ const TARGET_REQUIRING = new Set([
34
+ "click", "doubleClick", "rightClick", "press", "type", "keyPress", "setValue",
35
+ "scroll", "waitFor", "assert", "highlight",
36
+ ]);
37
+ function validateStep(step) {
38
+ if (TARGET_REQUIRING.has(step.action) && !step.target) {
39
+ const clipboardAssert = step.action === "assert" && step.assert?.property === "clipboard";
40
+ if (!clipboardAssert)
41
+ throw new Error(`step ${step.id}: action '${step.action}' requires a target`);
42
+ }
43
+ switch (step.action) {
44
+ case "type":
45
+ case "setValue":
46
+ if (step.args?.text == null)
47
+ throw new Error(`step ${step.id}: '${step.action}' requires args.text`);
48
+ break;
49
+ case "keyPress":
50
+ if (step.args?.keys == null)
51
+ throw new Error(`step ${step.id}: keyPress requires args.keys`);
52
+ break;
53
+ case "scroll":
54
+ if (step.args?.deltaX == null && step.args?.deltaY == null)
55
+ throw new Error(`step ${step.id}: scroll requires args.deltaX or args.deltaY`);
56
+ break;
57
+ case "assert":
58
+ if (!step.assert)
59
+ throw new Error(`step ${step.id}: assert requires an assert block`);
60
+ break;
61
+ case "wait":
62
+ if (step.args?.seconds == null)
63
+ throw new Error(`step ${step.id}: wait requires args.seconds`);
64
+ break;
65
+ case "caption":
66
+ if (step.args?.text == null)
67
+ throw new Error(`step ${step.id}: caption requires args.text`);
68
+ break;
69
+ case "pace":
70
+ if (step.args?.typeMsPerChar == null && step.args?.stepDelayMs == null)
71
+ throw new Error(`step ${step.id}: pace requires args.typeMsPerChar or args.stepDelayMs`);
72
+ break;
73
+ default:
74
+ break;
75
+ }
76
+ }
@@ -0,0 +1,394 @@
1
+ // The AutoPilot web runner: reimplements the deterministic plan-execution loop over
2
+ // a Playwright browser, exactly as Android reimplemented it over UiAutomator and iOS
3
+ // over XCUITest. The JSON plan schema is the only shared contract.
4
+ //
5
+ // Skip-don't-branch: actions with no web equivalent (native menubar `menu`, native
6
+ // pixel capture assertPixel/assertRegion/snapshot, host `exec`) are SKIPPED and
7
+ // reported as such — never a hard failure — matching the mobile runners.
8
+ import { chromium, } from "playwright";
9
+ // Actions with no web equivalent — reported as skipped (skip-don't-branch).
10
+ const SKIP_ON_WEB = new Set([
11
+ "menu", "assertPixel", "assertRegion", "snapshot", "exec",
12
+ ]);
13
+ /** Run a plan against a browser and return a report. */
14
+ export async function runPlan(plan, options) {
15
+ const timeout = plan.defaults?.timeoutMs ?? options.defaultTimeoutMs ?? 5000;
16
+ const browser = await chromium.launch({ headless: !options.headed });
17
+ const context = await browser.newContext(options.recordDir ? { recordVideo: { dir: options.recordDir } } : {});
18
+ const page = await context.newPage();
19
+ page.setDefaultTimeout(timeout);
20
+ const cadence = { typeMsPerChar: 0, stepDelayMs: 0 };
21
+ const results = [];
22
+ const url = plan.target.url ?? options.url;
23
+ try {
24
+ for (const step of plan.steps) {
25
+ const start = Date.now();
26
+ let r;
27
+ try {
28
+ r = await runStep(step, { page, url, timeout, demo: !!options.demo, cadence });
29
+ }
30
+ catch (e) {
31
+ r = {
32
+ id: step.id, action: step.action, result: "error",
33
+ durationMs: Date.now() - start, message: String(e),
34
+ };
35
+ }
36
+ r.durationMs = Date.now() - start;
37
+ results.push(r);
38
+ // Demo pacing: pause after each step (except pace itself) so a screencast is
39
+ // watchable. Never runs in a normal test (cadence stays 0 unless demo pace set it).
40
+ if (options.demo && step.action !== "pace" && cadence.stepDelayMs > 0) {
41
+ await sleep(cadence.stepDelayMs);
42
+ }
43
+ if (r.result === "fail" || r.result === "error") {
44
+ // Stop on first failure (matches the runners' default; no keepGoing here).
45
+ break;
46
+ }
47
+ }
48
+ }
49
+ finally {
50
+ await context.close();
51
+ await browser.close();
52
+ }
53
+ const counts = { pass: 0, fail: 0, error: 0, skipped: 0 };
54
+ for (const r of results)
55
+ counts[r.result] += 1;
56
+ const overall = counts.fail || counts.error ? "fail" : "pass";
57
+ return { plan: plan.name, result: overall, steps: results, counts };
58
+ }
59
+ async function runStep(step, ctx) {
60
+ const { page } = ctx;
61
+ const base = (result, extra = {}) => ({ id: step.id, action: step.action, result, durationMs: 0, ...extra });
62
+ if (SKIP_ON_WEB.has(step.action)) {
63
+ return base("skipped", { message: `${step.action} has no web equivalent` });
64
+ }
65
+ switch (step.action) {
66
+ case "launch": {
67
+ await page.goto(ctx.url, { waitUntil: "load" });
68
+ return base("pass");
69
+ }
70
+ case "terminate":
71
+ // Browser/context teardown happens in runPlan's finally; nothing per-step.
72
+ return base("pass");
73
+ case "wait":
74
+ await sleep((step.args?.seconds ?? 0) * 1000);
75
+ return base("pass");
76
+ case "waitFor": {
77
+ const present = step.args?.present ?? true;
78
+ const loc = locate(page, step.target);
79
+ try {
80
+ await loc.first().waitFor({ state: present ? "visible" : "hidden", timeout: ctx.timeout });
81
+ return base("pass");
82
+ }
83
+ catch {
84
+ return base("fail", { message: `element ${present ? "did not appear" : "did not disappear"}` });
85
+ }
86
+ }
87
+ case "click":
88
+ await locate(page, step.target).first().click();
89
+ return base("pass");
90
+ case "doubleClick":
91
+ await locate(page, step.target).first().dblclick();
92
+ return base("pass");
93
+ case "rightClick":
94
+ await locate(page, step.target).first().click({ button: "right" });
95
+ return base("pass");
96
+ case "press":
97
+ // No native AX "press" on web; a click is the closest robust equivalent.
98
+ await locate(page, step.target).first().click();
99
+ return base("pass");
100
+ case "type": {
101
+ const loc = locate(page, step.target).first();
102
+ const text = step.args?.text ?? "";
103
+ if (step.args?.clear)
104
+ await loc.fill("");
105
+ if (step.args?.focus !== false)
106
+ await loc.focus();
107
+ // Demo pacing: type char-by-char with a delay so it's watchable. In a normal
108
+ // run the delay is 0, so this types at full speed.
109
+ const delay = ctx.demo ? (step.args?.typeMsPerChar ?? ctx.cadence.typeMsPerChar) : 0;
110
+ await loc.pressSequentially(text, { delay });
111
+ if (step.args?.commit)
112
+ await loc.press("Enter");
113
+ return base("pass");
114
+ }
115
+ case "setValue": {
116
+ // fill() replaces the whole value in one shot (the setValue semantics).
117
+ await locate(page, step.target).first().fill(step.args?.text ?? "");
118
+ return base("pass");
119
+ }
120
+ case "keyPress": {
121
+ const key = chordToPlaywright(step.args?.keys ?? "");
122
+ await page.keyboard.press(key);
123
+ return base("pass");
124
+ }
125
+ case "scroll": {
126
+ const loc = locate(page, step.target).first();
127
+ const dx = step.args?.deltaX ?? 0;
128
+ const dy = step.args?.deltaY ?? 0;
129
+ await loc.evaluate((el, [x, y]) => { el.scrollBy(x, y); }, [dx, dy]);
130
+ return base("pass");
131
+ }
132
+ case "drag": {
133
+ const from = locate(page, step.target).first();
134
+ const to = step.args?.to ? locate(page, step.args.to).first() : null;
135
+ if (!to)
136
+ return base("error", { message: "drag on web requires args.to (file drag unsupported)" });
137
+ await from.dragTo(to);
138
+ return base("pass");
139
+ }
140
+ case "screenshot": {
141
+ const path = step.args?.path;
142
+ if (step.target)
143
+ await locate(page, step.target).first().screenshot(path ? { path } : {});
144
+ else
145
+ await page.screenshot(path ? { path, fullPage: false } : {});
146
+ return base("pass");
147
+ }
148
+ case "assert":
149
+ return await runAssert(step, ctx);
150
+ // ── Demo actions (v1.2). Render only in demo mode; otherwise passing no-ops.
151
+ case "pace": {
152
+ if (step.args?.typeMsPerChar != null)
153
+ ctx.cadence.typeMsPerChar = Math.max(0, step.args.typeMsPerChar);
154
+ if (step.args?.stepDelayMs != null)
155
+ ctx.cadence.stepDelayMs = Math.max(0, step.args.stepDelayMs);
156
+ return base("pass");
157
+ }
158
+ case "caption": {
159
+ if (ctx.demo)
160
+ await showCaption(page, step.args?.text ?? "", step.args?.position ?? "bottom", step.args?.holdMs ?? 0);
161
+ return base("pass");
162
+ }
163
+ case "highlight": {
164
+ if (ctx.demo) {
165
+ try {
166
+ await showHighlight(locate(page, step.target).first(), step.args?.holdMs ?? 0);
167
+ }
168
+ catch { /* decorative */ }
169
+ }
170
+ return base("pass");
171
+ }
172
+ default:
173
+ return base("skipped", { message: `unhandled action ${step.action}` });
174
+ }
175
+ }
176
+ // ── Selector mapping (verified against the Playwright locator API + the TestHost) ──
177
+ // identifier -> [id="..."]; role+title -> getByRole(role,{name:title}); role alone ->
178
+ // getByRole; title alone -> by accessible name; index -> .nth(); within -> chained.
179
+ function locate(page, sel) {
180
+ let loc = locateIn(page, sel);
181
+ if (sel.within) {
182
+ // Scope: resolve the parent first, then find the child inside it.
183
+ const parent = locate(page, sel.within);
184
+ loc = locateIn(parent, sel);
185
+ }
186
+ if (sel.index != null)
187
+ loc = loc.nth(sel.index);
188
+ return loc;
189
+ }
190
+ // Build a locator from a selector's own fields, rooted at `root` (a Page or Locator).
191
+ function locateIn(root, sel) {
192
+ if (sel.identifier)
193
+ return root.locator(`#${cssEscape(sel.identifier)}`);
194
+ if (sel.role) {
195
+ // AutoPilot roles may be native-AX style (AXButton) or plain ARIA (button).
196
+ const role = normalizeRole(sel.role);
197
+ return sel.title
198
+ ? root.getByRole(role, { name: sel.title })
199
+ : root.getByRole(role);
200
+ }
201
+ if (sel.title)
202
+ return root.getByText(sel.title);
203
+ if (sel.value)
204
+ return root.locator(`[value="${cssEscape(sel.value)}"]`);
205
+ // Nothing usable — an empty locator that will simply not match.
206
+ return root.locator(":scope >> nth=-1");
207
+ }
208
+ // Map a few common AX roles to ARIA roles; pass through anything already ARIA.
209
+ function normalizeRole(role) {
210
+ const ax = {
211
+ AXButton: "button",
212
+ AXTextField: "textbox",
213
+ AXTextArea: "textbox",
214
+ AXCheckBox: "checkbox",
215
+ AXStaticText: "text",
216
+ AXList: "list",
217
+ AXRow: "listitem",
218
+ AXLink: "link",
219
+ AXRadioButton: "radio",
220
+ };
221
+ return ax[role] ?? role;
222
+ }
223
+ async function runAssert(step, ctx) {
224
+ const a = step.assert;
225
+ const base = (result, extra = {}) => ({ id: step.id, action: "assert", result, durationMs: 0, ...extra });
226
+ // presence
227
+ if (a.op === "exists" || a.op === "notExists") {
228
+ const present = a.op === "exists";
229
+ const loc = locate(ctx.page, step.target);
230
+ try {
231
+ await loc.first().waitFor({ state: present ? "visible" : "hidden", timeout: ctx.timeout });
232
+ return base("pass", { expected: a.op, actual: a.op });
233
+ }
234
+ catch {
235
+ return base("fail", { expected: a.op, actual: present ? "notExists" : "exists" });
236
+ }
237
+ }
238
+ // count
239
+ if (a.property === "count") {
240
+ const n = await locate(ctx.page, step.target).count();
241
+ return compare(base, a, String(n));
242
+ }
243
+ // clipboard / stdout / stderr / exitCode have no web equivalent.
244
+ if (["clipboard", "stdout", "stderr", "exitCode"].includes(a.property)) {
245
+ return base("skipped", { message: `assert property '${a.property}' has no web equivalent` });
246
+ }
247
+ const loc = locate(ctx.page, step.target).first();
248
+ const actual = await readProperty(loc, a.property);
249
+ return compare(base, a, actual);
250
+ }
251
+ async function readProperty(loc, property) {
252
+ switch (property) {
253
+ case "value": {
254
+ // A form control's value, or the element's text for non-inputs (statusLabel).
255
+ const tag = await loc.evaluate((el) => el.tagName.toLowerCase()).catch(() => "");
256
+ if (tag === "input" || tag === "textarea" || tag === "select")
257
+ return (await loc.inputValue().catch(() => "")) ?? "";
258
+ return ((await loc.textContent()) ?? "").trim();
259
+ }
260
+ case "title":
261
+ return ((await loc.textContent()) ?? "").trim();
262
+ case "enabled":
263
+ return String(await loc.isEnabled().catch(() => false));
264
+ case "focused":
265
+ return String(await loc.evaluate((el) => el === document.activeElement).catch(() => false));
266
+ case "marked": // checkbox/radio checked state
267
+ return String(await loc.isChecked().catch(() => false));
268
+ case "position": {
269
+ const box = await loc.boundingBox();
270
+ return box ? `${Math.round(box.x)},${Math.round(box.y)}` : "";
271
+ }
272
+ case "size": {
273
+ const box = await loc.boundingBox();
274
+ return box ? `${Math.round(box.width)},${Math.round(box.height)}` : "";
275
+ }
276
+ default:
277
+ return "";
278
+ }
279
+ }
280
+ function compare(base, a, actual) {
281
+ const expected = a.expected ?? "";
282
+ let ok = false;
283
+ switch (a.op) {
284
+ case "equals":
285
+ ok = actual === expected;
286
+ break;
287
+ case "notEquals":
288
+ ok = actual !== expected;
289
+ break;
290
+ case "contains":
291
+ ok = actual.includes(expected);
292
+ break;
293
+ case "matches":
294
+ ok = safeRegex(expected)?.test(actual) ?? false;
295
+ break;
296
+ case "greaterThan":
297
+ ok = num(actual) > num(expected);
298
+ break;
299
+ case "lessThan":
300
+ ok = num(actual) < num(expected);
301
+ break;
302
+ default: ok = false;
303
+ }
304
+ return base(ok ? "pass" : "fail", { expected, actual });
305
+ }
306
+ // ── Demo overlays (injected into the page in demo mode) ──
307
+ async function showHighlight(loc, holdMs) {
308
+ const box = await loc.boundingBox();
309
+ if (!box)
310
+ return;
311
+ await loc.page().evaluate(({ b, hold }) => {
312
+ const d = document.createElement("div");
313
+ d.style.cssText =
314
+ `position:fixed;left:${b.x - 4}px;top:${b.y - 4}px;width:${b.width + 8}px;height:${b.height + 8}px;` +
315
+ "border:3px solid gold;border-radius:8px;box-shadow:0 0 12px 3px rgba(255,215,0,.8);" +
316
+ "pointer-events:none;z-index:2147483647;transition:opacity .3s;";
317
+ d.setAttribute("data-autopilot-overlay", "highlight");
318
+ document.body.appendChild(d);
319
+ setTimeout(() => { d.style.opacity = "0"; setTimeout(() => d.remove(), 300); }, Math.max(hold, 400));
320
+ }, { b: box, hold: holdMs });
321
+ }
322
+ async function showCaption(page, text, position, holdMs) {
323
+ await page.evaluate(({ t, pos, hold }) => {
324
+ const d = document.createElement("div");
325
+ const vertical = pos === "top" ? "top:24px;" : pos === "center" ? "top:50%;transform:translate(-50%,-50%);" : "bottom:24px;";
326
+ d.style.cssText =
327
+ `position:fixed;left:50%;${pos === "center" ? "" : "transform:translateX(-50%);"}${vertical}` +
328
+ "background:rgba(0,0,0,.8);color:#fff;font:600 20px system-ui,sans-serif;padding:12px 20px;" +
329
+ "border-radius:12px;pointer-events:none;z-index:2147483647;max-width:70vw;text-align:center;transition:opacity .3s;";
330
+ d.setAttribute("data-autopilot-overlay", "caption");
331
+ d.textContent = t;
332
+ document.body.appendChild(d);
333
+ setTimeout(() => { d.style.opacity = "0"; setTimeout(() => d.remove(), 300); }, Math.max(hold, 400));
334
+ }, { t: text, pos: position, hold: holdMs });
335
+ }
336
+ // ── helpers ──
337
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
338
+ function num(s) { const n = Number(s); return Number.isNaN(n) ? NaN : n; }
339
+ function safeRegex(p) { try {
340
+ return new RegExp(p);
341
+ }
342
+ catch {
343
+ return null;
344
+ } }
345
+ function cssEscape(s) { return s.replace(/["\\]/g, "\\$&"); }
346
+ // Translate an AutoPilot chord ("cmd+s", "cmd+plus") to a Playwright key string
347
+ // ("Meta+s", "Meta+Shift+="). Playwright uses Meta for Cmd, Control for Ctrl.
348
+ function chordToPlaywright(chord) {
349
+ const parts = chord.split("+").map((p) => p.trim().toLowerCase()).filter(Boolean);
350
+ const mods = [];
351
+ let key = "";
352
+ for (const p of parts) {
353
+ switch (p) {
354
+ case "cmd":
355
+ case "command":
356
+ mods.push("Meta");
357
+ break;
358
+ case "ctrl":
359
+ case "control":
360
+ mods.push("Control");
361
+ break;
362
+ case "shift":
363
+ mods.push("Shift");
364
+ break;
365
+ case "opt":
366
+ case "option":
367
+ case "alt":
368
+ mods.push("Alt");
369
+ break;
370
+ case "plus":
371
+ mods.push("Shift");
372
+ key = "=";
373
+ break; // '+' is Shift+'='
374
+ case "enter":
375
+ case "return":
376
+ key = "Enter";
377
+ break;
378
+ case "tab":
379
+ key = "Tab";
380
+ break;
381
+ case "space":
382
+ key = " ";
383
+ break;
384
+ case "escape":
385
+ key = "Escape";
386
+ break;
387
+ case "delete":
388
+ key = "Backspace";
389
+ break;
390
+ default: key = p.length === 1 ? p : p.charAt(0).toUpperCase() + p.slice(1);
391
+ }
392
+ }
393
+ return [...mods, key].filter(Boolean).join("+");
394
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "autopilot-web",
3
+ "version": "3.5.0",
4
+ "description": "Web backend for AutoPilot — runs the same declarative JSON plans against a browser via Playwright. A test runner AND a demo/screencast driver.",
5
+ "type": "module",
6
+ "bin": {
7
+ "autopilot-web": "dist/src/index.js"
8
+ },
9
+ "files": [
10
+ "dist/src",
11
+ "testhost",
12
+ "plans",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.json",
18
+ "run-plan": "npm run build && node dist/src/index.js",
19
+ "test": "npm run build && node --test dist/test/*.test.js",
20
+ "typecheck": "tsc -p tsconfig.json --noEmit",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "dependencies": {
27
+ "playwright": "^1.48.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.0.0",
31
+ "typescript": "^5.6.0"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/jschwefel-CBB/autopilot-web.git"
36
+ },
37
+ "homepage": "https://github.com/jschwefel-CBB/autopilot-web#readme",
38
+ "bugs": {
39
+ "url": "https://github.com/jschwefel-CBB/autopilot-web/issues"
40
+ },
41
+ "author": "Jason M. Schwefel",
42
+ "license": "MIT"
43
+ }
@@ -0,0 +1,65 @@
1
+ {
2
+ "schemaVersion": "1.2",
3
+ "name": "web-capabilities",
4
+ "target": {},
5
+ "defaults": { "timeoutMs": 5000, "retryIntervalMs": 100 },
6
+ "steps": [
7
+ { "id": "launch", "level": "happyPath", "action": "launch" },
8
+
9
+ { "id": "wait-name", "level": "happyPath", "action": "waitFor",
10
+ "target": { "identifier": "nameField" }, "args": { "present": true } },
11
+
12
+ { "id": "type-name", "level": "happyPath", "action": "type",
13
+ "target": { "identifier": "nameField" },
14
+ "args": { "text": "Ada", "clear": true } },
15
+
16
+ { "id": "assert-status-by-id", "level": "happyPath", "action": "assert",
17
+ "target": { "identifier": "statusLabel" },
18
+ "assert": { "property": "value", "op": "contains", "expected": "Ada" } },
19
+
20
+ { "id": "assert-status-by-role", "level": "integrationSuite", "action": "assert",
21
+ "target": { "role": "status" },
22
+ "assert": { "property": "value", "op": "contains", "expected": "Ada" } },
23
+
24
+ { "id": "click-greet-by-role", "level": "happyPath", "action": "click",
25
+ "target": { "role": "button", "title": "Greet" } },
26
+
27
+ { "id": "assert-greeted", "level": "happyPath", "action": "assert",
28
+ "target": { "identifier": "statusLabel" },
29
+ "assert": { "property": "value", "op": "equals", "expected": "status: greeted" } },
30
+
31
+ { "id": "type-search", "level": "integrationSuite", "action": "type",
32
+ "target": { "identifier": "searchField" },
33
+ "args": { "text": "Item 1" } },
34
+
35
+ { "id": "assert-filtered-count", "level": "integrationSuite", "action": "assert",
36
+ "target": { "role": "listitem" },
37
+ "assert": { "property": "count", "op": "greaterThan", "expected": "0" } },
38
+
39
+ { "id": "check-agree", "level": "happyPath", "action": "click",
40
+ "target": { "identifier": "agree" } },
41
+
42
+ { "id": "assert-agree-marked", "level": "integrationSuite", "action": "assert",
43
+ "target": { "identifier": "agree" },
44
+ "assert": { "property": "marked", "op": "equals", "expected": "true" } },
45
+
46
+ { "id": "assert-greet-enabled", "level": "integrationSuite", "action": "assert",
47
+ "target": { "identifier": "greetButton" },
48
+ "assert": { "property": "enabled", "op": "equals", "expected": "true" } },
49
+
50
+ { "id": "menu-skips", "level": "tryToBreakIt", "action": "menu",
51
+ "args": { "menuPath": ["File", "Nope"] } },
52
+
53
+ { "id": "exec-skips", "level": "tryToBreakIt", "action": "exec",
54
+ "args": { "command": "true" } },
55
+
56
+ { "id": "pace", "level": "happyPath", "action": "pace",
57
+ "args": { "typeMsPerChar": 30, "stepDelayMs": 150 } },
58
+
59
+ { "id": "caption-demo", "level": "happyPath", "action": "caption",
60
+ "args": { "text": "Demo overlay", "position": "bottom", "holdMs": 500 } },
61
+
62
+ { "id": "highlight-demo", "level": "happyPath", "action": "highlight",
63
+ "target": { "identifier": "nameField" }, "args": { "holdMs": 500 } }
64
+ ]
65
+ }
@@ -0,0 +1,108 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>AutoPilot Web TestHost</title>
7
+ <style>
8
+ :root { font-family: system-ui, -apple-system, sans-serif; }
9
+ body { margin: 0; padding: 24px; max-width: 720px; }
10
+ h1 { font-size: 20px; }
11
+ .row { margin: 14px 0; }
12
+ label { display: block; font-size: 13px; color: #555; margin-bottom: 4px; }
13
+ input[type="text"], input[type="search"] { width: 320px; padding: 8px; font-size: 15px; }
14
+ #status { font-weight: 600; min-height: 1.2em; }
15
+ #results { border: 1px solid #ccc; height: 140px; overflow-y: auto; width: 320px; padding: 4px; }
16
+ .result { padding: 6px 4px; border-bottom: 1px solid #eee; }
17
+ button { padding: 8px 14px; font-size: 15px; }
18
+ #clicks { color: #06c; }
19
+ </style>
20
+ </head>
21
+ <body>
22
+ <!--
23
+ Web TestHost for AutoPilot. Mirrors the native TestHostApp surface so the same
24
+ kinds of plans run against a browser. Elements carry BOTH a stable id (for the
25
+ selector.identifier -> [id] mapping) AND a proper role/accessible name (for the
26
+ selector role+title -> getByRole mapping) so both paths are exercised.
27
+ -->
28
+ <h1>AutoPilot Web TestHost</h1>
29
+
30
+ <div class="row">
31
+ <label for="nameField">Name</label>
32
+ <!-- Typing here echoes into #status, like the native nameField -> statusLabel. -->
33
+ <input type="text" id="nameField" aria-label="Name" />
34
+ </div>
35
+
36
+ <div class="row">
37
+ <label>Status</label>
38
+ <!-- role="status" + id. Its text reflects the last name typed. -->
39
+ <div id="statusLabel" role="status">status: (none)</div>
40
+ </div>
41
+
42
+ <div class="row">
43
+ <!-- A button with an accessible name "Greet" (getByRole('button',{name:'Greet'})). -->
44
+ <button id="greetButton" type="button">Greet</button>
45
+ <span id="clicks" role="note" aria-label="Clicks">clicks: 0</span>
46
+ </div>
47
+
48
+ <div class="row">
49
+ <label for="searchField">Search</label>
50
+ <input type="search" id="searchField" aria-label="Search" placeholder="type to filter" />
51
+ </div>
52
+
53
+ <div class="row">
54
+ <label>Results</label>
55
+ <!-- A scrollable list; used to exercise scroll + count asserts. -->
56
+ <div id="results" role="list" aria-label="Results"></div>
57
+ </div>
58
+
59
+ <div class="row">
60
+ <label><input type="checkbox" id="agree" aria-label="Agree" /> Agree to terms</label>
61
+ <div id="agreeState" role="status">agree: false</div>
62
+ </div>
63
+
64
+ <script>
65
+ const nameField = document.getElementById('nameField');
66
+ const statusLabel = document.getElementById('statusLabel');
67
+ nameField.addEventListener('input', () => {
68
+ const v = nameField.value;
69
+ statusLabel.textContent = v ? ('status: ' + v) : 'status: (none)';
70
+ });
71
+
72
+ let clicks = 0;
73
+ const clicksEl = document.getElementById('clicks');
74
+ document.getElementById('greetButton').addEventListener('click', () => {
75
+ clicks += 1;
76
+ clicksEl.textContent = 'clicks: ' + clicks;
77
+ statusLabel.textContent = 'status: greeted';
78
+ });
79
+
80
+ // Seed the results list, filtered by the search box.
81
+ const ALL = Array.from({ length: 12 }, (_, i) => 'Item ' + (i + 1));
82
+ const results = document.getElementById('results');
83
+ function renderResults(filter) {
84
+ const f = (filter || '').toLowerCase();
85
+ results.innerHTML = '';
86
+ for (const item of ALL) {
87
+ if (!f || item.toLowerCase().includes(f)) {
88
+ const el = document.createElement('div');
89
+ el.className = 'result';
90
+ el.setAttribute('role', 'listitem');
91
+ el.textContent = item;
92
+ results.appendChild(el);
93
+ }
94
+ }
95
+ }
96
+ renderResults('');
97
+ document.getElementById('searchField').addEventListener('input', (e) => {
98
+ renderResults(e.target.value);
99
+ });
100
+
101
+ const agree = document.getElementById('agree');
102
+ const agreeState = document.getElementById('agreeState');
103
+ agree.addEventListener('change', () => {
104
+ agreeState.textContent = 'agree: ' + agree.checked;
105
+ });
106
+ </script>
107
+ </body>
108
+ </html>