zerocheck 0.1.7 → 0.1.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 (3) hide show
  1. package/README.md +3 -3
  2. package/dist/index.js +234 -57
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -9,7 +9,7 @@ Paste a checklist, review one drafted check and browser verification result per
9
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.
10
10
 
11
11
  ```sh
12
- npm install --save-dev --save-exact zerocheck@0.1.7
12
+ npm install --save-dev --save-exact zerocheck@0.1.8
13
13
  export ZEROCHECK_API=https://your-instance.example.com
14
14
  npx zerocheck login
15
15
  npx zerocheck init --project your-project-id --url http://localhost:3000
@@ -155,7 +155,7 @@ Use the installed CLI as a stdio MCP server, with an explicit project directory:
155
155
  "mcpServers": {
156
156
  "zerocheck": {
157
157
  "command": "npx",
158
- "args": ["--yes", "zerocheck@0.1.7", "mcp", "--project-dir", "/absolute/path/to/project"]
158
+ "args": ["--yes", "zerocheck@0.1.8", "mcp", "--project-dir", "/absolute/path/to/project"]
159
159
  }
160
160
  }
161
161
  }
@@ -173,7 +173,7 @@ Local execution sends selected check YAML, the page context needed for AI (inclu
173
173
 
174
174
  ## Repository development
175
175
 
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.
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.8`; update that pin together with the package version for a release.
177
177
 
178
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.
179
179
 
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.8" },
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.8
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.8" });
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.8").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,6 +1,6 @@
1
1
  {
2
2
  "name": "zerocheck",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Turn manual release checklists into repeatable browser tests locally, in CI, or hosted.",
5
5
  "type": "module",
6
6
  "repository": {