zerocheck 0.1.7 → 0.1.9

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 (3) hide show
  1. package/README.md +72 -128
  2. package/dist/index.js +234 -57
  3. package/package.json +22 -2
package/README.md CHANGED
@@ -1,180 +1,124 @@
1
1
  # Zerocheck
2
2
 
3
- Zerocheck turns your team’s manual release checklist into repeatable browser tests.
3
+ **Repeatable browser tests from your release checklist**
4
4
 
5
- Paste a checklist, review one drafted check and browser verification result per actionable item, then save readable YAML in your repository. Run those same files locally, in GitHub Actions, or in a hosted browser. Results, recordings, attempts and healing history are available in the web app, with a summary and an authenticated evidence link in your pull request.
5
+ Zerocheck turns your team's manual release checklist into saved end-to-end browser tests. Start with the important user journeys, review the test definitions and browser results, and rerun the checks as your app changes.
6
6
 
7
- ## Setup
7
+ This is the Zerocheck CLI. Use it for repository tests on your laptop or in CI, or connect a coding agent through MCP.
8
8
 
9
- Requires Node.js 20.19 or newer and a Zerocheck project/account. Create a project in the web app’s Settings, then authorize a device or create a project token.
9
+ **New to Zerocheck? [Request pilot access](https://tryzerocheck.com/demo/).** We provision a customer instance and help you configure your first handful of tests during hands-on onboarding.
10
10
 
11
- ```sh
12
- npm install --save-dev --save-exact zerocheck@0.1.7
13
- export ZEROCHECK_API=https://your-instance.example.com
14
- npx zerocheck login
15
- npx zerocheck init --project your-project-id --url http://localhost:3000
16
- npx zerocheck install
17
- ```
11
+ **Already onboarded?** [Run your saved tests](#run-your-saved-tests), or open the instance URL supplied during onboarding to use the web app.
18
12
 
19
- `install` downloads Chromium for local execution. Linux CI can use `zerocheck install --with-deps`. Hosted execution does not require a browser on your machine, but still uses the CLI and your Zerocheck account.
13
+ ## Before you start
20
14
 
21
- Set `ZEROCHECK_API` to your customer instance before login; there is no default public service. Device login stores that instance with its token in `~/.zerocheck/config.json` (mode 0600). A different destination is rejected before any token is sent. Legacy unbound device tokens require logout and a new login. For unattended use, explicitly set both `ZEROCHECK_TOKEN` and `ZEROCHECK_API`; CI never borrows a saved device destination. Instance URLs must be HTTPS origins (HTTP localhost is permitted for development); API redirects are rejected. Never commit tokens or resolved test credentials.
15
+ Test execution requires a Zerocheck account, a provisioned customer instance and a project in that instance. **There is no default public service.**
22
16
 
23
- ## Import your release checklist
24
-
25
- ```sh
26
- npx zerocheck import release-checklist.md --env dev
27
- cat release-checklist.md | npx zerocheck import - --env staging
28
- npx zerocheck import --env dev
29
- ```
17
+ For the CLI, have these ready:
30
18
 
31
- The last command accepts an interactive paste; finish with Ctrl+D on a new line. Import supports text and Markdown. Each item retains its source line and one of these states:
19
+ - **Node.js 20.19 or newer.**
20
+ - **Your instance URL** and account access, supplied during onboarding.
21
+ - **Your project files:** `zerocheck.yaml` with the correct project, environment and app URL, plus at least one saved YAML test in its configured test directory. We set up these files with you during onboarding.
22
+ - **A running app** reachable from your machine, with the test accounts, fixtures and credential variables your checks need.
32
23
 
33
- - **Verified passing:** the exact draft completed in a browser and its expected outcome passed.
34
- - **Check failed:** the requested outcome failed. Review the check and captured result; this alone does not diagnose a product bug.
35
- - **Needs input:** an essential outcome, account, route or fixture is missing, or the item still needs a browser-executable interpretation.
36
- - **Could not run:** execution could not complete; inspect the recorded reason.
24
+ Local execution uses Zerocheck's AI service and uploads selected test definitions, page context and run evidence. It requires network access and authentication even though the browser runs on your machine.
37
25
 
38
- Headings, notes, duplicates and unsupported items remain visible. Import does not silently discard difficult items or weaken a failed expectation.
26
+ ## Run your saved tests
39
27
 
40
- The command prints the exact YAML for review. Save it with the single adoption command displayed at the end, or use `--save` when you want the import to save its displayed drafts immediately:
28
+ Run from the app repository containing the project files above. Replace the instance URL with the one you received during onboarding.
41
29
 
42
- ```sh
43
- npx zerocheck import --draft IMPORT_ID
44
- npx zerocheck import release-checklist.md --env dev --save
30
+ ```bash
31
+ npm install --save-dev --save-exact zerocheck@0.1.9
32
+ export ZEROCHECK_API="https://your-instance.example.com"
33
+ npx zerocheck login
34
+ npx zerocheck install
35
+ npx zerocheck validate
36
+ npx zerocheck run --json .zerocheck/latest-run.json
45
37
  ```
46
38
 
47
- The same `--draft` command adopts a reviewed import from the web app. Drafted checks are saved in a unique import folder beneath the configured test directory. Their YAML bytes and revision are preserved. Existing conflicting files are never overwritten. Partial imports may be saved, but unresolved/failed items remain in the import report and the command exits nonzero.
39
+ `login` prints a URL and code for you to approve in your browser. `install` downloads Chromium for local runs. `validate` checks the saved YAML without running a browser.
48
40
 
49
- Answer missing inputs together in a JSON file keyed by the displayed item IDs:
41
+ `run` executes the saved tests in the configured test directory, using `default_environment` from `zerocheck.yaml`. Confirm that environment points to the app you intend to test. When a results link is printed, open it to inspect outcomes, failed steps and screenshots after signing in. The JSON file also retains the run details. If publication fails, follow the upload-retry command printed by the CLI.
50
42
 
51
- ```json
52
- {"item-3":"Use the existing demo product with SKU DEMO-1; expect a total of $12.00."}
53
- ```
43
+ To run just one test, use its actual repository path:
54
44
 
55
- ```sh
56
- npx zerocheck import --draft IMPORT_ID --answers answers.json
45
+ ```bash
46
+ npx zerocheck run zerocheck/tests/your-test.yaml
57
47
  ```
58
48
 
59
- Only the answered unresolved items are drafted/verified again. To retry a failed item that has no drafted definition, use an empty answer, for example `{"item-3":""}`. Already completed definitions and unselected items are preserved. An existing executable draft is verified unchanged rather than regenerated; missing credentials and configuration or ambiguous-step errors show their respective remedies. Edit a drafted test’s expected behavior as a reviewed YAML change; changed YAML needs a new verification.
49
+ ### Starting from the web app
60
50
 
61
- ## One repository format, named environments
51
+ Open your provisioned instance, choose a saved test and run it against the intended environment. You can use the web app without installing this package.
62
52
 
63
- `zerocheck.yaml` configures the project. It is separate from the readable check files:
53
+ To bring an individual web test into your repository, use **Download YAML** and put that file in the configured test directory. This creates an independent copy. Web tests are edited in the web app; repository tests are edited in their files. The CLI does not automatically download your saved web tests.
64
54
 
65
- ```yaml
66
- project: your-project-id
67
- default_environment: dev
68
- test_directory: zerocheck/tests
69
- environments:
70
- dev:
71
- url: http://localhost:3000
72
- staging:
73
- url: https://staging.example.com
74
- secrets:
75
- EMAIL: ${ZEROCHECK_EMAIL}
76
- PASSWORD: ${ZEROCHECK_PASSWORD}
77
- login_steps:
78
- - Navigate to /login
79
- - Enter "${EMAIL}" into the Email field
80
- - Enter "${PASSWORD}" into the Password field
81
- - Click the Sign in button
82
- - Verify the dashboard is visible
83
- production:
84
- url: https://example.com
85
- execution:
86
- ```
87
-
88
- Only explicitly referenced secret variables are read for execution. Missing variables fail clearly. Listing, reading, saving and validating checks do not require execution secrets.
55
+ For a reviewed checklist import, the CLI can save its drafted tests with `import --draft IMPORT_ID`, as shown below. An import ID is different from an individual saved-test ID or run ID.
89
56
 
90
- Ordinary HTTP(S) redirects and embedded checkout flows need no domain list. A nonempty environment `allowed_origins` list is an optional exact-origin restriction: only your app origin and its listed origins can be interacted with. Omit it or leave it empty for unrestricted browsing; list only your app origin for app-only browsing. Credentials remain restricted separately: `secret_origins` grants each configured credential exact additional destinations during login steps only. If a browser restriction is set, it must also include those login destinations. Hosted private-network protections remain in effect.
57
+ <details>
58
+ <summary><strong>Need to create the project files first?</strong></summary>
91
59
 
92
- Each check is one `zerocheck/v1` YAML file:
60
+ After installing and logging in, replace the project ID and app URL, then initialize a new configuration:
93
61
 
94
- ```yaml
95
- version: zerocheck/v1
96
- name: Release notes are available
97
- blocks_merge: true
98
- steps:
99
- - Navigate to /releases
100
- - Verify the Release notes heading is visible
62
+ ```bash
63
+ npx zerocheck init --project "YOUR_PROJECT_ID" --env dev --url "http://localhost:3000"
101
64
  ```
102
65
 
103
- Repository files are authoritative for CLI-managed tests; tests created and saved in the web app are edited there. Sync cannot overwrite a web-managed definition with different contents. A CLI run uses the selected files’ current contents, and each saved result retains its exact revisions. Empty, missing, duplicate or invalid selections fail before execution; diagnostics list all selected path and YAML errors together. Correct the entire selection before running. Explicit authoring/save supplies an omitted `version`; reading or running an existing file never rewrites it. `run` never invents starter checks.
66
+ `init` creates `zerocheck.yaml` and an empty test directory. If the configuration already exists, edit that file to change its project or environments.
104
67
 
105
- Checks may end with `Verify` or a condition-based `Wait for`; timed waits cannot supply the outcome. `zerocheck validate` prints parsed steps and effective negative defaults without starting a browser. Quote an entire YAML step containing a literal `#`; plain step comments are rejected in test files and `zerocheck.yaml` login steps.
68
+ Add your onboarding-provided test YAML, download an agreed web test, or draft tests from a real release checklist:
106
69
 
107
- Negative quoted-text Verify defaults to bounded phrase matching and a 3-second observation window. `Paid` does not match `Unpaid`; explicit `exact text`, credential comparisons and `for N seconds` retain their precise meaning. New authoring writes defaults explicitly; existing saved bytes/history remain unchanged. Results and PR comments report actual observation mode/window. Unsupported semantic negatives need a quoted-text correction, and visible unreadable frames cannot prove absence. Positive text/URL/request Wait conditions use deterministic checks; semantic Wait still uses AI and supplies no independent target evidence.
70
+ ```bash
71
+ npx zerocheck install
72
+ npx zerocheck import release-checklist.md --env dev
73
+ ```
108
74
 
109
- ## Run and inspect
75
+ Import drafts and runs checks immediately. Review the displayed YAML and browser results before saving. Resolve missing inputs and inspect failed or incomplete checks. Replace `IMPORT_ID` with the import identifier printed by the command:
110
76
 
111
- ```sh
77
+ ```bash
78
+ npx zerocheck import --draft IMPORT_ID
112
79
  npx zerocheck validate
113
80
  npx zerocheck run --env dev
114
- npx zerocheck run zerocheck/tests/release.yaml --env staging --runner hosted
115
- npx zerocheck run --env production --runner local --fail-on-flaky
116
- npx zerocheck run --env staging --json .zerocheck/latest-run.json --junit .zerocheck/junit.xml
117
- npx zerocheck results RUN_ID
118
- npx zerocheck doctor --env staging
119
81
  ```
120
82
 
121
- `local` is the default runner and can reach localhost and private networks available to your machine/CI job. `hosted` uses Zerocheck’s existing hosted browser and can reach targets available to that worker; it cannot reach your laptop’s localhost, and Zerocheck does not create a network tunnel. Runner location, environment and trigger are independent. Local CLI/MCP requires a trusted single-user OS, Actions an isolated job, and hosted pilots a dedicated OS/container boundary without untrusted co-tenants; the browser control endpoint is local but not authenticated. Hosted interruptions produce an explicit error and require a new user-triggered run.
83
+ Saving a draft does not change a failed result into a pass. Keep the expected outcome when investigating a failure.
122
84
 
123
- Each selected check executes once in its own browser and retains its step trace. After a stuck browser is confirmed dead, the next independent check can start; whole-job cancellation or unconfirmed termination stops the selection. Historical records retain every older attempt. A transient grounding failure recovered within a check is reported as **passed after recovery** and marked flaky, even when CI succeeds. Step recovery details remain visible. `--fail-on-flaky` makes blocking flaky checks fail CI. `blocks_merge: false` explicitly makes a check non-blocking; setup errors and incomplete execution still fail the command.
85
+ </details>
124
86
 
125
- Exit codes: **0** completed within the selected merge policy; **1** blocking test failure (or strict flaky result); **2** setup, incomplete execution, infrastructure or result-publication error. A failed upload, including HTTP 401/409, preserves the local browser verdict and evidence, records the publication error separately, and adds a JUnit publication error. It never repeats the browser actions. Import exits nonzero while any actionable item remains unverified.
87
+ ## What a test looks like
126
88
 
127
- Learned targets persist in `.zerocheck/cache`. Local CLI and MCP share this cache; hosted execution uses its own persistent cache. A healed target is separate from a retry, and the result records target changes and their validation. Forms and login do not block target repairs. The engine saves only independently validated targets after a complete unchanged check; model-only evidence leaves the particular repair pending. A different independently proven input can still become reusable when the test also contains a semantic assertion. It never reruns a submission solely to validate healing. Recovery before dispatch stays in the current browser. The runner never restarts a whole check automatically. Provider transport failures can retry once within the remaining deadline; auth/configuration errors and failed expectations do not. Legacy `retries: 0` disables transient grounding retries; pre-dispatch target recovery remains available. Include `.zerocheck/` in `.gitignore`; `init` adds it.
128
-
129
- ## GitHub Actions and PR comments
130
-
131
- ```sh
132
- npx zerocheck init --github-actions
133
- ```
89
+ Each repository test is a readable YAML file. Adapt this example to a route and outcome that exist in your app:
134
90
 
135
- The generated `.github/workflows/zerocheck.yml` pins the CLI version, restores the local target cache, runs staging checks, and runs the reporter and artifact upload even if checks fail. Set the repository secret `ZEROCHECK_TOKEN` and repository variable `ZEROCHECK_API` to the same customer instance, configure staging in `zerocheck.yaml`, and add only the test-secret variables referenced there. For localhost testing, add your app startup and fixture setup before the run step.
136
-
137
- The workflow uses `pull_request`, not `pull_request_target`. It grants `contents: read` and `pull-requests: write`; it does not execute fork code with elevated secrets. Fork or read-only-token jobs keep their job summary and explain why a PR comment could not be posted.
138
-
139
- The comment contains pass/fail/flaky results, failing steps, environment, commit and a link to authenticated full results and private evidence. One Zerocheck bot comment is updated per PR. Results from older commits cannot replace the current comment. Neither report adapter publishes screenshots or recordings. Public artifact links, including old links, are disabled; private evidence is retained. Offline summaries still work when instance setup failed.
140
-
141
- ```sh
142
- npx zerocheck report --file .zerocheck/latest-run.json
91
+ ```yaml
92
+ version: zerocheck/v1
93
+ name: Release notes are available
94
+ blocks_merge: true
95
+ steps:
96
+ - Navigate to /releases
97
+ - Verify the Release notes heading is visible
143
98
  ```
144
99
 
145
- `GITHUB_TOKEN`, `GITHUB_EVENT_PATH` and `GITHUB_STEP_SUMMARY` come from Actions. Outside Actions, supply `--repo owner/repository --pr NUMBER` and `GITHUB_TOKEN`. Without PR context the same formatter produces a job summary.
100
+ ## Coding agents and other runners
146
101
 
147
- The template also supports a manual choice of runner and environment. Uncomment its schedule to run the same checks against staging on weekdays, or edit that explicit environment for a production schedule. Schedules do not enable a separate monitoring product.
102
+ After completing project setup, a compatible coding agent can use the same CLI through its local stdio MCP server:
148
103
 
149
- ## MCP for coding agents
150
-
151
- Use the installed CLI as a stdio MCP server, with an explicit project directory:
152
-
153
- ```json
154
- {
155
- "mcpServers": {
156
- "zerocheck": {
157
- "command": "npx",
158
- "args": ["--yes", "zerocheck@0.1.7", "mcp", "--project-dir", "/absolute/path/to/project"]
159
- }
160
- }
161
- }
104
+ ```bash
105
+ npx zerocheck mcp --project-dir "/absolute/path/to/your/app"
162
106
  ```
163
107
 
164
- Authenticate with `zerocheck login` first or provide both `ZEROCHECK_TOKEN` and `ZEROCHECK_API` through the MCP host’s environment/secret settings. stdout contains only MCP protocol messages; diagnostics use stderr.
165
-
166
- Tools: `list_checks`, `read_check`, `save_check`, `import_checklist`, `answer_import`, `adopt_import`, `verify_check`, `run_checks`, `get_results`, and `cancel`. Existing check replacements require their current `expectedRevision`. File operations stay within the configured project, including symbolic-link checks.
167
-
168
- Browser operations return job handles. Poll `get_results` with `kind: "job"` and `waitMs` up to 30000; inspect the returned run/draft ID for durable retrieval. `kind: "run"` reads a saved run after process restart; `kind: "import"` reads an uploaded import. Cancel with the job ID. Each MCP process permits one active browser job. Closing it cancels that job; cancellation cannot undo actions already performed.
169
-
170
- ## Data sent to Zerocheck
108
+ Configure the process in your MCP client using the [MCP guide](https://tryzerocheck.com/docs/mcp/). Device sign-in needs your approval. The MCP tools can inspect, draft, save and run tests, then read their results.
171
109
 
172
- Local execution sends selected check YAML, the page context needed for AI (including screenshots/accessibility content), and run results/artifacts to Zerocheck. The browser still runs on your machine. Hosted execution additionally sends the URL, login steps and explicitly configured test secrets needed to run the check. The CLI never uploads your entire process environment. Browser screenshots may contain visible application data. `report` links to authenticated evidence without creating public artifact URLs. Hosted server-owned credential references must use `ZEROCHECK_TEST_*`; locally resolved references may use other explicitly chosen test-variable names.
110
+ For hosted execution, add `--runner hosted` to `run`; Chromium installation on your machine is unnecessary for that runner. The hosted browser must be able to reach the configured app URL. It cannot automatically reach your laptop's localhost. Hosted CLI runs still use repository configuration and test files.
173
111
 
174
- ## Repository development
112
+ ## When a run needs attention
175
113
 
176
- The CLI bundles the shared engine source into `dist/index.js`; it has no unpublished engine-package dependency. From this repository, run `npm run build`, `npm run check`, and `npm test` in `packages/cli`. Tests include a real MCP client communicating with the bundled CLI over stdio. The generated workflow and documentation use version `0.1.7`; update that pin together with the package version for a release.
114
+ | Situation | Next step |
115
+ | --- | --- |
116
+ | No instance configured | Set `ZEROCHECK_API` to your customer instance before login. |
117
+ | No configuration or tests found | Add the project configuration and saved YAML tests. `init` creates an empty test directory. |
118
+ | Missing test credentials | Supply the variables referenced by your configured environment. |
119
+ | A test fails or cannot complete | Open the result and inspect the failed step and evidence. |
120
+ | Result publication fails | Follow the upload-retry command printed by the CLI. |
177
121
 
178
- Hosted runs and imports return a queued handle when browsers are occupied; CLI and MCP keep polling until they finish. The queue holds 20 waiting jobs for up to five minutes and never automatically resumes interrupted jobs after restart. Recent drafts is available in the web app’s Checks page.
122
+ Exit codes: **0** means the run completed within the configured merge policy; **1** means a blocking failure; **2** means a setup, incomplete-execution, infrastructure or result-publication error.
179
123
 
180
- If a local run finishes but its private upload fails, keep its ID and retry only publication with `zerocheck results <id> --upload`. For a saved local import use `zerocheck import --upload <id>`. These commands preserve the browser verdict and evidence and do not execute tests. They require the original configured instance and project. Reconcile an unresolved hosted run with `zerocheck results <id>` before starting another; for an import use `zerocheck import --status <id>`. These reads do not adopt tests. A lost response is not permission to replay a checkout.
124
+ [Request pilot access](https://tryzerocheck.com/demo/) · [Developer resources](https://tryzerocheck.com/developers/) · [CLI guide](https://tryzerocheck.com/cli/) · [Web app guide](https://tryzerocheck.com/docs/web-app/) · [Results](https://tryzerocheck.com/docs/results/)
package/dist/index.js CHANGED
@@ -7006,7 +7006,7 @@ async function loginCommand() {
7006
7006
  let device;
7007
7007
  try {
7008
7008
  device = await apiRequest("/v1/cli/auth/device", {
7009
- body: { device_name: deviceName, client_version: "0.1.7" },
7009
+ body: { device_name: deviceName, client_version: "0.1.9" },
7010
7010
  authed: false
7011
7011
  });
7012
7012
  } catch (err) {
@@ -7761,6 +7761,32 @@ import { selectors } from "playwright";
7761
7761
 
7762
7762
  // ../../src/agent/dom-operations.ts
7763
7763
  var facts = {
7764
+ /** Bounded observation preparation, not an authored action. Never scroll a
7765
+ * hidden/clip axis: scrollIntoView would expose intentionally hidden content. */
7766
+ revealForObservation: (target) => {
7767
+ if (!target.isConnected) throw new Error("Text target detached during observation.");
7768
+ const win = target.ownerDocument.defaultView;
7769
+ const ancestors = [];
7770
+ for (let node = target; node; node = node.parentElement ?? node.getRootNode()?.host) {
7771
+ const style = win.getComputedStyle(node);
7772
+ if (style.display === "none" || style.visibility !== "visible" || Number(style.opacity) === 0 || style.contentVisibility === "hidden") return false;
7773
+ ancestors.push(node);
7774
+ }
7775
+ let moved = false;
7776
+ for (const node of ancestors.slice(1)) {
7777
+ if (["HTML", "BODY"].includes(node.tagName)) continue;
7778
+ const style = win.getComputedStyle(node), box = node.getBoundingClientRect(), rect = target.getBoundingClientRect();
7779
+ const left = box.left + node.clientLeft, top = box.top + node.clientTop;
7780
+ const x = rect.left < left ? rect.left - left : Math.max(0, rect.right - left - node.clientWidth);
7781
+ const y = rect.top < top ? rect.top - top : Math.max(0, rect.bottom - top - node.clientHeight);
7782
+ const beforeX = node.scrollLeft, beforeY = node.scrollTop;
7783
+ const scrollX = /^(auto|scroll)$/.test(style.overflowX) && node.scrollWidth > node.clientWidth;
7784
+ const scrollY = /^(auto|scroll)$/.test(style.overflowY) && node.scrollHeight > node.clientHeight;
7785
+ if (scrollX && x || scrollY && y) node.scrollTo({ left: beforeX + (scrollX ? x : 0), top: beforeY + (scrollY ? y : 0), behavior: "instant" });
7786
+ moved ||= node.scrollLeft !== beforeX || node.scrollTop !== beforeY;
7787
+ }
7788
+ return moved;
7789
+ },
7764
7790
  /** Native geometry shared by text and embedded-frame observations. Partial
7765
7791
  * clipping cannot establish a complete text node or a frame's contents. */
7766
7792
  perceptibleBounds: (node, suppliedRect) => {
@@ -7820,7 +7846,20 @@ var facts = {
7820
7846
  const range = doc.createRange();
7821
7847
  range.selectNodeContents(node);
7822
7848
  const rects = Array.from(range.getClientRects());
7823
- const states = rects.map((rect) => nativeFacts.perceptibleBounds(node.parentElement, rect));
7849
+ const parent = node.parentElement, style = win.getComputedStyle(parent);
7850
+ const lineHeight = parseFloat(style.lineHeight), fontSize = parseFloat(style.fontSize);
7851
+ let plain = rects.length === 1 && parent.childElementCount === 0 && style.writingMode === "horizontal-tb" && style.clip === "auto" && style.clipPath === "none" && lineHeight >= fontSize;
7852
+ for (let owner = parent; plain && owner; owner = owner.parentElement ?? owner.getRootNode()?.host) {
7853
+ const outer = win.getComputedStyle(owner);
7854
+ if (outer.transform !== "none" || outer.zoom !== "1") plain = false;
7855
+ }
7856
+ const box = parent.getBoundingClientRect();
7857
+ const contentTop = box.top + parent.clientTop + parseFloat(style.paddingTop);
7858
+ const contentHeight = parent.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
7859
+ const states = rects.map((rect) => nativeFacts.perceptibleBounds(
7860
+ parent,
7861
+ plain && contentHeight >= lineHeight && rect.height >= lineHeight ? { left: rect.left, right: rect.right, top: Math.max(rect.top, contentTop), bottom: Math.min(rect.bottom, contentTop + contentHeight) } : rect
7862
+ ));
7824
7863
  if (states.includes("partial") || states.includes("visible") && states.includes("hidden")) throw new Error("Partly clipped text is inconclusive.");
7825
7864
  if (states.length && states.every((state) => state === "visible")) parts.push(node.textContent);
7826
7865
  return;
@@ -8070,6 +8109,7 @@ var facts = {
8070
8109
  }
8071
8110
  return "";
8072
8111
  },
8112
+ focusWrapper: (element) => element.hasAttribute("tabindex") && !element.isContentEditable && !element.matches('button,a[href],input,select,textarea,summary,[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="switch"],[role="tab"],[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"],[role="option"],[role="combobox"],[role="textbox"],[role="searchbox"],[role="spinbutton"]') && !element.hasAttribute("onclick"),
8073
8113
  decorations: (element) => {
8074
8114
  const el = element;
8075
8115
  const values = [];
@@ -8135,6 +8175,13 @@ var facts = {
8135
8175
  mutationPossible: false
8136
8176
  };
8137
8177
  },
8178
+ ordinalCollectionLabel: (element) => {
8179
+ const item = element.closest('li,[role="listitem"],tr,[role="row"],[role="option"],[role="treeitem"]');
8180
+ const collection = item?.parentElement?.closest('ol,ul,table,[role="list"],[role="grid"],[role="table"],[role="listbox"],[role="tree"]');
8181
+ if (!collection) return "";
8182
+ const ids = String(collection.getAttribute("aria-labelledby") || "").trim().split(/\s+/).filter(Boolean);
8183
+ return (ids.length ? ids.map((id) => collection.ownerDocument.getElementById(id)?.textContent || "").join(" ") : collection.getAttribute("aria-label") || "").replace(/\s+/g, " ").trim();
8184
+ },
8138
8185
  progressState: (element) => {
8139
8186
  const el = element;
8140
8187
  return {
@@ -8148,30 +8195,70 @@ var facts = {
8148
8195
  progressBoundary: (element) => element.matches('form,article,aside,nav,header,footer,tr,li,[role="form"],[role="dialog"],[role="region"],[role="row"],[role="listitem"],[role="complementary"],[role="navigation"],[role="banner"],[role="contentinfo"],[data-record-id],[data-row-id],[data-entity-id],[data-item-id],[aria-label],[aria-labelledby]'),
8149
8196
  progressView: (scope, _input, nativeFacts) => {
8150
8197
  if (!scope.isConnected) throw new Error("The active view detached during progress observation.");
8151
- const controls = [];
8152
- for (const node of scope.querySelectorAll('input:not([type="hidden"],[type="submit"],[type="button"],[type="reset"]),select,textarea,[role="textbox"],[role="combobox"],[role="checkbox"],[role="radio"],[role="switch"]')) {
8153
- let foreignOwner = false;
8198
+ const unknown2 = { fields: [], fieldless: false, ready: false };
8199
+ const owner = nativeFacts.readiness(scope, { ownSelectAX: false });
8200
+ if (!owner.active || owner.moving) return unknown2;
8201
+ const ownership = { owns(node) {
8154
8202
  for (let parent = node.parentElement; parent && parent !== scope; parent = parent.parentElement) {
8155
- if (nativeFacts.progressBoundary(parent)) {
8156
- foreignOwner = true;
8157
- break;
8158
- }
8203
+ if (nativeFacts.progressBoundary(parent)) return false;
8159
8204
  }
8160
- if (foreignOwner) continue;
8205
+ return scope.contains(node);
8206
+ } };
8207
+ const fields = [];
8208
+ let fieldCount = 0;
8209
+ for (const node of scope.querySelectorAll('input:not([type="hidden"],[type="submit"],[type="button"],[type="reset"]),select,textarea,[contenteditable=""],[contenteditable="true" i],[contenteditable="plaintext-only" i],[role="textbox"],[role="searchbox"],[role="spinbutton"],[role="combobox"],[role="checkbox"],[role="radio"],[role="switch"]')) {
8210
+ if (!ownership.owns(node)) continue;
8161
8211
  const readiness = nativeFacts.readiness(node, { ownSelectAX: false });
8162
- if (readiness.moving) return [];
8212
+ if (readiness.moving) return unknown2;
8163
8213
  if (!readiness.active) continue;
8164
8214
  const visibility = nativeFacts.perceptibleBounds(node);
8165
- if (visibility === "partial") return [];
8215
+ if (visibility === "partial") return unknown2;
8166
8216
  if (visibility !== "visible") continue;
8217
+ fieldCount++;
8167
8218
  const identity = nativeFacts.identity(node);
8168
- if (!identity || !identity.name) continue;
8169
- controls.push(JSON.stringify([identity.role, identity.name.replace(/\s+/g, " ").trim(), identity.inputType]));
8219
+ if (identity?.name) fields.push(JSON.stringify([identity.role, identity.name.replace(/\s+/g, " ").trim(), identity.inputType]));
8220
+ }
8221
+ const headings = [];
8222
+ const status = '[role="alert"],[role="status"],[role="log"],[role="timer"],[role="marquee"],[aria-live]:not([aria-live="off"])';
8223
+ for (const node of scope.querySelectorAll('h1,h2,h3,h4,h5,h6,[role="heading"]')) {
8224
+ if (!ownership.owns(node) || node.parentElement?.closest(status)) continue;
8225
+ const readiness = nativeFacts.readiness(node, { ownSelectAX: false });
8226
+ if (!readiness.active && !readiness.moving) continue;
8227
+ const visibility = nativeFacts.perceptibleBounds(node);
8228
+ if (visibility === "hidden" && !readiness.moving) continue;
8229
+ const eligible = !node.matches(status) && !node.querySelector(status);
8230
+ let text = "", ready = !readiness.moving && visibility === "visible";
8231
+ if (eligible && ready) {
8232
+ try {
8233
+ text = nativeFacts.perceptibleText(node, void 0, nativeFacts).replace(/\s+/g, " ").trim();
8234
+ } catch (error2) {
8235
+ if (error2 instanceof Error && /clipp/.test(error2.message)) ready = false;
8236
+ else throw error2;
8237
+ }
8238
+ }
8239
+ headings.push({ node, level: Number(node.getAttribute("aria-level") || node.localName.match(/^h([1-6])$/)?.[1] || 2), text, eligible, ready });
8170
8240
  }
8171
- return controls;
8241
+ const labelled = String(scope.getAttribute("aria-labelledby") || "").split(/\s+/).filter(Boolean);
8242
+ const explicit = headings.filter((heading) => labelled.includes(heading.node.id));
8243
+ const principal = labelled.length ? explicit : headings.filter((heading) => heading.level === Math.min(...headings.map((candidate) => candidate.level)));
8244
+ const chosen = principal.length === 1 && principal[0].eligible ? principal[0] : void 0;
8245
+ return { fields, fieldless: fieldCount === 0, ready: chosen?.ready ?? true, ...chosen?.ready && chosen.text ? { heading: chosen.text, headingLevel: chosen.level } : {} };
8172
8246
  }
8173
8247
  };
8174
8248
  var elements = {
8249
+ actionDescendants: (element) => Array.from(element.querySelectorAll('button,a[href],input,select,textarea,summary,[contenteditable],[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="switch"],[role="tab"],[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"],[role="option"],[role="combobox"],[role="textbox"],[role="searchbox"],[role="spinbutton"],[tabindex],[onclick]')),
8250
+ ordinalItems: (element) => {
8251
+ const itemSelector = 'li,[role="listitem"],tr,[role="row"],[role="option"],[role="treeitem"]';
8252
+ const collectionSelector = 'ol,ul,table,[role="list"],[role="grid"],[role="table"],[role="listbox"],[role="tree"]';
8253
+ const item = element.closest(itemSelector);
8254
+ const collection = item?.parentElement?.closest(collectionSelector);
8255
+ if (!collection) return [];
8256
+ return Array.from(collection.querySelectorAll(itemSelector)).filter((node) => {
8257
+ if (node.parentElement?.closest(collectionSelector) !== collection) return false;
8258
+ for (let parent = node.parentElement; parent && parent !== collection; parent = parent.parentElement) if (parent.matches(itemSelector)) return false;
8259
+ return true;
8260
+ });
8261
+ },
8175
8262
  containingLabel: (control) => control.isConnected ? control.closest("label") : null,
8176
8263
  selects: (root) => {
8177
8264
  if (root.localName === "form") return Array.from(root.elements).filter((node) => node.localName === "select");
@@ -8245,7 +8332,7 @@ var elements = {
8245
8332
  const root = el.getRootNode();
8246
8333
  return form ? Array.from((root.documentElement ?? root).querySelectorAll("button,input")).find((control) => control.form === form && ["submit", "image"].includes(control.type)) ?? null : null;
8247
8334
  },
8248
- progressScope: (element) => {
8335
+ progressScope: (element, _input, nativeFacts) => {
8249
8336
  if (element.form && !element.form.contains(element)) return element.form;
8250
8337
  let fieldScope = null;
8251
8338
  for (let node = element.parentElement; node && !["BODY", "HTML"].includes(node.tagName); node = node.parentElement) {
@@ -8253,6 +8340,10 @@ var elements = {
8253
8340
  if (node.hasAttribute("aria-label") || node.hasAttribute("aria-labelledby")) return fieldScope ?? node;
8254
8341
  if (node.querySelector(':scope > h1,:scope > h2,:scope > h3,:scope > h4,:scope > [role="heading"],:scope > header > h1,:scope > header > h2,:scope > header > h3')) return fieldScope ?? node;
8255
8342
  if (!fieldScope && node.querySelector('input,select,textarea,[role="textbox"],[role="combobox"],[role="checkbox"],[role="radio"]')) fieldScope = node;
8343
+ if (Array.from(node.querySelectorAll('h1,h2,h3,h4,h5,h6,[role="heading"]')).some((heading) => {
8344
+ for (let parent = heading.parentElement; parent && parent !== node; parent = parent.parentElement) if (nativeFacts.progressBoundary(parent)) return false;
8345
+ return true;
8346
+ })) return fieldScope ?? node;
8256
8347
  }
8257
8348
  return fieldScope;
8258
8349
  },
@@ -8600,7 +8691,11 @@ var TargetProgress = class {
8600
8691
  if (await read(() => signature(handle)) !== prior.signature) return;
8601
8692
  if (prior.scope && await read(() => readDOM(prior.scope, "connected"))) {
8602
8693
  const current = await read(() => readDOM(prior.scope, "progressView"));
8603
- if (prior.view.some((control) => !current.includes(control)) && current.some((control) => !prior.view.includes(control))) return;
8694
+ if (prior.view.ready && current.ready) {
8695
+ const fieldsChanged = prior.view.fields.some((control) => !current.fields.includes(control)) && current.fields.some((control) => !prior.view.fields.includes(control));
8696
+ const fieldlessTransition = (prior.view.fieldless || current.fieldless) && prior.view.heading && current.heading && prior.view.headingLevel === current.headingLevel && prior.view.heading !== current.heading;
8697
+ if (fieldsChanged || fieldlessTransition) return;
8698
+ }
8604
8699
  }
8605
8700
  await delay(Math.min(50, Math.max(1, deadline - Date.now())), void 0, { signal });
8606
8701
  }
@@ -8611,7 +8706,7 @@ var TargetProgress = class {
8611
8706
  try {
8612
8707
  const scopeValue = await selectDOM(handle, "progressScope");
8613
8708
  scope = scopeValue ?? void 0;
8614
- return { handle: duplicate, ...scope ? { scope } : {}, signature: await signature(handle), view: scope ? await readDOM(scope, "progressView") : [], lineNumber, entity };
8709
+ return { handle: duplicate, ...scope ? { scope } : {}, signature: await signature(handle), view: scope ? await readDOM(scope, "progressView") : { fields: [], fieldless: false, ready: false }, lineNumber, entity };
8615
8710
  } catch (error2) {
8616
8711
  await duplicate.dispose();
8617
8712
  await scope?.dispose();
@@ -16993,6 +17088,7 @@ async function waitForVisibleText(page, assertion, timeoutMs, signal, targetPoli
16993
17088
  for (let current = frame; current.parentFrame(); current = current.parentFrame()) {
16994
17089
  const owner = await read(() => current.frameElement());
16995
17090
  try {
17091
+ await read(() => readDOM(owner, "revealForObservation"));
16996
17092
  const state = await read(() => readDOM(owner, "perceptibleBounds"));
16997
17093
  if (state === "hidden") {
16998
17094
  hiddenOwner = true;
@@ -17015,6 +17111,7 @@ async function waitForVisibleText(page, assertion, timeoutMs, signal, targetPoli
17015
17111
  const handle = await read(() => locator.nth(index).elementHandle());
17016
17112
  if (!handle) throw new Error("Text target detached during observation.");
17017
17113
  try {
17114
+ await read(() => readDOM(handle, "revealForObservation"));
17018
17115
  const text = await read(() => readDOM(handle, "perceptibleText"));
17019
17116
  if (assertion.match === "phrase" ? phrasePattern(assertion.text).test(text) : text === assertion.text.replace(/\s+/g, " ").trim()) {
17020
17117
  found = true;
@@ -17927,7 +18024,13 @@ async function bindIntendedTarget(handle, step, identity, policy) {
17927
18024
  if (!frame) throw new TargetChangedError("Target detached before authored binding.");
17928
18025
  const authored = targetInstruction(step);
17929
18026
  const explicitSelector = explicitRoleTarget(authored.replace(/^(["'])(.*)\1$/, "$2"));
17930
- const instruction = explicitSelector ? JSON.stringify(explicitSelector.name) : authored;
18027
+ let instruction = explicitSelector ? JSON.stringify(explicitSelector.name) : authored;
18028
+ let tiers;
18029
+ if (hasAuthoredOrdinal(instruction) && literalTargetName(instruction) === void 0) {
18030
+ tiers = await bindingFrames(frame, policy);
18031
+ const whole = normalizeTargetName(instruction);
18032
+ if (await observedLiteralName(tiers, whole, step)) instruction = JSON.stringify(whole);
18033
+ }
17931
18034
  const fullName = normalizeTargetName(instruction);
17932
18035
  const localName = !identity.name && identity.textEntry ? await readDOM(handle, "localLabel") : "";
17933
18036
  const name = (identity.name || localName).trim().replace(/\s+/g, " ").toLowerCase();
@@ -17946,12 +18049,20 @@ async function bindIntendedTarget(handle, step, identity, policy) {
17946
18049
  const role = ["field", "input", "box"].includes(authoredRole ?? "") ? "textbox" : authoredRole === "dropdown" ? "combobox" : authoredRole ?? (step.keyword === "enter" ? "textbox" : step.keyword === "select" ? "combobox" : void 0);
17947
18050
  const clickFamily = !explicitSelector && step.keyword === "click" && ["button", "link"].includes(role ?? "");
17948
18051
  if (!targetRolesMatch(role, identity, clickFamily)) throw new SelectedTargetMismatchError("Ambiguous target: the authored control role does not match.");
17949
- const authoredName = anchored?.name ?? literalTargetName(qualifier?.rawControl ?? instruction);
18052
+ let authoredName = anchored?.name ?? literalTargetName(qualifier?.rawControl ?? instruction);
18053
+ if (ordinal && authoredName === void 0) {
18054
+ tiers ??= await bindingFrames(frame, policy);
18055
+ if (await observedLiteralName(tiers, control, step, role)) authoredName = control;
18056
+ }
17950
18057
  const literal3 = authoredName !== void 0 || control === name;
17951
- if (ordinal && !literal3) throw new SelectedTargetMismatchError("The authored ordinal control name does not match.");
18058
+ if (ordinal && !literal3) await assertDescriptiveOrder(handle, ordinal, identity.role, control);
17952
18059
  const controlName = roleOnly ? void 0 : authoredName;
17953
18060
  if (!literal3 && !roleOnly && step.keyword !== "act" && scanQuotedText(instruction).spans.length > 0) throw new SelectedTargetMismatchError("Ambiguous target: the authored control name does not match.");
17954
- if (!literal3 && !roleOnly && !qualifier) return identity;
18061
+ if (!literal3 && !roleOnly && !qualifier) {
18062
+ const unbound = { ...identity };
18063
+ delete unbound.binding;
18064
+ return unbound;
18065
+ }
17955
18066
  const observedName = authoredName ?? (identity.name.includes("[redacted]") ? (await readTargetIdentity(handle, [], Number.MAX_SAFE_INTEGER)).name : identity.name || localName);
17956
18067
  const escapedName = observedName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17957
18068
  const queryName = new RegExp(`^[\\s\uE000-\uF8FF\u2190-\u21FF\u27F0-\u27FF\u2900-\u297F]*${escapedName}[\\s\uE000-\uF8FF\u2190-\u21FF\u27F0-\u27FF\u2900-\u297F]*$`, "i");
@@ -17961,28 +18072,7 @@ async function bindIntendedTarget(handle, step, identity, policy) {
17961
18072
  let selected = false;
17962
18073
  let rejected = false;
17963
18074
  let ordered = 0;
17964
- const primary = [], embedded = [];
17965
- const requiredFrames = [];
17966
- for (let current = frame; current; current = current.parentFrame()) requiredFrames.push(current);
17967
- const topOrigin = policy ? await frameOrigin(frame.page().mainFrame()) : void 0;
17968
- for (const candidateFrame of policy || anchored ? frame.page().frames() : [frame]) {
17969
- try {
17970
- if (!await isFrameVisible(candidateFrame)) continue;
17971
- } catch (error2) {
17972
- if (!(error2 instanceof NativeFrameDetachedError)) throw error2;
17973
- if (requiredFrames.some((required) => required.isDetached())) throw new TargetChangedError("The selected target frame detached before authored binding.");
17974
- continue;
17975
- }
17976
- if (policy && (await blockedFrameOrigins(candidateFrame, policy)).length) continue;
17977
- let sameChain = true;
17978
- if (policy) {
17979
- for (let ancestor = candidateFrame; ancestor; ancestor = ancestor.parentFrame()) if (await frameOrigin(ancestor) !== topOrigin) {
17980
- sameChain = false;
17981
- break;
17982
- }
17983
- }
17984
- (sameChain ? primary : embedded).push(candidateFrame);
17985
- }
18075
+ const [primary, embedded] = tiers ?? await bindingFrames(frame, policy, Boolean(anchored));
17986
18076
  for (const tier of [primary, embedded]) {
17987
18077
  for (const candidateFrame of tier) {
17988
18078
  const nativeRole = ["button", "link", "textbox", "combobox", "checkbox", "radio", "switch", "tab", "menuitem", "menuitemcheckbox", "menuitemradio", "option", "searchbox", "spinbutton", "img"].includes(identity.role);
@@ -18018,6 +18108,7 @@ async function bindIntendedTarget(handle, step, identity, policy) {
18018
18108
  const isSelected = candidateFrame === frame && await candidate.evaluate((element, original) => element === original, handle);
18019
18109
  if (match === "conflict" && !isSelected) continue;
18020
18110
  if (literal3 && !roleOnly && !await nativeNameMatches(candidate, observedName, identity.role, Boolean(identity.textEntry))) continue;
18111
+ if (literal3 && step.keyword === "click" && await isDuplicateFocusWrapper(candidate, observedName)) continue;
18021
18112
  if (match === "conflict") {
18022
18113
  rejected = true;
18023
18114
  continue;
@@ -18076,6 +18167,96 @@ async function nativeNameMatches(handle, expected, role, textEntry) {
18076
18167
  }
18077
18168
  return normalize(name) === normalize(expected);
18078
18169
  }
18170
+ async function observedLiteralName(tiers, name, step, role) {
18171
+ if (!name) return false;
18172
+ for (const tier of tiers) for (const frame of tier) {
18173
+ const candidates = await frame.locator('button,a[href],input,select,textarea,summary,[contenteditable],[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="switch"],[role="tab"],[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"],[role="option"],[role="combobox"],[role="textbox"],[role="searchbox"],[role="spinbutton"],[tabindex],[onclick]').elementHandles();
18174
+ try {
18175
+ for (const candidate of candidates) {
18176
+ if (!(await targetReadiness(candidate)).active) continue;
18177
+ const identity = await readTargetIdentity(candidate);
18178
+ if (step.keyword === "click" && !identity.interactive || step.keyword === "enter" && !identity.textEntry || step.keyword === "select" && identity.role !== "combobox") continue;
18179
+ if (!targetRolesMatch(role, identity, step.keyword === "click" && ["button", "link"].includes(role ?? ""))) continue;
18180
+ if (await nativeNameMatches(candidate, name, identity.role, Boolean(identity.textEntry)) && !await isDuplicateFocusWrapper(candidate, name)) return true;
18181
+ }
18182
+ } finally {
18183
+ await Promise.all(candidates.map((candidate) => candidate.dispose()));
18184
+ }
18185
+ }
18186
+ return false;
18187
+ }
18188
+ async function focusWrapperWithoutDeclaredActivation(handle) {
18189
+ return await readDOM(handle, "focusWrapper") && !await handle.evaluate((element) => typeof element.onclick === "function");
18190
+ }
18191
+ async function isDuplicateFocusWrapper(handle, name) {
18192
+ if (!await focusWrapperWithoutDeclaredActivation(handle)) return false;
18193
+ const descendants = await selectAllDOM(handle, "actionDescendants");
18194
+ let matches = 0;
18195
+ try {
18196
+ for (const child of descendants) {
18197
+ if (!(await targetReadiness(child)).active || await focusWrapperWithoutDeclaredActivation(child)) continue;
18198
+ const identity = await readTargetIdentity(child);
18199
+ if (identity.interactive && await nativeNameMatches(child, name, identity.role, Boolean(identity.textEntry))) matches++;
18200
+ }
18201
+ return matches === 1;
18202
+ } finally {
18203
+ await Promise.all(descendants.map((child) => child.dispose()));
18204
+ }
18205
+ }
18206
+ async function assertDescriptiveOrder(handle, ordinal, role, description) {
18207
+ const label = await readDOM(handle, "ordinalCollectionLabel");
18208
+ if (!label || label.trim().replace(/\s+/g, " ").toLowerCase() !== description) return;
18209
+ const items = await selectAllDOM(handle, "ordinalItems");
18210
+ let count = 0;
18211
+ let selected = 0;
18212
+ try {
18213
+ for (const item of items) {
18214
+ if (!(await targetReadiness(item)).active) continue;
18215
+ const descendants = await selectAllDOM(item, "actionDescendants");
18216
+ const matches = [];
18217
+ try {
18218
+ for (const candidate of [item, ...descendants]) {
18219
+ if (!(await targetReadiness(candidate)).active || await focusWrapperWithoutDeclaredActivation(candidate)) continue;
18220
+ const identity = await readTargetIdentity(candidate);
18221
+ if (identity.role === role && identity.interactive) matches.push(candidate);
18222
+ }
18223
+ if (matches.length !== 1) return;
18224
+ count++;
18225
+ if (await sameDOMNode(matches[0], handle)) selected = count;
18226
+ } finally {
18227
+ await Promise.all(descendants.map((candidate) => candidate.dispose()));
18228
+ }
18229
+ }
18230
+ if (selected && selected !== ordinal) throw new SelectedTargetMismatchError("Ambiguous target: the selected control contradicts the authored ordinal in its collection.");
18231
+ } finally {
18232
+ await Promise.all(items.map((item) => item.dispose()));
18233
+ }
18234
+ }
18235
+ async function bindingFrames(frame, policy, acrossFrames = false) {
18236
+ const primary = [], embedded = [];
18237
+ const requiredFrames = [];
18238
+ for (let current = frame; current; current = current.parentFrame()) requiredFrames.push(current);
18239
+ const topOrigin = policy ? await frameOrigin(frame.page().mainFrame()) : void 0;
18240
+ for (const candidateFrame of policy || acrossFrames ? frame.page().frames() : [frame]) {
18241
+ try {
18242
+ if (!await isFrameVisible(candidateFrame)) continue;
18243
+ } catch (error2) {
18244
+ if (!(error2 instanceof NativeFrameDetachedError)) throw error2;
18245
+ if (requiredFrames.some((required) => required.isDetached())) throw new TargetChangedError("The selected target frame detached before authored binding.");
18246
+ continue;
18247
+ }
18248
+ if (policy && (await blockedFrameOrigins(candidateFrame, policy)).length) continue;
18249
+ let sameChain = true;
18250
+ if (policy) {
18251
+ for (let ancestor = candidateFrame; ancestor; ancestor = ancestor.parentFrame()) if (await frameOrigin(ancestor) !== topOrigin) {
18252
+ sameChain = false;
18253
+ break;
18254
+ }
18255
+ }
18256
+ (sameChain ? primary : embedded).push(candidateFrame);
18257
+ }
18258
+ return [primary, embedded];
18259
+ }
18079
18260
 
18080
18261
  // ../../src/policy/action-safety.ts
18081
18262
  function evaluateActionSafety(input) {
@@ -20924,7 +21105,12 @@ async function draftItem(item, request, options, draftId, earlier = []) {
20924
21105
  const aligned = assertionIndices.length === explicit.length && explicit.every((outcome, index) => {
20925
21106
  const at = assertionIndices[index];
20926
21107
  const proposed = parseStep(steps[at], at + 1);
20927
- return proposed.keyword === outcome.keyword && equivalentOutcome(normalizeAuthoredOutcome(outcome.argument, outcome.before, steps.slice(0, at)), proposed.argument, outcome.keyword === "wait_for") && (outcome.source.final ? at === steps.length - 1 : checkpointAligned(outcome, steps, at));
21108
+ try {
21109
+ validateOutcomeStep(outcome);
21110
+ } catch {
21111
+ return false;
21112
+ }
21113
+ return proposed.keyword === outcome.keyword && (outcome.source.final ? at === steps.length - 1 : checkpointAligned(outcome, steps, at));
20928
21114
  });
20929
21115
  if (aligned) {
20930
21116
  explicit.forEach((outcome, index) => {
@@ -21017,15 +21203,6 @@ function sourceClauses(text) {
21017
21203
  return { start, boundary, end, raw };
21018
21204
  });
21019
21205
  }
21020
- function equivalentOutcome(expected, proposed, bareText = false) {
21021
- try {
21022
- const a = parseVisibleTextAssertion(expected, bareText), b = parseVisibleTextAssertion(proposed, bareText);
21023
- if (a && b) return JSON.stringify(a) === JSON.stringify(b);
21024
- } catch {
21025
- return false;
21026
- }
21027
- return expected.replace(/\s+/g, " ").replace(/[.!]$/, "").trim() === proposed.replace(/\s+/g, " ").replace(/[.!]$/, "").trim();
21028
- }
21029
21206
  function actionIdentity(raw) {
21030
21207
  const step = parseStep(raw.replace(/[.!]$/, ""), 0);
21031
21208
  if (step.keyword === "act" || isOutcomeStep(step)) return void 0;
@@ -22243,7 +22420,7 @@ jobs:
22243
22420
  node-version: '22'
22244
22421
  - name: Install Zerocheck
22245
22422
  run: |
22246
- npm install --prefix "$RUNNER_TEMP/zerocheck-cli" --no-save --package-lock=false zerocheck@0.1.7
22423
+ npm install --prefix "$RUNNER_TEMP/zerocheck-cli" --no-save --package-lock=false zerocheck@0.1.9
22247
22424
  echo "$RUNNER_TEMP/zerocheck-cli/node_modules/.bin" >> "$GITHUB_PATH"
22248
22425
  - name: Restore learned targets
22249
22426
  uses: actions/cache@v4
@@ -28263,7 +28440,7 @@ var runner = z2.enum(["local", "hosted"]).default("local").describe("Local brows
28263
28440
  var output = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], structuredContent: { result: value } });
28264
28441
  var error = (value) => ({ isError: true, content: [{ type: "text", text: JSON.stringify({ error: "operation_failed", message: value instanceof Error ? value.message : String(value) }) }] });
28265
28442
  function createMcpServer(services) {
28266
- const server = new McpServer({ name: "zerocheck", version: "0.1.7" });
28443
+ const server = new McpServer({ name: "zerocheck", version: "0.1.9" });
28267
28444
  server.registerTool("list_checks", {
28268
28445
  description: "List the repository YAML checks, IDs, exact contents and revisions. Does not execute them.",
28269
28446
  inputSchema: { paths: z2.array(z2.string()).optional(), environment },
@@ -28403,7 +28580,7 @@ async function mcpCommand(options) {
28403
28580
  }
28404
28581
 
28405
28582
  // src/index.ts
28406
- var program = new Command().name("zerocheck").version("0.1.7").description("Turn your team\u2019s manual release checklist into repeatable browser tests.");
28583
+ var program = new Command().name("zerocheck").version("0.1.9").description("Turn your team\u2019s manual release checklist into repeatable browser tests.");
28407
28584
  var directory = (command) => command.option("--project-dir <path>", "Repository project directory", process.cwd());
28408
28585
  var environment2 = (command) => directory(command).addOption(new Option("--env <environment>", "Named project environment").choices(["dev", "staging", "production"]));
28409
28586
  var execution = (command) => environment2(command).addOption(new Option("--runner <runner>", "Browser execution location").choices(["local", "hosted"]).default("local"));
package/package.json CHANGED
@@ -1,7 +1,27 @@
1
1
  {
2
2
  "name": "zerocheck",
3
- "version": "0.1.7",
4
- "description": "Turn manual release checklists into repeatable browser tests locally, in CI, or hosted.",
3
+ "version": "0.1.9",
4
+ "description": "Turn release checklists into repeatable browser tests. Run with the Zerocheck CLI, CI or web app and inspect the evidence.",
5
+ "keywords": [
6
+ "qa-agent",
7
+ "ai-testing",
8
+ "agentic-testing",
9
+ "zerocheck",
10
+ "testing",
11
+ "qa",
12
+ "e2e",
13
+ "end-to-end",
14
+ "browser-testing",
15
+ "test-automation",
16
+ "release-checklist",
17
+ "plain-english",
18
+ "playwright",
19
+ "cli",
20
+ "mcp",
21
+ "ci"
22
+ ],
23
+ "homepage": "https://tryzerocheck.com/",
24
+ "license": "MIT",
5
25
  "type": "module",
6
26
  "repository": {
7
27
  "type": "git",