oh-my-opencode 5.0.0-beta.89 → 5.0.0-beta.90

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 (220) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/README.ru.md +1 -1
  5. package/README.zh-cn.md +1 -1
  6. package/dist/agents/sisyphus/grok-4.d.ts +3 -2
  7. package/dist/cli/config-manager/refresh-opencode-plugin-sandbox.d.ts +31 -0
  8. package/dist/cli/doctor/checks/tui-plugin-config.d.ts +11 -0
  9. package/dist/cli/index.js +1661 -859
  10. package/dist/cli/install-native/index.d.ts +9 -1
  11. package/dist/cli/install-native/legacy-omo-bin.d.ts +27 -0
  12. package/dist/cli/install-native/offer-native-setup.d.ts +31 -0
  13. package/dist/cli/install-native/omo-bin-test-fixtures.d.ts +35 -0
  14. package/dist/cli/install-native/plan.d.ts +8 -0
  15. package/dist/cli/install-native/repair-legacy-omo-bin.d.ts +26 -0
  16. package/dist/cli/install-native/run-native-install.d.ts +11 -1
  17. package/dist/cli/install-native/verify-omo-command.d.ts +22 -0
  18. package/dist/cli/native-edition-hint.d.ts +2 -2
  19. package/dist/cli-node/index.js +1661 -859
  20. package/dist/hooks/auto-update-checker/checker/sandbox-refresh.d.ts +51 -0
  21. package/dist/hooks/auto-update-checker/checker/semver-compare.d.ts +17 -0
  22. package/dist/hooks/auto-update-checker/checker.d.ts +3 -0
  23. package/dist/hooks/auto-update-checker/hook/background-update-check.d.ts +8 -0
  24. package/dist/hooks/auto-update-checker/hook.d.ts +2 -0
  25. package/dist/hooks/native-edition-nudge/hook.d.ts +1 -1
  26. package/dist/index.js +1433 -1055
  27. package/dist/shared/index.d.ts +1 -0
  28. package/dist/shared/opencode-plugin-sandbox.d.ts +54 -0
  29. package/dist/skills/browser/SKILL.md +8 -1
  30. package/dist/skills/browser/references/install.md +34 -5
  31. package/dist/skills/browser/runtime/omowright/index.js +2 -2
  32. package/dist/skills/browser/runtime/omowright/manifest.json +3 -3
  33. package/dist/skills/browser/scripts/browser-doctor.mjs +14 -3
  34. package/dist/skills/browser/scripts/browser-install.mjs +13 -1
  35. package/dist/skills/ulw-research/AGENTS.md +63 -0
  36. package/dist/skills/ulw-research/SKILL.md +38 -26
  37. package/dist/skills/ulw-research/references/deliverable-phase.md +187 -0
  38. package/dist/skills/ulw-research/references/report-gates.md +66 -0
  39. package/dist/skills/ulw-research/scripts/cli-support.mjs +102 -0
  40. package/dist/skills/ulw-research/scripts/cli-support.test.ts +88 -0
  41. package/dist/skills/ulw-research/scripts/contracts.mjs +162 -0
  42. package/dist/skills/ulw-research/scripts/contracts.test.ts +177 -0
  43. package/dist/skills/ulw-research/scripts/css-lite.mjs +204 -0
  44. package/dist/skills/ulw-research/scripts/css-lite.test.ts +159 -0
  45. package/dist/skills/ulw-research/scripts/design-spec.mjs +161 -0
  46. package/dist/skills/ulw-research/scripts/design-spec.test.ts +162 -0
  47. package/dist/skills/ulw-research/scripts/entities.mjs +39 -0
  48. package/dist/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  49. package/dist/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  50. package/dist/skills/ulw-research/scripts/format-extract.mjs +229 -0
  51. package/dist/skills/ulw-research/scripts/format-extract.test.ts +243 -0
  52. package/dist/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  53. package/dist/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  54. package/dist/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
  55. package/dist/skills/ulw-research/scripts/gates-static.mjs +167 -0
  56. package/dist/skills/ulw-research/scripts/gates-static.test.ts +265 -0
  57. package/dist/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  58. package/dist/skills/ulw-research/scripts/gates-text.mjs +190 -0
  59. package/dist/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
  60. package/dist/skills/ulw-research/scripts/html-lite.mjs +239 -0
  61. package/dist/skills/ulw-research/scripts/html-lite.test.ts +191 -0
  62. package/dist/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  63. package/dist/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
  64. package/dist/skills/ulw-research/scripts/outcome.mjs +250 -0
  65. package/dist/skills/ulw-research/scripts/outcome.test.ts +281 -0
  66. package/dist/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  67. package/dist/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
  68. package/dist/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  69. package/dist/skills/ulw-research/scripts/report-tools.mjs +61 -0
  70. package/dist/skills/ulw-research/scripts/report-tools.test.ts +213 -0
  71. package/dist/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
  72. package/dist/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
  73. package/dist/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
  74. package/dist/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
  75. package/dist/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
  76. package/dist/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
  77. package/dist/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
  78. package/dist/skills/visual-qa/references/browser-setup.md +3 -1
  79. package/dist/tui.js +534 -66
  80. package/package.json +15 -15
  81. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  82. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  83. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  84. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  85. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  86. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  87. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  88. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  89. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  90. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  91. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  92. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  93. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +5 -1
  94. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-6.md +5 -1
  95. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  96. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  97. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  98. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  99. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  100. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  101. package/packages/omo-codex/plugin/components/ultrawork/dist/cli.js +7 -3
  102. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  103. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  104. package/packages/omo-codex/plugin/components/ultrawork/src/directive-content.ts +1 -1
  105. package/packages/omo-codex/plugin/components/ulw-execute-continuation/directive.md +1 -1
  106. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  107. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  108. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +7 -3
  109. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
  110. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  111. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  112. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  113. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  114. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  115. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  116. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  117. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  118. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  119. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  120. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  121. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  122. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  123. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  124. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  125. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  126. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  127. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  128. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  129. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  130. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  131. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  132. package/packages/omo-codex/plugin/package-lock.json +12 -12
  133. package/packages/omo-codex/plugin/package.json +1 -1
  134. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +2 -2
  135. package/packages/omo-codex/plugin/skills/browser/SKILL.md +8 -1
  136. package/packages/omo-codex/plugin/skills/browser/references/install.md +34 -5
  137. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/index.js +2 -2
  138. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +3 -3
  139. package/packages/omo-codex/plugin/skills/browser/scripts/browser-doctor.mjs +14 -3
  140. package/packages/omo-codex/plugin/skills/browser/scripts/browser-install.mjs +13 -1
  141. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +7 -3
  142. package/packages/omo-codex/plugin/skills/ulw-research/AGENTS.md +63 -0
  143. package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +37 -25
  144. package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +187 -0
  145. package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +66 -0
  146. package/packages/omo-codex/plugin/skills/ulw-research/scripts/cli-support.mjs +102 -0
  147. package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +162 -0
  148. package/packages/omo-codex/plugin/skills/ulw-research/scripts/css-lite.mjs +204 -0
  149. package/packages/omo-codex/plugin/skills/ulw-research/scripts/design-spec.mjs +161 -0
  150. package/packages/omo-codex/plugin/skills/ulw-research/scripts/entities.mjs +39 -0
  151. package/packages/omo-codex/plugin/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  152. package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  153. package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract.mjs +229 -0
  154. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  155. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  156. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-static.mjs +167 -0
  157. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  158. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-text.mjs +190 -0
  159. package/packages/omo-codex/plugin/skills/ulw-research/scripts/html-lite.mjs +239 -0
  160. package/packages/omo-codex/plugin/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  161. package/packages/omo-codex/plugin/skills/ulw-research/scripts/outcome.mjs +250 -0
  162. package/packages/omo-codex/plugin/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  163. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  164. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools.mjs +61 -0
  165. package/packages/omo-codex/plugin/skills/visual-qa/references/browser-setup.md +3 -1
  166. package/packages/omo-codex/plugin/test/sync-skills-codex-compatibility.test.mjs +11 -0
  167. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +2 -2
  168. package/packages/omo-codex/plugin/test/sync-skills.test.mjs +20 -0
  169. package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
  170. package/packages/prompts-core/prompts/ultrawork/codex.md +7 -3
  171. package/packages/shared-skills/skills/browser/SKILL.md +8 -1
  172. package/packages/shared-skills/skills/browser/references/install.md +34 -5
  173. package/packages/shared-skills/skills/browser/runtime/omowright/index.js +2 -2
  174. package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +3 -3
  175. package/packages/shared-skills/skills/browser/scripts/browser-doctor.mjs +14 -3
  176. package/packages/shared-skills/skills/browser/scripts/browser-install.mjs +13 -1
  177. package/packages/shared-skills/skills/ulw-research/AGENTS.md +63 -0
  178. package/packages/shared-skills/skills/ulw-research/SKILL.md +38 -26
  179. package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +187 -0
  180. package/packages/shared-skills/skills/ulw-research/references/report-gates.md +66 -0
  181. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.mjs +102 -0
  182. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.test.ts +88 -0
  183. package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +162 -0
  184. package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +177 -0
  185. package/packages/shared-skills/skills/ulw-research/scripts/css-lite.mjs +204 -0
  186. package/packages/shared-skills/skills/ulw-research/scripts/css-lite.test.ts +159 -0
  187. package/packages/shared-skills/skills/ulw-research/scripts/design-spec.mjs +161 -0
  188. package/packages/shared-skills/skills/ulw-research/scripts/design-spec.test.ts +162 -0
  189. package/packages/shared-skills/skills/ulw-research/scripts/entities.mjs +39 -0
  190. package/packages/shared-skills/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  191. package/packages/shared-skills/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  192. package/packages/shared-skills/skills/ulw-research/scripts/format-extract.mjs +229 -0
  193. package/packages/shared-skills/skills/ulw-research/scripts/format-extract.test.ts +243 -0
  194. package/packages/shared-skills/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  195. package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  196. package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
  197. package/packages/shared-skills/skills/ulw-research/scripts/gates-static.mjs +167 -0
  198. package/packages/shared-skills/skills/ulw-research/scripts/gates-static.test.ts +265 -0
  199. package/packages/shared-skills/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  200. package/packages/shared-skills/skills/ulw-research/scripts/gates-text.mjs +190 -0
  201. package/packages/shared-skills/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
  202. package/packages/shared-skills/skills/ulw-research/scripts/html-lite.mjs +239 -0
  203. package/packages/shared-skills/skills/ulw-research/scripts/html-lite.test.ts +191 -0
  204. package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  205. package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
  206. package/packages/shared-skills/skills/ulw-research/scripts/outcome.mjs +250 -0
  207. package/packages/shared-skills/skills/ulw-research/scripts/outcome.test.ts +281 -0
  208. package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  209. package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
  210. package/packages/shared-skills/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  211. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.mjs +61 -0
  212. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +213 -0
  213. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
  214. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
  215. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
  216. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
  217. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
  218. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
  219. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
  220. package/packages/shared-skills/skills/visual-qa/references/browser-setup.md +3 -1
