mobile-automator 0.23.8

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.
Files changed (61) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +167 -0
  3. package/bin/mauto-session-daemon.js +101 -0
  4. package/bin/mauto.js +13 -0
  5. package/package.json +49 -0
  6. package/src/assertion/evaluator.js +285 -0
  7. package/src/cli.js +1722 -0
  8. package/src/config/coerce.js +97 -0
  9. package/src/config/manager.js +75 -0
  10. package/src/config/schema.js +124 -0
  11. package/src/device/action-catalog.js +99 -0
  12. package/src/device/bridge.js +139 -0
  13. package/src/device/connection.js +65 -0
  14. package/src/device/device-model.js +59 -0
  15. package/src/device/device-resolver.js +34 -0
  16. package/src/device/element-model.js +82 -0
  17. package/src/device/mobile-mcp-client.js +163 -0
  18. package/src/device/resolve-connection.js +156 -0
  19. package/src/device/selection.js +80 -0
  20. package/src/device/semantic-press/index.js +38 -0
  21. package/src/device/semantic-press/labels.js +39 -0
  22. package/src/device/semantic-press/resolver.js +100 -0
  23. package/src/device/session-client.js +173 -0
  24. package/src/device/session-daemon.js +526 -0
  25. package/src/device/session-paths.js +80 -0
  26. package/src/device/session-protocol.js +60 -0
  27. package/src/device/session-spawn.js +67 -0
  28. package/src/device/tool-args.js +14 -0
  29. package/src/guide/content/execute.agnostic.md +207 -0
  30. package/src/guide/content/execute.aware.md +281 -0
  31. package/src/guide/content/execute.invariants.md +4 -0
  32. package/src/guide/content/generate.agnostic.md +236 -0
  33. package/src/guide/content/generate.aware.md +327 -0
  34. package/src/guide/content/generate.invariants.md +5 -0
  35. package/src/guide/content/setup.agnostic.md +63 -0
  36. package/src/guide/content/setup.aware.md +91 -0
  37. package/src/guide/content/setup.invariants.md +3 -0
  38. package/src/guide/emitter.js +137 -0
  39. package/src/guide/placeholders.js +132 -0
  40. package/src/init/adapters.js +162 -0
  41. package/src/init/skill-meta.js +31 -0
  42. package/src/init/skill-renderer.js +60 -0
  43. package/src/mcp/prompts.js +55 -0
  44. package/src/mcp/server.js +55 -0
  45. package/src/memory/entries.js +61 -0
  46. package/src/memory/history.js +86 -0
  47. package/src/memory/lock.js +5 -0
  48. package/src/memory/paths.js +47 -0
  49. package/src/memory/store.js +230 -0
  50. package/src/output/envelope.js +106 -0
  51. package/src/result/capability-catalog.js +144 -0
  52. package/src/result/flags.js +52 -0
  53. package/src/result/store.js +273 -0
  54. package/src/scenario/validator.js +30 -0
  55. package/src/schemas/config_schema.json +68 -0
  56. package/src/schemas/format-error.js +19 -0
  57. package/src/schemas/result_schema.json +211 -0
  58. package/src/schemas/scenario_schema.json +587 -0
  59. package/src/setup/scaffold.js +53 -0
  60. package/src/util/atomic.js +41 -0
  61. package/src/util/lock.js +85 -0