@@ -0,0 +1,239 @@
1
+ // html-lite.mjs - a dependency-free HTML tokenizer for report checks.
2
+ //
3
+ // Single forward scan with indexOf (no whole-document regex, no backtracking). Produces a
4
+ // LiteNode tree: { tag, attrs, children, parent, line, text, raw }. Text nodes carry
5
+ // tag "#text"; <style>/<script>/<svg> keep their inner source in `raw` and have no parsed
6
+ // children. The "#document" root also carries `ids` (id -> occurrence count) so
7
+ // selectorPath can tell whether an id is unique. Plain Node ESM, no dependencies.
8
+
9
+ import { decodeEntities } from "./entities.mjs"
10
+
11
+ export { decodeEntities }
12
+
13
+ const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"])
14
+ const RAW_TAGS = new Set(["style", "script", "svg"])
15
+ const HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"]
16
+ const P_CLOSERS = new Set([
17
+ "address", "article", "blockquote", "details", "div", "dl", "fieldset", "figcaption", "figure", "footer",
18
+ "form", "header", "hr", "main", "menu", "nav", "ol", "p", "pre", "section", "table", "ul", ...HEADINGS,
19
+ ])
20
+ const P_SCOPE = new Set(["td", "th", "caption", "button", "body", "html"])
21
+ const LI_SCOPE = new Set(["ul", "ol", "menu", "body", "html"])
22
+ const PROSE_SKIP = new Set(["code", "pre", "kbd", "samp", "script", "style", "svg"])
23
+ const SAFE_ID = /^[A-Za-z_][\w-]*$/
24
+
25
+ export const PROSE_TAGS = new Set(["p", "li", ...HEADINGS, "td", "th", "figcaption", "blockquote", "dd", "dt"])
26
+ const PROSE_BREAKS = new Set([...P_CLOSERS, ...PROSE_TAGS, "br", "tr"])
27
+
28
+ function makeNode(tag, attrs, parent, line) {
29
+ return { tag, attrs, children: [], parent, line, text: "", raw: null }
30
+ }
31
+
32
+ function lineStarts(source) {
33
+ const starts = [0]
34
+ for (let at = source.indexOf("\n"); at !== -1; at = source.indexOf("\n", at + 1)) starts.push(at + 1)
35
+ return starts
36
+ }
37
+
38
+ function lineAt(starts, offset) {
39
+ let lo = 0
40
+ let hi = starts.length - 1
41
+ while (lo < hi) {
42
+ const mid = (lo + hi + 1) >> 1
43
+ if (starts[mid] <= offset) lo = mid
44
+ else hi = mid - 1
45
+ }
46
+ return lo + 1
47
+ }
48
+
49
+ const isSpace = (ch) => ch === " " || ch === "\n" || ch === "\t" || ch === "\r" || ch === "\f"
50
+ const isLetter = (ch) => (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z")
51
+
52
+ function skipSpaces(source, at) {
53
+ while (at < source.length && isSpace(source[at])) at++
54
+ return at
55
+ }
56
+
57
+ /** Read `<name attrs...>` starting just after `<`. */
58
+ function readTag(source, start) {
59
+ const end = source.length
60
+ let at = start
61
+ while (at < end && !isSpace(source[at]) && source[at] !== ">" && source[at] !== "/") at++
62
+ const name = source.slice(start, at).toLowerCase()
63
+ const attrs = {}
64
+ let selfClosing = false
65
+ while (at < end) {
66
+ const ch = source[at]
67
+ if (isSpace(ch)) { at++; continue }
68
+ if (ch === ">") return { name, attrs, end: at + 1, selfClosing }
69
+ selfClosing = ch === "/" && source[at + 1] === ">"
70
+ if (ch === "/") { at++; continue }
71
+ let stop = at + 1
72
+ while (stop < end && !isSpace(source[stop]) && source[stop] !== "/" && source[stop] !== ">" && source[stop] !== "=") stop++
73
+ const key = source.slice(at, stop).toLowerCase()
74
+ at = skipSpaces(source, stop)
75
+ let value = ""
76
+ if (source[at] === "=") {
77
+ at = skipSpaces(source, at + 1)
78
+ const quote = source[at]
79
+ if (quote === '"' || quote === "'") {
80
+ const close = source.indexOf(quote, at + 1)
81
+ const valueEnd = close === -1 ? end : close
82
+ value = source.slice(at + 1, valueEnd)
83
+ at = valueEnd + 1
84
+ } else {
85
+ let valueEnd = at
86
+ while (valueEnd < end && !isSpace(source[valueEnd]) && source[valueEnd] !== ">") valueEnd++
87
+ value = source.slice(at, valueEnd)
88
+ at = valueEnd
89
+ }
90
+ }
91
+ if (key !== "__proto__" && !Object.hasOwn(attrs, key)) attrs[key] = decodeEntities(value)
92
+ }
93
+ return { name, attrs, end, selfClosing }
94
+ }
95
+
96
+ function tagNameAt(source, at, tag) {
97
+ if (source.slice(at, at + tag.length).toLowerCase() !== tag) return false
98
+ const next = source[at + tag.length] ?? ">"
99
+ return isSpace(next) || next === ">" || next === "/"
100
+ }
101
+
102
+ /** Find the close of a raw element; nested <svg> is depth-counted. Returns [innerEnd, resumeAt]. */
103
+ function findRawClose(source, from, tag) {
104
+ let depth = 0
105
+ for (let lt = source.indexOf("<", from); lt !== -1; lt = source.indexOf("<", lt + 1)) {
106
+ if (source[lt + 1] === "/" && tagNameAt(source, lt + 2, tag)) {
107
+ if (depth === 0) {
108
+ const gt = source.indexOf(">", lt)
109
+ return [lt, gt === -1 ? source.length : gt + 1]
110
+ }
111
+ depth--
112
+ } else if (tag === "svg" && tagNameAt(source, lt + 1, tag)) {
113
+ depth++
114
+ }
115
+ }
116
+ return [source.length, source.length]
117
+ }
118
+
119
+ /** Parse an HTML string into a LiteNode tree rooted at "#document"; never throws on malformed input. */
120
+ export function parseHtml(source) {
121
+ const src = String(source)
122
+ const starts = lineStarts(src)
123
+ const root = makeNode("#document", {}, null, 1)
124
+ root.ids = new Map()
125
+ const stack = [root]
126
+ let sawBody = false
127
+ let textStart = 0
128
+ let at = 0
129
+ const top = () => stack[stack.length - 1]
130
+ const flushText = (to) => {
131
+ if (to > textStart) {
132
+ const node = makeNode("#text", {}, top(), lineAt(starts, textStart))
133
+ node.text = decodeEntities(src.slice(textStart, to))
134
+ top().children.push(node)
135
+ }
136
+ }
137
+ const closeWithin = (tag, scope) => {
138
+ for (let k = stack.length - 1; k > 0; k--) {
139
+ if (stack[k].tag === tag) { stack.length = k; return }
140
+ if (scope.has(stack[k].tag)) return
141
+ }
142
+ }
143
+ while (at < src.length) {
144
+ const lt = src.indexOf("<", at)
145
+ if (lt === -1) break
146
+ const next = src[lt + 1] ?? ""
147
+ if (src.startsWith("<!--", lt)) {
148
+ flushText(lt)
149
+ const close = src.indexOf("-->", lt + 4)
150
+ at = textStart = close === -1 ? src.length : close + 3
151
+ } else if (next === "!" || next === "?") {
152
+ flushText(lt)
153
+ const close = src.indexOf(">", lt)
154
+ at = textStart = close === -1 ? src.length : close + 1
155
+ } else if (next === "/" && isLetter(src[lt + 2] ?? "")) {
156
+ flushText(lt)
157
+ const tag = readTag(src, lt + 2)
158
+ if (tag.name !== "body" && tag.name !== "html") {
159
+ for (let k = stack.length - 1; k > 0; k--) {
160
+ if (stack[k].tag === tag.name) { stack.length = k; break }
161
+ }
162
+ }
163
+ at = textStart = tag.end
164
+ } else if (isLetter(next)) {
165
+ flushText(lt)
166
+ const tag = readTag(src, lt + 1)
167
+ at = textStart = tag.end
168
+ if ((tag.name === "html" || tag.name === "body" || tag.name === "head") && stack.some((n) => n.tag === tag.name)) continue
169
+ if (P_CLOSERS.has(tag.name)) closeWithin("p", P_SCOPE)
170
+ if (tag.name === "li") closeWithin("li", LI_SCOPE)
171
+ const node = makeNode(tag.name, tag.attrs, top(), lineAt(starts, lt))
172
+ top().children.push(node)
173
+ if (tag.name === "body") sawBody = true
174
+ if (tag.attrs.id) root.ids.set(tag.attrs.id, (root.ids.get(tag.attrs.id) ?? 0) + 1)
175
+ if (RAW_TAGS.has(tag.name)) {
176
+ node.raw = ""
177
+ if (!tag.selfClosing) {
178
+ const [innerEnd, resume] = findRawClose(src, tag.end, tag.name)
179
+ node.raw = src.slice(tag.end, innerEnd)
180
+ at = textStart = resume
181
+ }
182
+ } else if (!VOID_TAGS.has(tag.name)) {
183
+ stack.push(node)
184
+ }
185
+ } else {
186
+ at = lt + 1
187
+ }
188
+ }
189
+ flushText(src.length)
190
+ if (!sawBody) wrapInBody(root)
191
+ return root
192
+ }
193
+
194
+ /** Fragments without <body>: move everything except <head> under a synthesized body. */
195
+ function wrapInBody(root) {
196
+ const host = root.children.find((child) => child.tag === "html") ?? root
197
+ const kept = host.children.filter((child) => child.tag === "head")
198
+ const moved = host.children.filter((child) => child.tag !== "head")
199
+ const body = makeNode("body", {}, host, moved[0]?.line ?? 1)
200
+ for (const child of moved) child.parent = body
201
+ body.children = moved
202
+ host.children = [...kept, body]
203
+ }
204
+
205
+ function segment(node) {
206
+ const same = node.parent.children.filter((child) => child.tag === node.tag)
207
+ return same.length > 1 ? `${node.tag}:nth-of-type(${same.indexOf(node) + 1})` : node.tag
208
+ }
209
+
210
+ /** CSS selector for a node: nearest unique-id anchor, else `body > tag:nth-of-type(k) > ...`. */
211
+ export function selectorPath(node) {
212
+ let root = node
213
+ while (root.parent) root = root.parent
214
+ const ids = root.ids ?? new Map()
215
+ const segments = []
216
+ for (let cur = node.tag === "#text" ? node.parent : node; cur?.parent; cur = cur.parent) {
217
+ const id = cur.attrs.id
218
+ if (id && SAFE_ID.test(id) && ids.get(id) === 1) return [`#${id}`, ...segments].join(" > ")
219
+ if (cur.tag === "body") return ["body", ...segments].join(" > ")
220
+ if (cur.tag === "html") break
221
+ segments.unshift(segment(cur))
222
+ }
223
+ return segments.join(" > ")
224
+ }
225
+
226
+ /** Descendant text minus code/pre/kbd/samp/script/style/svg, whitespace collapsed. */
227
+ export function proseText(node) {
228
+ const parts = []
229
+ const walk = (cur) => {
230
+ if (cur.tag === "#text") { parts.push(cur.text); return }
231
+ if (PROSE_SKIP.has(cur.tag)) return
232
+ const breaks = PROSE_BREAKS.has(cur.tag)
233
+ if (breaks) parts.push(" ")
234
+ for (const child of cur.children) walk(child)
235
+ if (breaks) parts.push(" ")
236
+ }
237
+ for (const child of node.children) walk(child)
238
+ return parts.join("").replace(/\s+/g, " ").trim()
239
+ }
@@ -0,0 +1,191 @@
1
+ import { describe, expect, test } from "bun:test"
2
+
3
+ import { decodeEntities, parseHtml, proseText, PROSE_TAGS, selectorPath } from "./html-lite.mjs"
4
+
5
+ type LiteNode = {
6
+ tag: string
7
+ attrs: Record<string, string>
8
+ children: LiteNode[]
9
+ parent: LiteNode | null
10
+ line: number
11
+ text: string
12
+ raw: string | null
13
+ }
14
+
15
+ function elements(root: LiteNode): LiteNode[] {
16
+ const out: LiteNode[] = []
17
+ const stack = [...root.children].reverse()
18
+ while (stack.length > 0) {
19
+ const node = stack.pop() as LiteNode
20
+ if (node.tag === "#text") continue
21
+ out.push(node)
22
+ for (let i = node.children.length - 1; i >= 0; i--) stack.push(node.children[i] as LiteNode)
23
+ }
24
+ return out
25
+ }
26
+
27
+ function byTag(root: LiteNode, tag: string): LiteNode[] {
28
+ return elements(root).filter((node) => node.tag === tag)
29
+ }
30
+
31
+ function childTags(node: LiteNode): string[] {
32
+ return node.children.filter((child) => child.tag !== "#text").map((child) => child.tag)
33
+ }
34
+
35
+ describe("selectorPath", () => {
36
+ test("#given the 3rd p in the 2nd section under main #when a path is built #then it uses nth-of-type segments under body", () => {
37
+ // given
38
+ const doc = parseHtml(
39
+ "<html><body><main><section><p>x</p></section><section><p>1</p><p>2</p><p>3</p></section></main></body></html>",
40
+ )
41
+ const third = byTag(doc, "p")[3] as LiteNode
42
+ // when
43
+ const path = selectorPath(third)
44
+ // then
45
+ expect(path).toBe("body > main > section:nth-of-type(2) > p:nth-of-type(3)")
46
+ })
47
+
48
+ test("#given an ancestor with a unique id #when a path is built #then it anchors at that id", () => {
49
+ // given
50
+ const doc = parseHtml(
51
+ '<body><main><section id="results"><p>1</p><p>2</p><p>3</p></section></main></body>',
52
+ )
53
+ const third = byTag(doc, "p")[2] as LiteNode
54
+ // when
55
+ const path = selectorPath(third)
56
+ // then
57
+ expect(path).toBe("#results > p:nth-of-type(3)")
58
+ })
59
+
60
+ test("#given a duplicated id #when a path is built #then the id is not used as an anchor", () => {
61
+ // given
62
+ const doc = parseHtml('<body><div id="dup"><p>a</p></div><div id="dup"><p>b</p></div></body>')
63
+ const second = byTag(doc, "p")[1] as LiteNode
64
+ // when
65
+ const path = selectorPath(second)
66
+ // then
67
+ expect(path).toBe("body > div:nth-of-type(2) > p")
68
+ })
69
+ })
70
+
71
+ describe("parseHtml", () => {
72
+ test("#given a p followed by an h2 #when parsed #then the p auto-closes and h2 is its sibling", () => {
73
+ // given
74
+ const source = "<body><p>a<h2>b</h2></body>"
75
+ // when
76
+ const doc = parseHtml(source)
77
+ // then
78
+ const body = byTag(doc, "body")[0] as LiteNode
79
+ expect(childTags(body)).toEqual(["p", "h2"])
80
+ expect((byTag(doc, "h2")[0] as LiteNode).parent).toBe(body)
81
+ })
82
+
83
+ test("#given consecutive li without close tags #when parsed #then each li is a sibling", () => {
84
+ // given
85
+ const source = "<body><ul><li>one<li>two<li>three</ul></body>"
86
+ // when
87
+ const doc = parseHtml(source)
88
+ // then
89
+ const list = byTag(doc, "ul")[0] as LiteNode
90
+ expect(childTags(list)).toEqual(["li", "li", "li"])
91
+ })
92
+
93
+ test("#given an element on source line 7 #when parsed #then its line is 7", () => {
94
+ // given
95
+ const source = ["<html>", "<body>", "<main>", "<section>", "<p>one</p>", "<p>two</p>", '<p id="seven">x</p>', "</section>"].join("\n")
96
+ // when
97
+ const doc = parseHtml(source)
98
+ // then
99
+ const seven = byTag(doc, "p").find((node) => node.attrs.id === "seven") as LiteNode
100
+ expect(seven.line).toBe(7)
101
+ })
102
+
103
+ test("#given comments, void tags and an unmatched close tag #when parsed #then comments vanish, voids stay leaves, the stray close is ignored", () => {
104
+ // given
105
+ const source = "<body><!-- <p>hidden</p> --><div>a<br>b<img src=x.png></span> c</div></body>"
106
+ // when
107
+ const doc = parseHtml(source)
108
+ // then
109
+ expect(byTag(doc, "p")).toHaveLength(0)
110
+ const div = byTag(doc, "div")[0] as LiteNode
111
+ expect(childTags(div)).toEqual(["br", "img"])
112
+ expect((byTag(doc, "br")[0] as LiteNode).children).toHaveLength(0)
113
+ expect((byTag(doc, "img")[0] as LiteNode).attrs.src).toBe("x.png")
114
+ expect(proseText(div)).toBe("a b c")
115
+ })
116
+
117
+ test("#given style, script and svg elements #when parsed #then their inner source is captured raw", () => {
118
+ // given
119
+ const source =
120
+ '<head><style>p > a { color: red }</style></head><body><script>if (a < b) {}</script><svg viewBox="0 0 1 1"><svg><text>t</text></svg></svg><p>after</p></body>'
121
+ // when
122
+ const doc = parseHtml(source)
123
+ // then
124
+ expect((byTag(doc, "style")[0] as LiteNode).raw).toBe("p > a { color: red }")
125
+ expect((byTag(doc, "script")[0] as LiteNode).raw).toBe("if (a < b) {}")
126
+ const svg = byTag(doc, "svg")[0] as LiteNode
127
+ expect(svg.raw).toBe("<svg><text>t</text></svg>")
128
+ expect(svg.attrs.viewbox).toBe("0 0 1 1")
129
+ expect(childTags(svg.parent as LiteNode)).toEqual(["script", "svg", "p"])
130
+ })
131
+
132
+ test("#given an unclosed fragment without body #when parsed #then the tree still has body > div > p", () => {
133
+ // given
134
+ const source = "<div><p>unclosed"
135
+ // when
136
+ const doc = parseHtml(source)
137
+ // then
138
+ const p = byTag(doc, "p")[0] as LiteNode
139
+ expect(p.parent?.tag).toBe("div")
140
+ expect(p.parent?.parent?.tag).toBe("body")
141
+ expect(selectorPath(p)).toBe("body > div > p")
142
+ expect(proseText(p)).toBe("unclosed")
143
+ })
144
+
145
+ test("#given attributes in every quoting style #when parsed #then values are decoded", () => {
146
+ // given
147
+ const source = `<body><a href="#s1" data-x='y &amp; z' hidden title=bare>k</a></body>`
148
+ // when
149
+ const doc = parseHtml(source)
150
+ // then
151
+ const anchor = byTag(doc, "a")[0] as LiteNode
152
+ expect(anchor.attrs).toEqual({ href: "#s1", "data-x": "y & z", hidden: "", title: "bare" })
153
+ })
154
+ })
155
+
156
+ describe("decodeEntities", () => {
157
+ test("#given &mdash; #when decoded #then it is U+2014", () => {
158
+ expect(decodeEntities("a&mdash;b")).toBe("a\u2014b")
159
+ })
160
+
161
+ test("#given named, decimal and hex entities #when decoded #then each maps to its code point", () => {
162
+ // given
163
+ const source = "&amp;&lt;&gt;&quot;&apos;&nbsp;&ndash;&hellip;&copy;&reg;&times;&middot;&#44;&#x1F600;"
164
+ // when
165
+ const decoded = decodeEntities(source)
166
+ // then
167
+ expect(decoded).toBe("&<>\"'\u00a0\u2013\u2026\u00a9\u00ae\u00d7\u00b7,\u{1F600}")
168
+ })
169
+
170
+ test("#given unknown or malformed entities #when decoded #then they are left verbatim", () => {
171
+ expect(decodeEntities("&bogus; & &#xZZ; AT&T")).toBe("&bogus; & &#xZZ; AT&T")
172
+ })
173
+ })
174
+
175
+ describe("proseText", () => {
176
+ test("#given a paragraph with inline code #when prose is extracted #then code is skipped and whitespace collapsed", () => {
177
+ // given
178
+ const doc = parseHtml("<body><p>Run <code>npm i</code>\n then <kbd>Enter</kbd> now</p></body>")
179
+ const p = byTag(doc, "p")[0] as LiteNode
180
+ // when
181
+ const prose = proseText(p)
182
+ // then
183
+ expect(prose).toBe("Run then now")
184
+ })
185
+
186
+ test("#given PROSE_TAGS #when read #then it holds the prose-bearing tags", () => {
187
+ expect([...PROSE_TAGS].sort()).toEqual(
188
+ ["blockquote", "dd", "dt", "figcaption", "h1", "h2", "h3", "h4", "h5", "h6", "li", "p", "td", "th"].sort(),
189
+ )
190
+ })
191
+ })
@@ -0,0 +1,83 @@
1
+ // layout-probe.mjs - the in-page probe the layout gates consume.
2
+ //
3
+ // buildProbeSource() returns ONE async IIFE expression (the engine's evaluate awaits it): it first
4
+ // forces lazy images to load eagerly and awaits their decode (bounded), so natural sizes are real. The orchestrator evaluates it in the rendered
5
+ // report through the browser skill's owned headless engine and writes the result to boxes.json;
6
+ // gates-layout.mjs reads that file. This module never launches a browser.
7
+ //
8
+ // Selector paths use the SAME rule as html-lite.selectorPath (a unique safe #id anchors the path,
9
+ // otherwise tag segments with :nth-of-type(k) only when the parent has more than one child of that
10
+ // tag, prefixed "body"), so a layout defect can be mapped back to the static tree. The rule lives in
11
+ // PATH_FUNCTION_SOURCE, embedded verbatim in the probe and exercised by the parity test.
12
+
13
+ export const PROBE_VERSION = 1
14
+ export const SAMPLED_SELECTOR = "figure, .fig, img, svg, table, h1, h2, h3, p, pre"
15
+ export const EXCLUDED_ANCESTORS = Object.freeze(["svg", "nav", "header", "footer"])
16
+
17
+ /** In-page path rule. idCount(id) returns how many elements carry that id. */
18
+ export const PATH_FUNCTION_SOURCE = `function ulwSelectorPath(el, idCount) {
19
+ const SAFE_ID = /^[A-Za-z_][\\w-]*$/;
20
+ const segments = [];
21
+ for (let cur = el; cur && cur.parentElement; cur = cur.parentElement) {
22
+ const tag = cur.tagName.toLowerCase();
23
+ const id = cur.id;
24
+ if (id && SAFE_ID.test(id) && idCount(id) === 1) return ["#" + id].concat(segments).join(" > ");
25
+ if (tag === "body") return ["body"].concat(segments).join(" > ");
26
+ if (tag === "html") break;
27
+ const same = Array.from(cur.parentElement.children).filter((c) => c.tagName === cur.tagName);
28
+ segments.unshift(same.length > 1 ? tag + ":nth-of-type(" + (same.indexOf(cur) + 1) + ")" : tag);
29
+ }
30
+ return segments.join(" > ");
31
+ }`
32
+
33
+ export class ProbeError extends Error {
34
+ constructor(code, message) {
35
+ super(message)
36
+ this.name = "ProbeError"
37
+ this.code = code
38
+ }
39
+ }
40
+
41
+ /** @param {{ cap?: number, root?: string }} [options] */
42
+ export function buildProbeSource({ cap = 400, root = "main, article" } = {}) {
43
+ if (!Number.isInteger(cap) || cap <= 0) throw new ProbeError("invalid_cap", `--cap requires a positive integer, got ${cap}`)
44
+ if (typeof root !== "string" || root.trim().length === 0) throw new ProbeError("invalid_root", "--root requires a non-empty CSS selector")
45
+ return `(async () => {
46
+ const CAP = ${cap};
47
+ const IMAGE_WAIT_MS = 5000;
48
+ const ROOT = document.querySelector(${JSON.stringify(root)}) || document.body;
49
+ const EXCLUDED = ${JSON.stringify(EXCLUDED_ANCESTORS)};
50
+ const images = Array.from(document.images);
51
+ for (const img of images) if (img.loading === "lazy") img.loading = "eager";
52
+ const settle = Promise.all(images.map((img) => (img.complete && img.naturalWidth > 0 ? null : img.decode().catch(() => null))));
53
+ await Promise.race([settle, new Promise((resolve) => setTimeout(resolve, IMAGE_WAIT_MS))]);
54
+ ${PATH_FUNCTION_SOURCE}
55
+ const idCount = (id) => document.querySelectorAll("#" + CSS.escape(id)).length;
56
+ const path = (el) => ulwSelectorPath(el, idCount);
57
+ const r1 = (n) => Math.round(n * 10) / 10;
58
+ const rect = (el) => { const b = el.getBoundingClientRect(); return { x: r1(b.left + scrollX), y: r1(b.top + scrollY), w: r1(b.width), h: r1(b.height) }; };
59
+ const excluded = (el) => { for (let p = el.parentElement; p; p = p.parentElement) if (EXCLUDED.includes(p.tagName.toLowerCase())) return true; return false; };
60
+ const all = Array.from(ROOT.querySelectorAll(${JSON.stringify(SAMPLED_SELECTOR)})).filter((el) => {
61
+ if (excluded(el)) return false;
62
+ const b = el.getBoundingClientRect();
63
+ return b.width > 0 || b.height > 0;
64
+ });
65
+ const boxes = all.slice(0, CAP).map((el) => {
66
+ const cs = getComputedStyle(el);
67
+ const parent = el.parentElement;
68
+ const box = {
69
+ selector: path(el), tag: el.tagName.toLowerCase(), rect: rect(el),
70
+ parentSelector: parent ? path(parent) : null, parentRect: parent ? rect(parent) : null,
71
+ parentOverflow: parent ? { x: getComputedStyle(parent).overflowX, y: getComputedStyle(parent).overflowY } : null,
72
+ scroll: { sw: el.scrollWidth, cw: el.clientWidth, sh: el.scrollHeight, ch: el.clientHeight },
73
+ overflow: { x: cs.overflowX, y: cs.overflowY }, position: cs.position,
74
+ textLen: (el.textContent || "").trim().length,
75
+ };
76
+ if (el.tagName === "IMG") { box.objectFit = cs.objectFit; box.natural = { w: el.naturalWidth, h: el.naturalHeight }; }
77
+ return box;
78
+ });
79
+ return { probe_version: ${PROBE_VERSION}, viewport: { w: innerWidth, h: innerHeight },
80
+ document: { w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight },
81
+ count: all.length, truncated: all.length > CAP, boxes };
82
+ })()`
83
+ }
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from "bun:test"
2
+
3
+ import { parseHtml, selectorPath } from "./html-lite.mjs"
4
+ import { buildProbeSource, PATH_FUNCTION_SOURCE, PROBE_VERSION, ProbeError } from "./layout-probe.mjs"
5
+
6
+ // A minimal element adapter over the html-lite tree: exactly the DOM surface the path rule reads.
7
+ function adapt(node, parentAdapter, cache) {
8
+ if (cache.has(node)) return cache.get(node)
9
+ const el = { tagName: node.tag.toUpperCase(), id: node.attrs.id ?? "", parentElement: parentAdapter, children: [] }
10
+ cache.set(node, el)
11
+ el.children = node.children.filter((c) => c.tag !== "#text").map((c) => adapt(c, el, cache))
12
+ return el
13
+ }
14
+
15
+ function pathRule() {
16
+ return new Function(`${PATH_FUNCTION_SOURCE}; return ulwSelectorPath;`)()
17
+ }
18
+
19
+ const FIXTURE = `<!doctype html><html><head><style>p{}</style></head><body>
20
+ <main id="top"><section><h2>A</h2><p>one</p><p>two</p><figure class="fig"><img src="a.png"><figcaption>c</figcaption></figure></section>
21
+ <section id="results"><h2>B</h2><p>x</p><figure><svg width="300"><text>t</text></svg></figure><figure><img src="b.png"></figure></section>
22
+ <section><div id="dup"></div><div id="dup"><p>y</p></div></section></main></body></html>`
23
+
24
+ describe("PATH_FUNCTION_SOURCE", () => {
25
+ test("#given the html-lite tree of a fixture #when the in-page path rule runs on an adapter of every element #then it equals html-lite selectorPath", () => {
26
+ // given
27
+ const doc = parseHtml(FIXTURE)
28
+ const ids = new Map()
29
+ const cache = new Map()
30
+ const elements = []
31
+ const walk = (node, parentAdapter) => {
32
+ if (node.tag === "#text") return
33
+ const el = adapt(node, parentAdapter, cache)
34
+ if (node.tag !== "#root" && node.parent) elements.push([node, el])
35
+ if (el.id) ids.set(el.id, (ids.get(el.id) ?? 0) + 1)
36
+ for (const child of node.children) walk(child, el)
37
+ }
38
+ walk(doc, null)
39
+ const rule = pathRule()
40
+ // when
41
+ const mismatches = elements
42
+ .filter(([node]) => node.tag !== "html" && node.tag !== "head" && node.tag !== "#root")
43
+ .map(([node, el]) => [selectorPath(node), rule(el, (id) => ids.get(id) ?? 0)])
44
+ .filter(([expected, actual]) => expected !== actual)
45
+ // then
46
+ expect(elements.length).toBeGreaterThan(10)
47
+ expect(mismatches).toEqual([])
48
+ })
49
+
50
+ test("#given a duplicated id #when a descendant path is built #then the duplicate is not used as an anchor", () => {
51
+ const doc = parseHtml(FIXTURE)
52
+ const p = []
53
+ const find = (n) => { if (n.tag === "p" && n.parent?.attrs.id === "dup") p.push(n); n.children.forEach(find) }
54
+ find(doc)
55
+ expect(selectorPath(p[0])).toBe("#top > section:nth-of-type(3) > div:nth-of-type(2) > p")
56
+ })
57
+ })
58
+
59
+ describe("buildProbeSource", () => {
60
+ test("#given defaults #when built #then it is one async IIFE expression that parses and carries the version, cap, and image wait", () => {
61
+ const src = buildProbeSource()
62
+ expect(src).toMatch(/^\(async \(\) => \{[\s\S]*\}\)\(\)\s*$/)
63
+ expect(src.includes('img.loading = "eager"')).toBe(true)
64
+ expect(() => new Function(`return ${src}`)).not.toThrow()
65
+ expect(src.includes("const CAP = 400;")).toBe(true)
66
+ expect(src.includes(`probe_version: ${PROBE_VERSION}`)).toBe(true)
67
+ })
68
+
69
+ test("#given cap 50 and a custom root #when built #then both are embedded as literals", () => {
70
+ const src = buildProbeSource({ cap: 50, root: "article.report" })
71
+ expect(src.includes("const CAP = 50;")).toBe(true)
72
+ expect(src.includes('document.querySelector("article.report")')).toBe(true)
73
+ })
74
+
75
+ test("#given cap 0 or a fractional cap #when built #then a typed ProbeError is thrown", () => {
76
+ expect(() => buildProbeSource({ cap: 0 })).toThrow(ProbeError)
77
+ expect(() => buildProbeSource({ cap: 1.5 })).toThrow(ProbeError)
78
+ })
79
+
80
+ test("#given an empty root #when built #then a typed ProbeError is thrown", () => {
81
+ expect(() => buildProbeSource({ root: "" })).toThrow(ProbeError)
82
+ expect(() => buildProbeSource({ root: " " })).toThrow(ProbeError)
83
+ })
84
+
85
+ test("#given a root containing a double quote #when built #then the source still parses", () => {
86
+ const src = buildProbeSource({ root: 'main[data-x="1"]' })
87
+ expect(() => new Function(`return ${src}`)).not.toThrow()
88
+ })
89
+ })