package/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2025 Mobile QA Skills Contributors
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # 🚀 Mobile Automator
2
+
3
+ > **Turn your AI coding agent into a mobile QA engineer.**
4
+
5
+ `mauto` is a host-agnostic CLI that lets **any** AI coding agent author and run UI tests on a real device or emulator. The agent decides *what* to do; `mauto` does it — tap, type, swipe, assert — through [mobile-mcp](https://github.com/mobile-next/mobile-mcp). Tests are plain JSON scenarios, **platform-agnostic** by default, so the same scenario runs on Android and iOS.
6
+
7
+ Claude Code, Cursor, Gemini CLI, GitHub Copilot, and OpenAI Agents all have one-command setup via `mauto init --agent <claude|cursor|gemini|copilot|agents|all>`, which installs native Agent Skills into each host's skills directory (claude and cursor also get slash-commands/rules and the `mauto` MCP entry); any other MCP-capable agent connects via the `mauto mcp` prompts server. See [Using another agent](#-using-another-agent).
8
+
9
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
10
+ [![Platform](https://img.shields.io/badge/Platform-Android%20%7C%20iOS%20%7C%20Flutter%20%7C%20RN%20%7C%20KMP%20%7C%20CMP-green.svg)](#)
11
+ [![Node](https://img.shields.io/badge/Node-%E2%89%A518-brightgreen.svg)](#)
12
+
13
+ ---
14
+
15
+ ## ⏱️ Quick start (5 minutes)
16
+
17
+ **You need:** Node ≥ 18 · an AI coding agent · a running emulator/simulator or a connected device.
18
+
19
+ > This quick start uses **Claude Code** as the example agent. For Cursor, swap `--agent claude` → `--agent cursor`. For any other agent, see [Using another agent](#-using-another-agent).
20
+
21
+ **1. Install `mauto`** (from source — not yet on npm):
22
+
23
+ ```bash
24
+ git clone https://github.com/sh3lan93/mobile-automator
25
+ cd mobile-automator
26
+ npm install && npm link # exposes `mauto` globally
27
+ ```
28
+
29
+ **2. Wire it into your project and agent:**
30
+
31
+ ```bash
32
+ cd ~/projects/my-app
33
+ mauto init --agent claude # or: cursor | gemini | copilot | agents | all
34
+ mauto setup # scaffolds mobile-automator/ + config
35
+ ```
36
+
37
+ `init` installs native Agent Skills into each host's skills directory and writes agent command files + MCP server entry (`.claude/commands/` + `.mcp.json` for Claude Code; `.cursor/` for Cursor). `setup` creates the `mobile-automator/` workspace. Add `--mode agnostic` to `setup` for cross-platform apps (Flutter/RN/KMP/CMP).
38
+
39
+ **3. Check your device is visible:**
40
+
41
+ ```bash
42
+ mauto devices # lists devices; `mauto devices use <id>` to pin one
43
+ ```
44
+
45
+ **4. Author and run a test — from inside your agent:**
46
+
47
+ Open your agent in the project. In **Claude Code**, `init` installs slash commands:
48
+
49
+ ```
50
+ /mobile-automator-generate → describe a flow in plain English; the agent
51
+ drives the app and writes a scenario JSON
52
+
53
+ /mobile-automator-execute → the agent replays the scenario and reports
54
+ pass/fail with diagnostics
55
+ ```
56
+
57
+ In **Cursor**, `init` installs a project rule instead — just ask the agent in plain language (e.g. *"generate a login test"* / *"execute the login scenario"*) and it follows the same workflow. **Any other agent:** see [Using another agent](#-using-another-agent) below.
58
+
59
+ That's it. Scenarios land in `mobile-automator/scenarios/`, results in `mobile-automator/results/`.
60
+
61
+ ---
62
+
63
+ ## 🧠 How it works
64
+
65
+ `mauto` splits the work into a **brain** and **hands**:
66
+
67
+ - **The agent (brain)** resolves targets ("the Login button"), judges visual assertions, and assembles scenarios. It reads workflow guidance on demand with `mauto guide <topic>`.
68
+ - **`mauto` (hands)** performs deterministic, scriptable actions and mechanical assertions, driving the device via mobile-mcp. Every verb emits a uniform JSON envelope (`{ok, data, error, hint}`); add `--human` for readable output.
69
+
70
+ Targets are always **visible text + semantic role + coordinates** — never brittle resource-ids — which is what makes scenarios portable across Android and iOS.
71
+
72
+ ```
73
+ Your agent ──drives──▶ mauto verbs ──wrap──▶ mobile-mcp ──▶ device
74
+ (decisions) (deterministic) (automation)
75
+ ```
76
+
77
+ Because the contract is just verbs + JSON, **no agent is special** — `mauto init` ships native Agent Skills for Claude Code, Cursor, Gemini CLI, GitHub Copilot, and OpenAI Agents, but any agent that can run a shell command or speak MCP can drive the same tool.
78
+
79
+ ---
80
+
81
+ ## 🤖 Using another agent
82
+
83
+ `mauto init` ships native Agent Skills for **Claude Code**, **Cursor**, **Gemini CLI**, **GitHub Copilot** (`copilot`), and **OpenAI Agents** (`agents`); use `--agent all` to install for all hosts at once. Any other AI agent can drive `mauto` through one of these, no adapter required:
84
+
85
+ - **MCP (recommended):** point your MCP-capable agent at the prompts server — `mauto mcp` (stdio) — which exposes the `generate` / `execute` / `setup` workflows as prompts. Register it like any MCP server: command `mauto`, args `["mcp"]`.
86
+ - **Plain shell:** have the agent read `mauto bootstrap` once (the verb map + invariants), then read `mauto guide <topic>` for a workflow and call the verbs (`mauto elements`, `tap`, `type`, `assert`, …) directly. Every verb returns the `{ok, data, error, hint}` envelope, so the agent can act on results programmatically.
87
+
88
+ Either way the workspace, scenarios, and schemas are identical — the agent is just a different driver for the same `mauto` hands.
89
+
90
+ ---
91
+
92
+ ## 🔀 Platform modes
93
+
94
+ Chosen at `mauto setup` (stored in `mobile-automator/config.json`):
95
+
96
+ | Mode | Best for | Enable |
97
+ |------|----------|--------|
98
+ | **platform-aware** (default) | Single-OS / OS-specific UI | `mauto setup` |
99
+ | **platform-agnostic** | Cross-platform (Flutter, RN, KMP, CMP) | `mauto setup --mode agnostic` |
100
+
101
+ In agnostic mode, OS-shaped gestures become four semantic actions — `press_back`, `dismiss_keyboard`, `grant_permission`, `deny_permission` — resolved to the right native primitive at runtime, so one scenario covers both platforms.
102
+
103
+ ---
104
+
105
+ ## 📋 Command reference
106
+
107
+ Most low-level verbs are called **by the agent**, not by you. The ones you'll run directly are at the top.
108
+
109
+ | Command | What it does |
110
+ |---------|--------------|
111
+ | `mauto init --agent <claude\|cursor\|gemini\|copilot\|agents\|all>` | Install native Agent Skills into each host's skills directory; also writes slash-commands/rules + MCP entry for claude/cursor |
112
+ | `mauto setup [--mode aware\|agnostic]` | Scaffold the `mobile-automator/` workspace + config |
113
+ | `mauto devices` · `devices use <id>` · `devices clear` | List / pin / unpin the target device |
114
+ | `mauto guide <generate\|execute\|setup>` | Print the workflow guidance for a topic |
115
+ | `mauto mcp` | Run the MCP prompts server (for any MCP-capable agent) |
116
+ | `mauto bootstrap` | Print the verb map + invariants (onboarding for any agent) |
117
+ | `mauto memory show [--kind <k>] [--scenario <id>]` | Print cross-session memory (run history auto-harvested from results) as raw markdown for the agent to read at run start |
118
+ | `mauto memory add <text> --kind <app-knowledge\|preferences>` | Record a durable agent-learned app fact or user preference (`[asserted]`, de-duped) |
119
+ | `mauto memory forget --kind <k> --match <substr>` | Remove agent-authored memory entries matching a substring (the correction path) |
120
+ | `mauto validate <file>` | Validate a scenario JSON against the schema |
121
+ | `mauto config get\|set <key> [value]` | Read / update workspace config |
122
+ | **Agent-driven verbs** | `elements`, `tap`, `type`, `swipe`, `press`, `screenshot`, `assert`, `result`, `schema` |
123
+
124
+ Run `mauto <command> --help` for full flags.
125
+
126
+ ---
127
+
128
+ ## 📂 Workspace layout
129
+
130
+ After `mauto setup`:
131
+
132
+ ```
133
+ mobile-automator/
134
+ ├── config.json # mode, environments, project config
135
+ ├── scenarios/ # test scenarios (JSON, schema 2.1)
136
+ ├── screenshots/ # reference screenshots
137
+ └── results/ # execution results
138
+ ```
139
+
140
+ Scenario, result, and config schemas: `mauto schema scenario` / `mauto schema result` / `mauto schema config`.
141
+
142
+ ---
143
+
144
+ ## 🐛 Troubleshooting
145
+
146
+ See **[TROUBLESHOOTING.md](TROUBLESHOOTING.md)**.
147
+
148
+ ## 🤝 Contributing
149
+
150
+ Contributions welcome under Apache 2.0. Good first areas: platform support, assertion types, CI/CD integrations.
151
+
152
+ ## 📄 License
153
+
154
+ [Apache License 2.0](LICENSE).
155
+
156
+ ## 🙏 Acknowledgments
157
+
158
+ - **[mobile-mcp](https://github.com/mobile-next/mobile-mcp)** — the device-automation engine `mauto` wraps.
159
+ - **[Conductor](https://github.com/gemini-cli-extensions/conductor)** — inspired the structured-workflow approach.
160
+
161
+ ---
162
+
163
+ <div align="center">
164
+
165
+ **Built for mobile QA engineers** · [Report a bug](https://github.com/sh3lan93/mobile-automator/issues) · [Request a feature](https://github.com/sh3lan93/mobile-automator/issues)
166
+
167
+ </div>
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Detached entrypoint for the device session daemon. session-spawn.js launches
5
+ // this with the project root / device / idle timeout passed via env vars.
6
+ //
7
+ // Kept dependency-light and side-effect-free at require time so the unit smoke
8
+ // test can load it without spawning a daemon: startDaemon only runs under
9
+ // `require.main === module`.
10
+
11
+ const fs = require('fs');
12
+
13
+ const { startDaemon } = require('../src/device/session-daemon');
14
+ const paths = require('../src/device/session-paths');
15
+
16
+ // Best-effort synchronous unlink — never throws. Used by the crash guards so a
17
+ // hard failure can't leave a stale lock/socket/pidfile wedging the next spawn.
18
+ function safeUnlinkSync(p) {
19
+ try {
20
+ fs.unlinkSync(p);
21
+ } catch (_) {
22
+ /* not present */
23
+ }
24
+ }
25
+
26
+ // Tear down this process's workspace files (lock/socket/pid/handle) on exit —
27
+ // but ONLY if we still own the lock (it holds our pid). This is load-bearing
28
+ // for the spawn race: a loser whose startDaemon threw ELOCKED never acquired
29
+ // the lock, so it must NOT delete the winner's files. Deleting them would free
30
+ // the lock and unlink the live socket path, so the next client re-spawns and a
31
+ // second mobile-mcp child starts — the exact C2/B2 orphan the lock prevents.
32
+ // Gating on the lock's pid also stops a departing owner from nuking a successor
33
+ // that has already taken over the workspace.
34
+ function cleanupWorkspaceIfOwned(projectRoot) {
35
+ let owner;
36
+ try {
37
+ owner = fs.readFileSync(paths.lockPath(projectRoot), 'utf8').trim();
38
+ } catch (_) {
39
+ return; // no lock present — nothing of ours to clean
40
+ }
41
+ if (owner !== String(process.pid)) return; // not our lock — leave the owner's files
42
+ safeUnlinkSync(paths.lockPath(projectRoot));
43
+ safeUnlinkSync(paths.socketPath(projectRoot));
44
+ safeUnlinkSync(paths.pidFilePath(projectRoot));
45
+ safeUnlinkSync(paths.handlePath(projectRoot));
46
+ }
47
+
48
+ async function main() {
49
+ const projectRoot = process.env.MAUTO_SESSION_PROJECT_ROOT;
50
+ if (!projectRoot) {
51
+ process.stderr.write('mauto-session-daemon: MAUTO_SESSION_PROJECT_ROOT is required\n');
52
+ process.exit(3);
53
+ }
54
+ const device = process.env.MAUTO_SESSION_DEVICE || null;
55
+ const idleRaw = process.env.MAUTO_SESSION_IDLE_MS;
56
+ const idleMs = idleRaw ? Number(idleRaw) : undefined;
57
+
58
+ let daemon = null;
59
+
60
+ // Crash guards: this is the real, single-daemon process, so a best-effort
61
+ // teardown on a crash keeps a leaked mobile-mcp child / stale files from
62
+ // wedging the next spawn. (Kept OUT of startDaemon so in-process tests that
63
+ // start many daemons don't accumulate global listeners.)
64
+ // 'exit' allows only synchronous work — drop the lock + socket + pidfile so
65
+ // the next spawn isn't wedged by leftovers from this process.
66
+ const onExit = () => cleanupWorkspaceIfOwned(projectRoot);
67
+ process.on('exit', onExit);
68
+ process.on('uncaughtException', (err) => {
69
+ process.stderr.write(`mauto-session-daemon: uncaught ${err && err.stack ? err.stack : err}\n`);
70
+ // Tear the daemon down (closes the mobile-mcp child) then let 'exit' clean
71
+ // the files.
72
+ if (daemon && typeof daemon.stop === 'function') {
73
+ daemon.stop().catch(() => {});
74
+ }
75
+ process.exit(1);
76
+ });
77
+ process.on('unhandledRejection', (err) => {
78
+ process.stderr.write(`mauto-session-daemon: unhandled rejection ${err && err.stack ? err.stack : err}\n`);
79
+ // Same teardown as uncaughtException: a rejected promise must not leave a
80
+ // leaked mobile-mcp child / stale files wedging the next spawn.
81
+ if (daemon && typeof daemon.stop === 'function') {
82
+ daemon.stop().catch(() => {});
83
+ }
84
+ process.exit(1);
85
+ });
86
+
87
+ daemon = await startDaemon({ projectRoot, device, idleMs });
88
+ // Keep the event loop alive until the daemon stops (idle reap / signal /
89
+ // shutdown frame), then exit cleanly.
90
+ await daemon.whenStopped;
91
+ process.exit(0);
92
+ }
93
+
94
+ if (require.main === module) {
95
+ main().catch((err) => {
96
+ process.stderr.write(`mauto-session-daemon: ${err.message || err}\n`);
97
+ process.exit(1);
98
+ });
99
+ }
100
+
101
+ module.exports = { main, cleanupWorkspaceIfOwned };
package/bin/mauto.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { run, emitFatal } = require('../src/cli');
5
+
6
+ // Final safety net for the envelope contract (#120): a stray async rejection
7
+ // that escapes the in-program boundary still becomes one JSON envelope + the
8
+ // mapped exit code instead of a raw stack trace. Registered here, at the
9
+ // process entry point, so importing/testing the CLI never installs a global
10
+ // exit-on-reject listener.
11
+ process.on('unhandledRejection', emitFatal);
12
+
13
+ run(process.argv);
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "mobile-automator",
3
+ "version": "0.23.8",
4
+ "description": "Host-agnostic mauto CLI for AI-driven mobile QA automation — analyzes a mobile project and drives devices through mobile-mcp.",
5
+ "homepage": "https://github.com/sh3lan93/mobile-automator#readme",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/sh3lan93/mobile-automator.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/sh3lan93/mobile-automator/issues"
12
+ },
13
+ "license": "Apache-2.0",
14
+ "author": "Mobile QA Skills Contributors",
15
+ "bin": {
16
+ "mauto": "bin/mauto.js",
17
+ "mobile-automator": "bin/mauto.js",
18
+ "mauto-session-daemon": "bin/mauto-session-daemon.js"
19
+ },
20
+ "files": [
21
+ "bin/",
22
+ "src/"
23
+ ],
24
+ "scripts": {
25
+ "test": "jest",
26
+ "prepublishOnly": "npm test",
27
+ "test:watch": "jest --watch",
28
+ "test:coverage": "jest --coverage",
29
+ "lint:guides": "jest tests/lint/guide-no-mcp-tool-leak.test.js tests/lint/guide-no-placeholder-leak.test.js tests/lint/guide-agnostic-no-os.test.js tests/lint/guide-artifact-reuse.test.js tests/lint/skill-invariants.test.js tests/lint/skill-no-placeholder-leak.test.js tests/lint/skill-frontmatter-valid.test.js tests/lint/config-schema.test.js tests/lint/result-coverage.test.js",
30
+ "lint:schema-additive": "jest tests/lint/schema-additive.test.js",
31
+ "test:unit": "jest tests/unit",
32
+ "test:integration": "jest tests/integration",
33
+ "test:all": "jest"
34
+ },
35
+ "dependencies": {
36
+ "@mobilenext/mobile-mcp": "0.0.55",
37
+ "@modelcontextprotocol/sdk": "^1.29.0",
38
+ "ajv": "^8.20.0",
39
+ "commander": "^12.1.0"
40
+ },
41
+ "devDependencies": {
42
+ "jest": "^29.7.0",
43
+ "jest-environment-jsdom": "^29.7.0",
44
+ "toml": "^3.0.0"
45
+ },
46
+ "engines": {
47
+ "node": ">=18.0.0"
48
+ }
49
+ }
@@ -0,0 +1,285 @@
1
+ 'use strict';
2
+
3
+ // Pure assertion evaluator.
4
+ //
5
+ // The `mauto guide execute` reasoning floor splits the 27 assertion types into
6
+ // two tiers:
7
+ //
8
+ // Tier 1 — element-list-powered. These are purely MECHANICAL: a deterministic
9
+ // check over the agnostic element list (+ captured variables / a
10
+ // previous snapshot). The CLI owns them.
11
+ //
12
+ // Tier 2 — screenshot + AI visual analysis. These require an agent to judge a
13
+ // screenshot, so the CLI cannot decide pass/fail. We surface them as
14
+ // needs_agent with pass = null.
15
+ //
16
+ // MECHANICAL_TYPES is the verbatim Tier-1 list from that guide section,
17
+ // minus `element_state` whose per-attribute state
18
+ // inspection is not represented in the agnostic element model (no enabled/
19
+ // focused/checked fields survive normalization), so it cannot be decided
20
+ // mechanically here and falls through to needs_agent.
21
+ const MECHANICAL_TYPES = new Set([
22
+ 'element_exists',
23
+ 'element_not_exists',
24
+ 'element_visible',
25
+ 'element_text',
26
+ 'text_contains',
27
+ 'text_not_empty',
28
+ 'element_hint',
29
+ 'pattern_match',
30
+ 'element_count',
31
+ 'list_item_count',
32
+ 'list_is_empty',
33
+ 'content_description',
34
+ 'has_accessibility_label',
35
+ 'value_matches_variable',
36
+ 'text_changed',
37
+ ]);
38
+
39
+ function labelOf(e) {
40
+ return (e && typeof e.accessibility_label === 'string') ? e.accessibility_label : '';
41
+ }
42
+
43
+ // The element's displayed content for content assertions. A real text node
44
+ // wins; when an element carries none (common in agnostic UIs that expose
45
+ // content through Semantics/accessibility labels rather than text), fall back
46
+ // to the accessibility label so content assertions work on label-driven apps
47
+ // instead of always seeing an empty string.
48
+ function textOf(e) {
49
+ if (e && typeof e.text === 'string' && e.text.length > 0) return e.text;
50
+ return labelOf(e);
51
+ }
52
+
53
+ // An element "matches" a target string if the target appears (case-insensitive)
54
+ // in its text or its accessibility label. A null/empty target matches all.
55
+ function matches(e, target) {
56
+ if (target === undefined || target === null || target === '') return true;
57
+ const needle = String(target).toLowerCase();
58
+ return (
59
+ textOf(e).toLowerCase().includes(needle) ||
60
+ labelOf(e).toLowerCase().includes(needle)
61
+ );
62
+ }
63
+
64
+ function findFirst(elements, target) {
65
+ return elements.find((e) => matches(e, target)) || null;
66
+ }
67
+
68
+ function countMatching(elements, target) {
69
+ return elements.filter((e) => matches(e, target)).length;
70
+ }
71
+
72
+ function compare(actual, operator, expected) {
73
+ switch (operator) {
74
+ case '>':
75
+ return actual > expected;
76
+ case '>=':
77
+ return actual >= expected;
78
+ case '<':
79
+ return actual < expected;
80
+ case '<=':
81
+ return actual <= expected;
82
+ case '!=':
83
+ return actual !== expected;
84
+ case '==':
85
+ case '=':
86
+ case undefined:
87
+ case null:
88
+ default:
89
+ return actual === expected;
90
+ }
91
+ }
92
+
93
+ function boolFromFlag(v, dflt = true) {
94
+ if (v === undefined || v === null || v === '') return dflt;
95
+ if (typeof v === 'boolean') return v;
96
+ return String(v).toLowerCase() !== 'false';
97
+ }
98
+
99
+ function result(type, mechanical, pass, message) {
100
+ return {
101
+ type,
102
+ mechanical,
103
+ pass,
104
+ needs_agent: !mechanical,
105
+ message,
106
+ };
107
+ }
108
+
109
+ function needsAgent(type) {
110
+ return result(
111
+ type,
112
+ false,
113
+ null,
114
+ `Assertion '${type}' is a Tier-2 visual check; the agent must judge it from a screenshot.`
115
+ );
116
+ }
117
+
118
+ // Evaluate a single assertion.
119
+ // assertion: { type, target?, expected?, operator?, count?, pattern?, variable_name? }
120
+ // ctx: { elements, variables = {}, previous = null }
121
+ function evaluate(assertion = {}, ctx = {}) {
122
+ const type = assertion.type;
123
+ const elements = Array.isArray(ctx.elements) ? ctx.elements : [];
124
+ const variables = ctx.variables || {};
125
+ const previous = Array.isArray(ctx.previous) ? ctx.previous : null;
126
+
127
+ if (!MECHANICAL_TYPES.has(type)) {
128
+ return needsAgent(type);
129
+ }
130
+
131
+ const target = assertion.target;
132
+ let pass = false;
133
+ let message = '';
134
+
135
+ switch (type) {
136
+ case 'element_exists': {
137
+ const found = findFirst(elements, target);
138
+ pass = Boolean(found);
139
+ message = pass
140
+ ? `Element matching '${target}' is present.`
141
+ : `No element matching '${target}' found on screen.`;
142
+ break;
143
+ }
144
+ case 'element_not_exists': {
145
+ const found = findFirst(elements, target);
146
+ pass = !found;
147
+ message = pass
148
+ ? `Element matching '${target}' is absent, as expected.`
149
+ : `Element matching '${target}' is present but expected to be absent.`;
150
+ break;
151
+ }
152
+ case 'element_visible': {
153
+ const wantVisible = boolFromFlag(assertion.expected, true);
154
+ const found = Boolean(findFirst(elements, target));
155
+ pass = found === wantVisible;
156
+ message = pass
157
+ ? `Element '${target}' visibility=${found} matches expected=${wantVisible}.`
158
+ : `Element '${target}' visibility=${found} but expected=${wantVisible}.`;
159
+ break;
160
+ }
161
+ case 'element_text': {
162
+ const found = findFirst(elements, target);
163
+ const actual = found ? textOf(found) : null;
164
+ pass = actual !== null && actual === assertion.expected;
165
+ message = pass
166
+ ? `Element '${target}' text equals '${assertion.expected}'.`
167
+ : `Element '${target}' text is '${actual}', expected '${assertion.expected}'.`;
168
+ break;
169
+ }
170
+ case 'text_contains': {
171
+ const needle = String(assertion.expected == null ? '' : assertion.expected);
172
+ const scope = assertion.target ? elements.filter((e) => matches(e, assertion.target)) : elements;
173
+ const hit = scope.find((e) => textOf(e).includes(needle));
174
+ pass = Boolean(hit);
175
+ message = pass
176
+ ? `Found text containing '${needle}'.`
177
+ : `No element text contains '${needle}'.`;
178
+ break;
179
+ }
180
+ case 'text_not_empty': {
181
+ const found = findFirst(elements, target);
182
+ pass = Boolean(found) && textOf(found).length > 0;
183
+ message = pass
184
+ ? `Element '${target}' has non-empty text.`
185
+ : `Element '${target}' has empty or missing text.`;
186
+ break;
187
+ }
188
+ case 'element_hint': {
189
+ const found = findFirst(elements, target);
190
+ // The agnostic model has no dedicated hint field; treat text/label as the
191
+ // best available surface for a hint/placeholder match.
192
+ const surface = found ? `${textOf(found)} ${labelOf(found)}` : '';
193
+ const expected = String(assertion.expected == null ? '' : assertion.expected);
194
+ pass = Boolean(found) && surface.includes(expected);
195
+ message = pass
196
+ ? `Element '${target}' hint matches '${expected}'.`
197
+ : `Element '${target}' hint does not match '${expected}'.`;
198
+ break;
199
+ }
200
+ case 'pattern_match': {
201
+ let re;
202
+ try {
203
+ re = new RegExp(assertion.pattern);
204
+ } catch (err) {
205
+ return result(type, true, false, `Invalid regex pattern: ${err.message}`);
206
+ }
207
+ const scope = assertion.target ? elements.filter((e) => matches(e, assertion.target)) : elements;
208
+ pass = scope.some((e) => re.test(textOf(e)));
209
+ message = pass
210
+ ? `At least one element text matches /${assertion.pattern}/.`
211
+ : `No element text matches /${assertion.pattern}/.`;
212
+ break;
213
+ }
214
+ case 'element_count':
215
+ case 'list_item_count': {
216
+ const actual = countMatching(elements, target);
217
+ const expected = Number(assertion.count);
218
+ const operator = assertion.operator || '==';
219
+ pass = compare(actual, operator, expected);
220
+ message = pass
221
+ ? `Count ${actual} ${operator} ${expected} for '${target}'.`
222
+ : `Count ${actual} does not satisfy ${operator} ${expected} for '${target}'.`;
223
+ break;
224
+ }
225
+ case 'list_is_empty': {
226
+ const actual = countMatching(elements, target);
227
+ pass = actual === 0;
228
+ message = pass
229
+ ? `No items matching '${target}' (list empty).`
230
+ : `${actual} item(s) matching '${target}' present (list not empty).`;
231
+ break;
232
+ }
233
+ case 'content_description': {
234
+ const found = findFirst(elements, target);
235
+ const label = found ? labelOf(found) : '';
236
+ const expected = String(assertion.expected == null ? '' : assertion.expected);
237
+ pass = Boolean(found) && (expected === '' ? label.length > 0 : label === expected);
238
+ message = pass
239
+ ? `Element '${target}' content-description matches.`
240
+ : `Element '${target}' content-description '${label}' did not match '${expected}'.`;
241
+ break;
242
+ }
243
+ case 'has_accessibility_label': {
244
+ const found = findFirst(elements, target);
245
+ const label = found ? labelOf(found) : '';
246
+ pass = Boolean(found) && label.length > 0;
247
+ message = pass
248
+ ? `Element '${target}' has a non-empty accessibility label.`
249
+ : `Element '${target}' is missing an accessibility label.`;
250
+ break;
251
+ }
252
+ case 'value_matches_variable': {
253
+ const varName = assertion.variable_name;
254
+ const expected = variables[varName];
255
+ const found = findFirst(elements, target);
256
+ const actual = found ? textOf(found) : null;
257
+ pass = expected !== undefined && actual !== null && actual.includes(String(expected));
258
+ message = pass
259
+ ? `Element '${target}' contains variable '${varName}'=${expected}.`
260
+ : `Element '${target}' text '${actual}' does not match variable '${varName}'=${expected}.`;
261
+ break;
262
+ }
263
+ case 'text_changed': {
264
+ if (!previous) {
265
+ return result(type, true, false, `No previous snapshot to compare '${target}' against.`);
266
+ }
267
+ const curr = findFirst(elements, target);
268
+ const prev = findFirst(previous, target);
269
+ const currText = curr ? textOf(curr) : null;
270
+ const prevText = prev ? textOf(prev) : null;
271
+ pass = currText !== prevText;
272
+ message = pass
273
+ ? `Element '${target}' text changed from '${prevText}' to '${currText}'.`
274
+ : `Element '${target}' text unchanged ('${currText}').`;
275
+ break;
276
+ }
277
+ default:
278
+ // Should be unreachable given the MECHANICAL_TYPES guard above.
279
+ return needsAgent(type);
280
+ }
281
+
282
+ return result(type, true, pass, message);
283
+ }
284
+
285
+ module.exports = { MECHANICAL_TYPES, evaluate };