@jitsusama/agentic-harness.core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/dist/advisor/charter.d.ts +14 -0
- package/dist/advisor/charter.js +48 -0
- package/dist/advisor/findings.d.ts +43 -0
- package/dist/advisor/findings.js +82 -0
- package/dist/advisor/index.d.ts +24 -0
- package/dist/advisor/index.js +23 -0
- package/dist/advisor/settings.d.ts +16 -0
- package/dist/advisor/settings.js +30 -0
- package/dist/advisor/substantive.d.ts +14 -0
- package/dist/advisor/substantive.js +33 -0
- package/dist/advisor/tools.d.ts +38 -0
- package/dist/advisor/tools.js +202 -0
- package/dist/attribution/commit-hook.d.ts +53 -0
- package/dist/attribution/commit-hook.js +133 -0
- package/dist/attribution/gh-footer.d.ts +27 -0
- package/dist/attribution/gh-footer.js +80 -0
- package/dist/attribution/index.d.ts +11 -0
- package/dist/attribution/index.js +11 -0
- package/dist/attribution/model-name.d.ts +11 -0
- package/dist/attribution/model-name.js +26 -0
- package/dist/bin/attribution.d.ts +36 -0
- package/dist/bin/attribution.js +82 -0
- package/dist/bin/cli.d.ts +14 -0
- package/dist/bin/cli.js +236 -0
- package/dist/bin/gate-deps.d.ts +23 -0
- package/dist/bin/gate-deps.js +61 -0
- package/dist/bin/guardians.d.ts +51 -0
- package/dist/bin/guardians.js +174 -0
- package/dist/bin/memory.d.ts +16 -0
- package/dist/bin/memory.js +81 -0
- package/dist/bin/notes.d.ts +30 -0
- package/dist/bin/notes.js +67 -0
- package/dist/bin/process-exec.d.ts +14 -0
- package/dist/bin/process-exec.js +31 -0
- package/dist/bin/quest.d.ts +38 -0
- package/dist/bin/quest.js +187 -0
- package/dist/bin/slack-auth.d.ts +49 -0
- package/dist/bin/slack-auth.js +107 -0
- package/dist/clock/index.d.ts +46 -0
- package/dist/clock/index.js +72 -0
- package/dist/command/cwd.d.ts +21 -0
- package/dist/command/cwd.js +60 -0
- package/dist/command/edit.d.ts +14 -0
- package/dist/command/edit.js +22 -0
- package/dist/command/flags.d.ts +29 -0
- package/dist/command/flags.js +111 -0
- package/dist/command/index.d.ts +16 -0
- package/dist/command/index.js +12 -0
- package/dist/command/tokenize.d.ts +7 -0
- package/dist/command/tokenize.js +315 -0
- package/dist/command/types.d.ts +66 -0
- package/dist/command/types.js +9 -0
- package/dist/completion/index.d.ts +17 -0
- package/dist/completion/index.js +16 -0
- package/dist/completion/investigate.d.ts +63 -0
- package/dist/completion/investigate.js +198 -0
- package/dist/completion/resolve.d.ts +29 -0
- package/dist/completion/resolve.js +42 -0
- package/dist/completion/side.d.ts +51 -0
- package/dist/completion/side.js +93 -0
- package/dist/completion/types.d.ts +84 -0
- package/dist/completion/types.js +14 -0
- package/dist/exec/exec.d.ts +38 -0
- package/dist/exec/exec.js +51 -0
- package/dist/exec/index.d.ts +8 -0
- package/dist/exec/index.js +8 -0
- package/dist/gate/decision.d.ts +52 -0
- package/dist/gate/decision.js +68 -0
- package/dist/gate/deps.d.ts +14 -0
- package/dist/gate/deps.js +1 -0
- package/dist/gate/index.d.ts +2 -0
- package/dist/gate/index.js +1 -0
- package/dist/git-cli/index.d.ts +47 -0
- package/dist/git-cli/index.js +127 -0
- package/dist/github-cli/command-spec.d.ts +14 -0
- package/dist/github-cli/command-spec.js +18 -0
- package/dist/github-cli/index.d.ts +71 -0
- package/dist/github-cli/index.js +188 -0
- package/dist/google/apis/calendar.d.ts +64 -0
- package/dist/google/apis/calendar.js +297 -0
- package/dist/google/apis/docs.d.ts +29 -0
- package/dist/google/apis/docs.js +154 -0
- package/dist/google/apis/drive.d.ts +46 -0
- package/dist/google/apis/drive.js +189 -0
- package/dist/google/apis/gmail.d.ts +67 -0
- package/dist/google/apis/gmail.js +374 -0
- package/dist/google/apis/index.d.ts +10 -0
- package/dist/google/apis/index.js +10 -0
- package/dist/google/apis/sheets.d.ts +16 -0
- package/dist/google/apis/sheets.js +54 -0
- package/dist/google/apis/slides.d.ts +16 -0
- package/dist/google/apis/slides.js +40 -0
- package/dist/google/auth/browser.d.ts +5 -0
- package/dist/google/auth/browser.js +27 -0
- package/dist/google/auth/credentials.d.ts +26 -0
- package/dist/google/auth/credentials.js +69 -0
- package/dist/google/auth/errors.d.ts +15 -0
- package/dist/google/auth/errors.js +26 -0
- package/dist/google/auth/index.d.ts +12 -0
- package/dist/google/auth/index.js +12 -0
- package/dist/google/auth/oauth-app.d.ts +15 -0
- package/dist/google/auth/oauth-app.js +20 -0
- package/dist/google/auth/oauth.d.ts +46 -0
- package/dist/google/auth/oauth.js +146 -0
- package/dist/google/auth/server.d.ts +13 -0
- package/dist/google/auth/server.js +70 -0
- package/dist/google/auth/setup-instructions.d.ts +16 -0
- package/dist/google/auth/setup-instructions.js +24 -0
- package/dist/google/auth/store.d.ts +19 -0
- package/dist/google/auth/store.js +27 -0
- package/dist/google/index.d.ts +13 -0
- package/dist/google/index.js +12 -0
- package/dist/google/paths.d.ts +17 -0
- package/dist/google/paths.js +28 -0
- package/dist/google/renderers/calendar.d.ts +23 -0
- package/dist/google/renderers/calendar.js +312 -0
- package/dist/google/renderers/drive.d.ts +31 -0
- package/dist/google/renderers/drive.js +214 -0
- package/dist/google/renderers/email.d.ts +16 -0
- package/dist/google/renderers/email.js +130 -0
- package/dist/google/renderers/index.d.ts +8 -0
- package/dist/google/renderers/index.js +8 -0
- package/dist/google/types.d.ts +143 -0
- package/dist/google/types.js +27 -0
- package/dist/governance/distill.d.ts +31 -0
- package/dist/governance/distill.js +78 -0
- package/dist/governance/index.d.ts +11 -0
- package/dist/governance/index.js +10 -0
- package/dist/governance/render.d.ts +13 -0
- package/dist/governance/render.js +23 -0
- package/dist/governance/store.d.ts +32 -0
- package/dist/governance/store.js +102 -0
- package/dist/governance/types.d.ts +19 -0
- package/dist/governance/types.js +9 -0
- package/dist/guardian/index.d.ts +7 -0
- package/dist/guardian/index.js +7 -0
- package/dist/guardian/types.d.ts +37 -0
- package/dist/guardian/types.js +12 -0
- package/dist/internal/github/cli.d.ts +40 -0
- package/dist/internal/github/cli.js +102 -0
- package/dist/internal/guardian/commit-file.d.ts +7 -0
- package/dist/internal/guardian/commit-file.js +36 -0
- package/dist/internal/guardian/commit-format.d.ts +35 -0
- package/dist/internal/guardian/commit-format.js +63 -0
- package/dist/internal/guardian/commit-shell.d.ts +38 -0
- package/dist/internal/guardian/commit-shell.js +134 -0
- package/dist/internal/guardian/history-gate.d.ts +41 -0
- package/dist/internal/guardian/history-gate.js +76 -0
- package/dist/internal/guardian/prose-gate.d.ts +31 -0
- package/dist/internal/guardian/prose-gate.js +50 -0
- package/dist/internal/guardian/redirect-gate.d.ts +41 -0
- package/dist/internal/guardian/redirect-gate.js +114 -0
- package/dist/internal/guardian/section-gate.d.ts +20 -0
- package/dist/internal/guardian/section-gate.js +36 -0
- package/dist/internal/guardian/title-gate.d.ts +20 -0
- package/dist/internal/guardian/title-gate.js +36 -0
- package/dist/internal/json-array.d.ts +11 -0
- package/dist/internal/json-array.js +40 -0
- package/dist/internal/lsp/registry.d.ts +12 -0
- package/dist/internal/lsp/registry.js +16 -0
- package/dist/internal/notes/discovery.d.ts +59 -0
- package/dist/internal/notes/discovery.js +91 -0
- package/dist/internal/notes/frontmatter.d.ts +52 -0
- package/dist/internal/notes/frontmatter.js +163 -0
- package/dist/internal/notes/id.d.ts +42 -0
- package/dist/internal/notes/id.js +80 -0
- package/dist/internal/notes/io.d.ts +25 -0
- package/dist/internal/notes/io.js +51 -0
- package/dist/internal/notes/sanitize.d.ts +19 -0
- package/dist/internal/notes/sanitize.js +35 -0
- package/dist/internal/process-global.d.ts +20 -0
- package/dist/internal/process-global.js +28 -0
- package/dist/internal/quest/alias-index.d.ts +61 -0
- package/dist/internal/quest/alias-index.js +69 -0
- package/dist/internal/quest/append-journey.d.ts +22 -0
- package/dist/internal/quest/append-journey.js +63 -0
- package/dist/internal/quest/bash-write.d.ts +49 -0
- package/dist/internal/quest/bash-write.js +208 -0
- package/dist/internal/quest/dates.d.ts +14 -0
- package/dist/internal/quest/dates.js +20 -0
- package/dist/internal/quest/discovery.d.ts +96 -0
- package/dist/internal/quest/discovery.js +354 -0
- package/dist/internal/quest/fields.d.ts +37 -0
- package/dist/internal/quest/fields.js +94 -0
- package/dist/internal/quest/frontmatter.d.ts +88 -0
- package/dist/internal/quest/frontmatter.js +630 -0
- package/dist/internal/quest/git-signals.d.ts +53 -0
- package/dist/internal/quest/git-signals.js +192 -0
- package/dist/internal/quest/id.d.ts +50 -0
- package/dist/internal/quest/id.js +116 -0
- package/dist/internal/quest/io.d.ts +54 -0
- package/dist/internal/quest/io.js +233 -0
- package/dist/internal/quest/mentions.d.ts +45 -0
- package/dist/internal/quest/mentions.js +70 -0
- package/dist/internal/quest/mutate.d.ts +38 -0
- package/dist/internal/quest/mutate.js +83 -0
- package/dist/internal/quest/process-liveness.d.ts +98 -0
- package/dist/internal/quest/process-liveness.js +216 -0
- package/dist/internal/quest/quest-doc.d.ts +116 -0
- package/dist/internal/quest/quest-doc.js +239 -0
- package/dist/internal/quest/ranking.d.ts +48 -0
- package/dist/internal/quest/ranking.js +114 -0
- package/dist/internal/quest/reopen.d.ts +65 -0
- package/dist/internal/quest/reopen.js +113 -0
- package/dist/internal/quest/resolve-cwd.d.ts +32 -0
- package/dist/internal/quest/resolve-cwd.js +89 -0
- package/dist/internal/quest/sanitize.d.ts +42 -0
- package/dist/internal/quest/sanitize.js +69 -0
- package/dist/internal/quest/scaffold.d.ts +59 -0
- package/dist/internal/quest/scaffold.js +169 -0
- package/dist/internal/quest/scratch.d.ts +27 -0
- package/dist/internal/quest/scratch.js +106 -0
- package/dist/internal/quest/session-liveness.d.ts +125 -0
- package/dist/internal/quest/session-liveness.js +386 -0
- package/dist/internal/quest/session-ownership.d.ts +22 -0
- package/dist/internal/quest/session-ownership.js +100 -0
- package/dist/internal/quest/session-registry.d.ts +221 -0
- package/dist/internal/quest/session-registry.js +266 -0
- package/dist/internal/quest/status.d.ts +10 -0
- package/dist/internal/quest/status.js +15 -0
- package/dist/internal/quest/structural-journal.d.ts +41 -0
- package/dist/internal/quest/structural-journal.js +67 -0
- package/dist/internal/quest/structural.d.ts +43 -0
- package/dist/internal/quest/structural.js +77 -0
- package/dist/internal/quest/toc.d.ts +28 -0
- package/dist/internal/quest/toc.js +90 -0
- package/dist/internal/quest/trees.d.ts +53 -0
- package/dist/internal/quest/trees.js +145 -0
- package/dist/internal/quest/url-fetchers.d.ts +50 -0
- package/dist/internal/quest/url-fetchers.js +97 -0
- package/dist/internal/quest/write-classifier.d.ts +46 -0
- package/dist/internal/quest/write-classifier.js +59 -0
- package/dist/internal/refs/builtins.d.ts +32 -0
- package/dist/internal/refs/builtins.js +179 -0
- package/dist/internal/refs/registry.d.ts +25 -0
- package/dist/internal/refs/registry.js +42 -0
- package/dist/internal/registry/global-symbol-registry.d.ts +37 -0
- package/dist/internal/registry/global-symbol-registry.js +55 -0
- package/dist/internal/review/registry.d.ts +12 -0
- package/dist/internal/review/registry.js +16 -0
- package/dist/internal/sqlite/db.d.ts +16 -0
- package/dist/internal/sqlite/db.js +26 -0
- package/dist/internal/terminal/builtins.d.ts +8 -0
- package/dist/internal/terminal/builtins.js +14 -0
- package/dist/internal/terminal/drivers/fallback.d.ts +8 -0
- package/dist/internal/terminal/drivers/fallback.js +31 -0
- package/dist/internal/terminal/drivers/shared.d.ts +39 -0
- package/dist/internal/terminal/drivers/shared.js +51 -0
- package/dist/internal/terminal/drivers/tmux.d.ts +14 -0
- package/dist/internal/terminal/drivers/tmux.js +88 -0
- package/dist/internal/terminal/drivers/wezterm.d.ts +35 -0
- package/dist/internal/terminal/drivers/wezterm.js +175 -0
- package/dist/internal/terminal/registry.d.ts +12 -0
- package/dist/internal/terminal/registry.js +16 -0
- package/dist/internal/tree/builtins.d.ts +8 -0
- package/dist/internal/tree/builtins.js +10 -0
- package/dist/internal/tree/providers/git-worktree.d.ts +20 -0
- package/dist/internal/tree/providers/git-worktree.js +279 -0
- package/dist/internal/tree/registry.d.ts +12 -0
- package/dist/internal/tree/registry.js +16 -0
- package/dist/lsp/config.d.ts +77 -0
- package/dist/lsp/config.js +154 -0
- package/dist/lsp/external.d.ts +13 -0
- package/dist/lsp/external.js +52 -0
- package/dist/lsp/format.d.ts +11 -0
- package/dist/lsp/format.js +54 -0
- package/dist/lsp/index.d.ts +17 -0
- package/dist/lsp/index.js +16 -0
- package/dist/lsp/offsets.d.ts +22 -0
- package/dist/lsp/offsets.js +51 -0
- package/dist/lsp/registry.d.ts +28 -0
- package/dist/lsp/registry.js +45 -0
- package/dist/lsp/standalone/backend.d.ts +42 -0
- package/dist/lsp/standalone/backend.js +150 -0
- package/dist/lsp/standalone/document.d.ts +45 -0
- package/dist/lsp/standalone/document.js +81 -0
- package/dist/lsp/standalone/server.d.ts +84 -0
- package/dist/lsp/standalone/server.js +521 -0
- package/dist/lsp/types.d.ts +119 -0
- package/dist/lsp/types.js +13 -0
- package/dist/memory/db.d.ts +15 -0
- package/dist/memory/db.js +25 -0
- package/dist/memory/index.d.ts +12 -0
- package/dist/memory/index.js +11 -0
- package/dist/memory/paths.d.ts +19 -0
- package/dist/memory/paths.js +33 -0
- package/dist/memory/scope.d.ts +14 -0
- package/dist/memory/scope.js +22 -0
- package/dist/memory/store.d.ts +12 -0
- package/dist/memory/store.js +146 -0
- package/dist/memory/types.d.ts +80 -0
- package/dist/memory/types.js +6 -0
- package/dist/notes/types.d.ts +50 -0
- package/dist/notes/types.js +23 -0
- package/dist/notes/verbs/create.d.ts +5 -0
- package/dist/notes/verbs/create.js +55 -0
- package/dist/notes/verbs/mutate.d.ts +8 -0
- package/dist/notes/verbs/mutate.js +152 -0
- package/dist/notes/verbs/queries.d.ts +15 -0
- package/dist/notes/verbs/queries.js +116 -0
- package/dist/notes/verbs/reindex.d.ts +12 -0
- package/dist/notes/verbs/reindex.js +35 -0
- package/dist/notes/verbs/shared.d.ts +37 -0
- package/dist/notes/verbs/shared.js +29 -0
- package/dist/observability/index.d.ts +13 -0
- package/dist/observability/index.js +12 -0
- package/dist/observability/recorder.d.ts +50 -0
- package/dist/observability/recorder.js +77 -0
- package/dist/observability/store.d.ts +22 -0
- package/dist/observability/store.js +213 -0
- package/dist/observability/types.d.ts +82 -0
- package/dist/observability/types.js +1 -0
- package/dist/process/index.d.ts +9 -0
- package/dist/process/index.js +9 -0
- package/dist/process/process.d.ts +95 -0
- package/dist/process/process.js +153 -0
- package/dist/prose/block.d.ts +9 -0
- package/dist/prose/block.js +43 -0
- package/dist/prose/detect.d.ts +50 -0
- package/dist/prose/detect.js +217 -0
- package/dist/prose/gate.d.ts +14 -0
- package/dist/prose/gate.js +21 -0
- package/dist/prose/index.d.ts +4 -0
- package/dist/prose/index.js +4 -0
- package/dist/prose/wrap.d.ts +9 -0
- package/dist/prose/wrap.js +47 -0
- package/dist/quest/actions.d.ts +20 -0
- package/dist/quest/actions.js +107 -0
- package/dist/quest/config.d.ts +79 -0
- package/dist/quest/config.js +83 -0
- package/dist/quest/index.d.ts +29 -0
- package/dist/quest/index.js +28 -0
- package/dist/quest/lifecycle.d.ts +365 -0
- package/dist/quest/lifecycle.js +900 -0
- package/dist/quest/lookup.d.ts +243 -0
- package/dist/quest/lookup.js +496 -0
- package/dist/quest/machine.d.ts +48 -0
- package/dist/quest/machine.js +96 -0
- package/dist/quest/render-rows.d.ts +166 -0
- package/dist/quest/render-rows.js +214 -0
- package/dist/quest/state.d.ts +111 -0
- package/dist/quest/state.js +46 -0
- package/dist/quest/types.d.ts +224 -0
- package/dist/quest/types.js +10 -0
- package/dist/quest/verbs/alias.d.ts +9 -0
- package/dist/quest/verbs/alias.js +129 -0
- package/dist/quest/verbs/lifecycle.d.ts +36 -0
- package/dist/quest/verbs/lifecycle.js +407 -0
- package/dist/quest/verbs/queries.d.ts +24 -0
- package/dist/quest/verbs/queries.js +241 -0
- package/dist/quest/verbs/reorder.d.ts +15 -0
- package/dist/quest/verbs/reorder.js +85 -0
- package/dist/quest/verbs/shared.d.ts +63 -0
- package/dist/quest/verbs/shared.js +35 -0
- package/dist/quest/verbs/stage.d.ts +39 -0
- package/dist/quest/verbs/stage.js +506 -0
- package/dist/quest/verbs/structural.d.ts +23 -0
- package/dist/quest/verbs/structural.js +310 -0
- package/dist/quest/verbs/tree-ops.d.ts +20 -0
- package/dist/quest/verbs/tree-ops.js +289 -0
- package/dist/refs/index.d.ts +13 -0
- package/dist/refs/index.js +12 -0
- package/dist/refs/lookup.d.ts +53 -0
- package/dist/refs/lookup.js +102 -0
- package/dist/refs/register.d.ts +34 -0
- package/dist/refs/register.js +44 -0
- package/dist/refs/types.d.ts +81 -0
- package/dist/refs/types.js +17 -0
- package/dist/remote/index.d.ts +9 -0
- package/dist/remote/index.js +9 -0
- package/dist/remote/name.d.ts +22 -0
- package/dist/remote/name.js +30 -0
- package/dist/result/cite.d.ts +84 -0
- package/dist/result/cite.js +127 -0
- package/dist/result/counts.d.ts +15 -0
- package/dist/result/counts.js +17 -0
- package/dist/result/details.d.ts +61 -0
- package/dist/result/details.js +121 -0
- package/dist/result/digest.d.ts +37 -0
- package/dist/result/digest.js +227 -0
- package/dist/result/follow.d.ts +29 -0
- package/dist/result/follow.js +37 -0
- package/dist/result/index.d.ts +26 -0
- package/dist/result/index.js +26 -0
- package/dist/result/listing.d.ts +62 -0
- package/dist/result/listing.js +72 -0
- package/dist/result/location.d.ts +67 -0
- package/dist/result/location.js +125 -0
- package/dist/result/query.d.ts +67 -0
- package/dist/result/query.js +144 -0
- package/dist/result/spill.d.ts +41 -0
- package/dist/result/spill.js +121 -0
- package/dist/result/store.d.ts +83 -0
- package/dist/result/store.js +214 -0
- package/dist/result/view.d.ts +31 -0
- package/dist/result/view.js +38 -0
- package/dist/review/anchor.d.ts +84 -0
- package/dist/review/anchor.js +89 -0
- package/dist/review/ask/agents.d.ts +80 -0
- package/dist/review/ask/agents.js +296 -0
- package/dist/review/ask/anchorable.d.ts +35 -0
- package/dist/review/ask/anchorable.js +76 -0
- package/dist/review/ask/answer.d.ts +54 -0
- package/dist/review/ask/answer.js +209 -0
- package/dist/review/ask/audit.d.ts +71 -0
- package/dist/review/ask/audit.js +158 -0
- package/dist/review/ask/collect.d.ts +41 -0
- package/dist/review/ask/collect.js +82 -0
- package/dist/review/ask/council.d.ts +290 -0
- package/dist/review/ask/council.js +386 -0
- package/dist/review/ask/critique.d.ts +69 -0
- package/dist/review/ask/critique.js +157 -0
- package/dist/review/ask/harvest.d.ts +119 -0
- package/dist/review/ask/harvest.js +409 -0
- package/dist/review/ask/identity.d.ts +93 -0
- package/dist/review/ask/identity.js +134 -0
- package/dist/review/ask/judge.d.ts +37 -0
- package/dist/review/ask/judge.js +140 -0
- package/dist/review/ask/persona.d.ts +84 -0
- package/dist/review/ask/persona.js +127 -0
- package/dist/review/ask/progress.d.ts +117 -0
- package/dist/review/ask/progress.js +137 -0
- package/dist/review/ask/prompt.d.ts +131 -0
- package/dist/review/ask/prompt.js +242 -0
- package/dist/review/ask/roster.d.ts +94 -0
- package/dist/review/ask/roster.js +338 -0
- package/dist/review/ask/run.d.ts +435 -0
- package/dist/review/ask/run.js +340 -0
- package/dist/review/ask/span.d.ts +70 -0
- package/dist/review/ask/span.js +166 -0
- package/dist/review/ask/stack-round.d.ts +66 -0
- package/dist/review/ask/stack-round.js +125 -0
- package/dist/review/ask/start.d.ts +48 -0
- package/dist/review/ask/start.js +118 -0
- package/dist/review/ask/store.d.ts +83 -0
- package/dist/review/ask/store.js +226 -0
- package/dist/review/ask/wire.d.ts +89 -0
- package/dist/review/ask/wire.js +204 -0
- package/dist/review/attach.d.ts +161 -0
- package/dist/review/attach.js +357 -0
- package/dist/review/authoring.d.ts +84 -0
- package/dist/review/authoring.js +203 -0
- package/dist/review/backed.d.ts +90 -0
- package/dist/review/backed.js +168 -0
- package/dist/review/bind.d.ts +75 -0
- package/dist/review/bind.js +223 -0
- package/dist/review/budget.d.ts +133 -0
- package/dist/review/budget.js +278 -0
- package/dist/review/capabilities.d.ts +217 -0
- package/dist/review/capabilities.js +46 -0
- package/dist/review/change.d.ts +155 -0
- package/dist/review/change.js +15 -0
- package/dist/review/checks.d.ts +56 -0
- package/dist/review/checks.js +11 -0
- package/dist/review/config.d.ts +71 -0
- package/dist/review/config.js +125 -0
- package/dist/review/conventions.d.ts +35 -0
- package/dist/review/conventions.js +58 -0
- package/dist/review/conversation.d.ts +98 -0
- package/dist/review/conversation.js +16 -0
- package/dist/review/decided.d.ts +58 -0
- package/dist/review/decided.js +67 -0
- package/dist/review/diff.d.ts +116 -0
- package/dist/review/diff.js +237 -0
- package/dist/review/draft/continuation.d.ts +23 -0
- package/dist/review/draft/continuation.js +32 -0
- package/dist/review/draft/fanout.d.ts +57 -0
- package/dist/review/draft/fanout.js +46 -0
- package/dist/review/draft/handle.d.ts +55 -0
- package/dist/review/draft/handle.js +110 -0
- package/dist/review/draft/plan.d.ts +112 -0
- package/dist/review/draft/plan.js +343 -0
- package/dist/review/draft/publish.d.ts +38 -0
- package/dist/review/draft/publish.js +118 -0
- package/dist/review/draft/render.d.ts +32 -0
- package/dist/review/draft/render.js +100 -0
- package/dist/review/draft/state.d.ts +98 -0
- package/dist/review/draft/state.js +83 -0
- package/dist/review/draft/store.d.ts +33 -0
- package/dist/review/draft/store.js +100 -0
- package/dist/review/elsewhere.d.ts +27 -0
- package/dist/review/elsewhere.js +39 -0
- package/dist/review/engine.d.ts +106 -0
- package/dist/review/engine.js +243 -0
- package/dist/review/events.d.ts +59 -0
- package/dist/review/events.js +35 -0
- package/dist/review/failed.d.ts +46 -0
- package/dist/review/failed.js +56 -0
- package/dist/review/finding.d.ts +81 -0
- package/dist/review/finding.js +85 -0
- package/dist/review/fix.d.ts +147 -0
- package/dist/review/fix.js +175 -0
- package/dist/review/followup.d.ts +84 -0
- package/dist/review/followup.js +123 -0
- package/dist/review/index.d.ts +134 -0
- package/dist/review/index.js +80 -0
- package/dist/review/keys.d.ts +23 -0
- package/dist/review/keys.js +40 -0
- package/dist/review/landing.d.ts +37 -0
- package/dist/review/landing.js +68 -0
- package/dist/review/lenses.d.ts +132 -0
- package/dist/review/lenses.js +430 -0
- package/dist/review/propose-from.d.ts +76 -0
- package/dist/review/propose-from.js +86 -0
- package/dist/review/provider.d.ts +306 -0
- package/dist/review/provider.js +16 -0
- package/dist/review/providers/git/index.d.ts +23 -0
- package/dist/review/providers/git/index.js +214 -0
- package/dist/review/providers/github/authoring.d.ts +22 -0
- package/dist/review/providers/github/authoring.js +350 -0
- package/dist/review/providers/github/claims.d.ts +49 -0
- package/dist/review/providers/github/claims.js +136 -0
- package/dist/review/providers/github/conversation.d.ts +18 -0
- package/dist/review/providers/github/conversation.js +455 -0
- package/dist/review/providers/github/fields.d.ts +30 -0
- package/dist/review/providers/github/fields.js +47 -0
- package/dist/review/providers/github/index.d.ts +15 -0
- package/dist/review/providers/github/index.js +110 -0
- package/dist/review/providers/github/proposals.d.ts +14 -0
- package/dist/review/providers/github/proposals.js +298 -0
- package/dist/review/providers/github/queue.d.ts +46 -0
- package/dist/review/providers/github/queue.js +122 -0
- package/dist/review/providers/github/stacking.d.ts +20 -0
- package/dist/review/providers/github/stacking.js +107 -0
- package/dist/review/queue.d.ts +43 -0
- package/dist/review/queue.js +56 -0
- package/dist/review/reactable.d.ts +93 -0
- package/dist/review/reactable.js +140 -0
- package/dist/review/register.d.ts +52 -0
- package/dist/review/register.js +53 -0
- package/dist/review/resolve.d.ts +58 -0
- package/dist/review/resolve.js +150 -0
- package/dist/review/retarget.d.ts +73 -0
- package/dist/review/retarget.js +82 -0
- package/dist/review/revisited.d.ts +77 -0
- package/dist/review/revisited.js +121 -0
- package/dist/review/stack.d.ts +110 -0
- package/dist/review/stack.js +52 -0
- package/dist/sections/block.d.ts +9 -0
- package/dist/sections/block.js +42 -0
- package/dist/sections/detect.d.ts +31 -0
- package/dist/sections/detect.js +67 -0
- package/dist/sections/gate.d.ts +21 -0
- package/dist/sections/gate.js +22 -0
- package/dist/sections/index.d.ts +5 -0
- package/dist/sections/index.js +5 -0
- package/dist/sections/sanctioned.d.ts +13 -0
- package/dist/sections/sanctioned.js +21 -0
- package/dist/shell/index.d.ts +8 -0
- package/dist/shell/index.js +8 -0
- package/dist/shell/parse.d.ts +125 -0
- package/dist/shell/parse.js +298 -0
- package/dist/slack/api/channels.d.ts +12 -0
- package/dist/slack/api/channels.js +28 -0
- package/dist/slack/api/client.d.ts +94 -0
- package/dist/slack/api/client.js +272 -0
- package/dist/slack/api/files.d.ts +95 -0
- package/dist/slack/api/files.js +223 -0
- package/dist/slack/api/index.d.ts +11 -0
- package/dist/slack/api/index.js +11 -0
- package/dist/slack/api/messages.d.ts +98 -0
- package/dist/slack/api/messages.js +292 -0
- package/dist/slack/api/reactions.d.ts +47 -0
- package/dist/slack/api/reactions.js +50 -0
- package/dist/slack/api/resolve-conversations.d.ts +43 -0
- package/dist/slack/api/resolve-conversations.js +130 -0
- package/dist/slack/api/resolve-messages.d.ts +29 -0
- package/dist/slack/api/resolve-messages.js +93 -0
- package/dist/slack/api/resolve-users.d.ts +27 -0
- package/dist/slack/api/resolve-users.js +73 -0
- package/dist/slack/api/search.d.ts +86 -0
- package/dist/slack/api/search.js +186 -0
- package/dist/slack/api/users.d.ts +12 -0
- package/dist/slack/api/users.js +33 -0
- package/dist/slack/auth/browser-extract.d.ts +33 -0
- package/dist/slack/auth/browser-extract.js +201 -0
- package/dist/slack/auth/browser.d.ts +5 -0
- package/dist/slack/auth/browser.js +25 -0
- package/dist/slack/auth/credentials.d.ts +14 -0
- package/dist/slack/auth/credentials.js +24 -0
- package/dist/slack/auth/errors.d.ts +9 -0
- package/dist/slack/auth/errors.js +19 -0
- package/dist/slack/auth/extract.d.ts +23 -0
- package/dist/slack/auth/extract.js +44 -0
- package/dist/slack/auth/index.d.ts +12 -0
- package/dist/slack/auth/index.js +11 -0
- package/dist/slack/auth/oauth-app.d.ts +11 -0
- package/dist/slack/auth/oauth-app.js +20 -0
- package/dist/slack/auth/oauth.d.ts +31 -0
- package/dist/slack/auth/oauth.js +82 -0
- package/dist/slack/auth/server.d.ts +14 -0
- package/dist/slack/auth/server.js +75 -0
- package/dist/slack/auth/store.d.ts +17 -0
- package/dist/slack/auth/store.js +27 -0
- package/dist/slack/block-message.d.ts +9 -0
- package/dist/slack/block-message.js +30 -0
- package/dist/slack/blocks.d.ts +94 -0
- package/dist/slack/blocks.js +765 -0
- package/dist/slack/content-gate.d.ts +12 -0
- package/dist/slack/content-gate.js +22 -0
- package/dist/slack/detect.d.ts +31 -0
- package/dist/slack/detect.js +125 -0
- package/dist/slack/index.d.ts +18 -0
- package/dist/slack/index.js +17 -0
- package/dist/slack/paths.d.ts +28 -0
- package/dist/slack/paths.js +46 -0
- package/dist/slack/renderers/channel.d.ts +6 -0
- package/dist/slack/renderers/channel.js +27 -0
- package/dist/slack/renderers/index.d.ts +9 -0
- package/dist/slack/renderers/index.js +9 -0
- package/dist/slack/renderers/message.d.ts +21 -0
- package/dist/slack/renderers/message.js +214 -0
- package/dist/slack/renderers/reactions.d.ts +8 -0
- package/dist/slack/renderers/reactions.js +43 -0
- package/dist/slack/renderers/search.d.ts +6 -0
- package/dist/slack/renderers/search.js +39 -0
- package/dist/slack/renderers/user.d.ts +6 -0
- package/dist/slack/renderers/user.js +32 -0
- package/dist/slack/resolvers/cache.d.ts +29 -0
- package/dist/slack/resolvers/cache.js +70 -0
- package/dist/slack/resolvers/conversation-cache.d.ts +42 -0
- package/dist/slack/resolvers/conversation-cache.js +128 -0
- package/dist/slack/resolvers/conversation.d.ts +44 -0
- package/dist/slack/resolvers/conversation.js +229 -0
- package/dist/slack/resolvers/index.d.ts +8 -0
- package/dist/slack/resolvers/index.js +8 -0
- package/dist/slack/resolvers/target.d.ts +19 -0
- package/dist/slack/resolvers/target.js +45 -0
- package/dist/slack/resolvers/url.d.ts +20 -0
- package/dist/slack/resolvers/url.js +22 -0
- package/dist/slack/resolvers/user.d.ts +54 -0
- package/dist/slack/resolvers/user.js +134 -0
- package/dist/slack/types.d.ts +180 -0
- package/dist/slack/types.js +13 -0
- package/dist/tdd/attest.d.ts +21 -0
- package/dist/tdd/attest.js +21 -0
- package/dist/tdd/discipline.d.ts +11 -0
- package/dist/tdd/discipline.js +28 -0
- package/dist/tdd/index.d.ts +14 -0
- package/dist/tdd/index.js +13 -0
- package/dist/tdd/machine.d.ts +48 -0
- package/dist/tdd/machine.js +139 -0
- package/dist/tdd/presentation.d.ts +34 -0
- package/dist/tdd/presentation.js +38 -0
- package/dist/tdd/queries.d.ts +10 -0
- package/dist/tdd/queries.js +22 -0
- package/dist/terminal/index.d.ts +12 -0
- package/dist/terminal/index.js +11 -0
- package/dist/terminal/register.d.ts +21 -0
- package/dist/terminal/register.js +31 -0
- package/dist/terminal/resolve.d.ts +63 -0
- package/dist/terminal/resolve.js +117 -0
- package/dist/terminal/types.d.ts +134 -0
- package/dist/terminal/types.js +19 -0
- package/dist/thinking/index.d.ts +20 -0
- package/dist/thinking/index.js +27 -0
- package/dist/title/block.d.ts +9 -0
- package/dist/title/block.js +32 -0
- package/dist/title/detect.d.ts +25 -0
- package/dist/title/detect.js +154 -0
- package/dist/title/gate.d.ts +19 -0
- package/dist/title/gate.js +21 -0
- package/dist/title/index.d.ts +3 -0
- package/dist/title/index.js +3 -0
- package/dist/tree/index.d.ts +18 -0
- package/dist/tree/index.js +17 -0
- package/dist/tree/register.d.ts +21 -0
- package/dist/tree/register.js +31 -0
- package/dist/tree/resolve.d.ts +20 -0
- package/dist/tree/resolve.js +31 -0
- package/dist/tree/types.d.ts +83 -0
- package/dist/tree/types.js +12 -0
- package/dist/ui/count.d.ts +29 -0
- package/dist/ui/count.js +35 -0
- package/dist/ui/path.d.ts +25 -0
- package/dist/ui/path.js +40 -0
- package/dist/verify/index.d.ts +17 -0
- package/dist/verify/index.js +14 -0
- package/dist/verify/project.d.ts +21 -0
- package/dist/verify/project.js +49 -0
- package/dist/verify/resolve.d.ts +26 -0
- package/dist/verify/resolve.js +27 -0
- package/dist/verify/run.d.ts +31 -0
- package/dist/verify/run.js +145 -0
- package/dist/web/a11y/announcements.d.ts +67 -0
- package/dist/web/a11y/announcements.js +116 -0
- package/dist/web/a11y/budget.d.ts +75 -0
- package/dist/web/a11y/budget.js +97 -0
- package/dist/web/a11y/focus.d.ts +37 -0
- package/dist/web/a11y/focus.js +106 -0
- package/dist/web/a11y/index.d.ts +19 -0
- package/dist/web/a11y/index.js +25 -0
- package/dist/web/a11y/naming.d.ts +23 -0
- package/dist/web/a11y/naming.js +55 -0
- package/dist/web/a11y/outline.d.ts +23 -0
- package/dist/web/a11y/outline.js +42 -0
- package/dist/web/a11y/reading.d.ts +12 -0
- package/dist/web/a11y/reading.js +101 -0
- package/dist/web/a11y/scope.d.ts +23 -0
- package/dist/web/a11y/scope.js +99 -0
- package/dist/web/a11y/states.d.ts +22 -0
- package/dist/web/a11y/states.js +53 -0
- package/dist/web/a11y/tree.d.ts +124 -0
- package/dist/web/a11y/tree.js +269 -0
- package/dist/web/a11y/walk.d.ts +225 -0
- package/dist/web/a11y/walk.js +568 -0
- package/dist/web/a11y/walkprobe.d.ts +44 -0
- package/dist/web/a11y/walkprobe.js +365 -0
- package/dist/web/audit/axe.d.ts +219 -0
- package/dist/web/audit/axe.js +322 -0
- package/dist/web/audit/behind.d.ts +63 -0
- package/dist/web/audit/behind.js +142 -0
- package/dist/web/audit/capture.d.ts +43 -0
- package/dist/web/audit/capture.js +185 -0
- package/dist/web/audit/colour.d.ts +85 -0
- package/dist/web/audit/colour.js +189 -0
- package/dist/web/audit/contrast.d.ts +74 -0
- package/dist/web/audit/contrast.js +104 -0
- package/dist/web/audit/health.d.ts +27 -0
- package/dist/web/audit/health.js +57 -0
- package/dist/web/audit/index.d.ts +22 -0
- package/dist/web/audit/index.js +22 -0
- package/dist/web/audit/pair.d.ts +55 -0
- package/dist/web/audit/pair.js +157 -0
- package/dist/web/audit/probe.d.ts +31 -0
- package/dist/web/audit/probe.js +189 -0
- package/dist/web/audit/report.d.ts +22 -0
- package/dist/web/audit/report.js +250 -0
- package/dist/web/audit/structure.d.ts +126 -0
- package/dist/web/audit/structure.js +595 -0
- package/dist/web/audit/sweep.d.ts +66 -0
- package/dist/web/audit/sweep.js +117 -0
- package/dist/web/audit/target.d.ts +89 -0
- package/dist/web/audit/target.js +168 -0
- package/dist/web/audit/verdict.d.ts +48 -0
- package/dist/web/audit/verdict.js +52 -0
- package/dist/web/audit/visual.d.ts +161 -0
- package/dist/web/audit/visual.js +297 -0
- package/dist/web/browser.d.ts +213 -0
- package/dist/web/browser.js +927 -0
- package/dist/web/compare/images.d.ts +45 -0
- package/dist/web/compare/images.js +101 -0
- package/dist/web/compare/index.d.ts +15 -0
- package/dist/web/compare/index.js +15 -0
- package/dist/web/compare/provenance.d.ts +48 -0
- package/dist/web/compare/provenance.js +86 -0
- package/dist/web/compare/regions.d.ts +78 -0
- package/dist/web/compare/regions.js +211 -0
- package/dist/web/cookies/extract.d.ts +26 -0
- package/dist/web/cookies/extract.js +167 -0
- package/dist/web/cookies/index.d.ts +8 -0
- package/dist/web/cookies/index.js +7 -0
- package/dist/web/cookies/inject.d.ts +13 -0
- package/dist/web/cookies/inject.js +27 -0
- package/dist/web/cookies/setup.d.ts +31 -0
- package/dist/web/cookies/setup.js +94 -0
- package/dist/web/design/index.d.ts +9 -0
- package/dist/web/design/index.js +9 -0
- package/dist/web/design/inventory.d.ts +126 -0
- package/dist/web/design/inventory.js +324 -0
- package/dist/web/design/probe.d.ts +24 -0
- package/dist/web/design/probe.js +88 -0
- package/dist/web/element/actionable.d.ts +34 -0
- package/dist/web/element/actionable.js +48 -0
- package/dist/web/element/animations.d.ts +48 -0
- package/dist/web/element/animations.js +74 -0
- package/dist/web/element/box.d.ts +53 -0
- package/dist/web/element/box.js +58 -0
- package/dist/web/element/hover.d.ts +75 -0
- package/dist/web/element/hover.js +172 -0
- package/dist/web/element/index.d.ts +17 -0
- package/dist/web/element/index.js +17 -0
- package/dist/web/element/listeners.d.ts +72 -0
- package/dist/web/element/listeners.js +98 -0
- package/dist/web/element/measure.d.ts +40 -0
- package/dist/web/element/measure.js +118 -0
- package/dist/web/element/probes.d.ts +108 -0
- package/dist/web/element/probes.js +195 -0
- package/dist/web/element/pseudo.d.ts +51 -0
- package/dist/web/element/pseudo.js +96 -0
- package/dist/web/element/view.d.ts +30 -0
- package/dist/web/element/view.js +117 -0
- package/dist/web/element/visibility.d.ts +42 -0
- package/dist/web/element/visibility.js +67 -0
- package/dist/web/envelope/artifacts.d.ts +23 -0
- package/dist/web/envelope/artifacts.js +45 -0
- package/dist/web/envelope/index.d.ts +9 -0
- package/dist/web/envelope/index.js +9 -0
- package/dist/web/envelope/naming.d.ts +23 -0
- package/dist/web/envelope/naming.js +43 -0
- package/dist/web/envelope/paged.d.ts +42 -0
- package/dist/web/envelope/paged.js +99 -0
- package/dist/web/envelope/sink.d.ts +43 -0
- package/dist/web/envelope/sink.js +67 -0
- package/dist/web/environment/devices.d.ts +65 -0
- package/dist/web/environment/devices.js +82 -0
- package/dist/web/environment/emulation.d.ts +124 -0
- package/dist/web/environment/emulation.js +258 -0
- package/dist/web/environment/index.d.ts +15 -0
- package/dist/web/environment/index.js +15 -0
- package/dist/web/environment/probes.d.ts +18 -0
- package/dist/web/environment/probes.js +40 -0
- package/dist/web/environment/shaping.d.ts +90 -0
- package/dist/web/environment/shaping.js +141 -0
- package/dist/web/environment/status.d.ts +51 -0
- package/dist/web/environment/status.js +109 -0
- package/dist/web/environment/storage.d.ts +55 -0
- package/dist/web/environment/storage.js +142 -0
- package/dist/web/environment/tabs.d.ts +32 -0
- package/dist/web/environment/tabs.js +47 -0
- package/dist/web/environment/view.d.ts +10 -0
- package/dist/web/environment/view.js +42 -0
- package/dist/web/evaluate/index.d.ts +6 -0
- package/dist/web/evaluate/index.js +6 -0
- package/dist/web/evaluate/outcome.d.ts +74 -0
- package/dist/web/evaluate/outcome.js +91 -0
- package/dist/web/evaluate/probe.d.ts +31 -0
- package/dist/web/evaluate/probe.js +303 -0
- package/dist/web/index.d.ts +47 -0
- package/dist/web/index.js +47 -0
- package/dist/web/input/index.d.ts +11 -0
- package/dist/web/input/index.js +11 -0
- package/dist/web/input/keys.d.ts +48 -0
- package/dist/web/input/keys.js +189 -0
- package/dist/web/input/pointer.d.ts +56 -0
- package/dist/web/input/pointer.js +77 -0
- package/dist/web/input/touch.d.ts +61 -0
- package/dist/web/input/touch.js +83 -0
- package/dist/web/mermaid.d.ts +69 -0
- package/dist/web/mermaid.js +223 -0
- package/dist/web/paths.d.ts +16 -0
- package/dist/web/paths.js +27 -0
- package/dist/web/perf/heap.d.ts +42 -0
- package/dist/web/perf/heap.js +73 -0
- package/dist/web/perf/index.d.ts +14 -0
- package/dist/web/perf/index.js +14 -0
- package/dist/web/perf/layers.d.ts +98 -0
- package/dist/web/perf/layers.js +165 -0
- package/dist/web/perf/probe.d.ts +26 -0
- package/dist/web/perf/probe.js +123 -0
- package/dist/web/perf/profile.d.ts +53 -0
- package/dist/web/perf/profile.js +97 -0
- package/dist/web/perf/trace.d.ts +152 -0
- package/dist/web/perf/trace.js +429 -0
- package/dist/web/perf/view.d.ts +6 -0
- package/dist/web/perf/view.js +86 -0
- package/dist/web/perf/vitals.d.ts +141 -0
- package/dist/web/perf/vitals.js +175 -0
- package/dist/web/reader.d.ts +131 -0
- package/dist/web/reader.js +340 -0
- package/dist/web/screenshot.d.ts +50 -0
- package/dist/web/screenshot.js +108 -0
- package/dist/web/search.d.ts +49 -0
- package/dist/web/search.js +137 -0
- package/dist/web/session/artifacts.d.ts +32 -0
- package/dist/web/session/artifacts.js +47 -0
- package/dist/web/session/emulation.d.ts +118 -0
- package/dist/web/session/emulation.js +307 -0
- package/dist/web/session/recording.d.ts +51 -0
- package/dist/web/session/recording.js +64 -0
- package/dist/web/session/settling.d.ts +42 -0
- package/dist/web/session/settling.js +104 -0
- package/dist/web/session/shaping.d.ts +48 -0
- package/dist/web/session/shaping.js +113 -0
- package/dist/web/session/sourcemaps.d.ts +64 -0
- package/dist/web/session/sourcemaps.js +135 -0
- package/dist/web/session/telemetry.d.ts +219 -0
- package/dist/web/session/telemetry.js +486 -0
- package/dist/web/session/wires.d.ts +22 -0
- package/dist/web/session/wires.js +11 -0
- package/dist/web/session.d.ts +1153 -0
- package/dist/web/session.js +3400 -0
- package/dist/web/snapshot/deep.d.ts +36 -0
- package/dist/web/snapshot/deep.js +91 -0
- package/dist/web/snapshot/flatten.d.ts +101 -0
- package/dist/web/snapshot/flatten.js +131 -0
- package/dist/web/snapshot/index.d.ts +8 -0
- package/dist/web/snapshot/index.js +10 -0
- package/dist/web/snapshot/presented.d.ts +32 -0
- package/dist/web/snapshot/presented.js +74 -0
- package/dist/web/snapshot/query.d.ts +67 -0
- package/dist/web/snapshot/query.js +136 -0
- package/dist/web/sourcemap/index.d.ts +9 -0
- package/dist/web/sourcemap/index.js +9 -0
- package/dist/web/sourcemap/mappings.d.ts +111 -0
- package/dist/web/sourcemap/mappings.js +218 -0
- package/dist/web/styles/capture.d.ts +49 -0
- package/dist/web/styles/capture.js +85 -0
- package/dist/web/styles/cascade.d.ts +107 -0
- package/dist/web/styles/cascade.js +275 -0
- package/dist/web/styles/curation.d.ts +45 -0
- package/dist/web/styles/curation.js +282 -0
- package/dist/web/styles/index.d.ts +10 -0
- package/dist/web/styles/index.js +10 -0
- package/dist/web/target/index.d.ts +7 -0
- package/dist/web/target/index.js +7 -0
- package/dist/web/target/refusals.d.ts +59 -0
- package/dist/web/target/refusals.js +305 -0
- package/dist/web/target/target.d.ts +59 -0
- package/dist/web/target/target.js +94 -0
- package/dist/web/telemetry/buffer.d.ts +35 -0
- package/dist/web/telemetry/buffer.js +72 -0
- package/dist/web/telemetry/console.d.ts +127 -0
- package/dist/web/telemetry/console.js +217 -0
- package/dist/web/telemetry/dialogs.d.ts +41 -0
- package/dist/web/telemetry/dialogs.js +38 -0
- package/dist/web/telemetry/downloads.d.ts +42 -0
- package/dist/web/telemetry/downloads.js +65 -0
- package/dist/web/telemetry/failure.d.ts +31 -0
- package/dist/web/telemetry/failure.js +72 -0
- package/dist/web/telemetry/har.d.ts +97 -0
- package/dist/web/telemetry/har.js +227 -0
- package/dist/web/telemetry/index.d.ts +18 -0
- package/dist/web/telemetry/index.js +18 -0
- package/dist/web/telemetry/lifecycle.d.ts +69 -0
- package/dist/web/telemetry/lifecycle.js +120 -0
- package/dist/web/telemetry/network.d.ts +152 -0
- package/dist/web/telemetry/network.js +130 -0
- package/dist/web/telemetry/requests.d.ts +31 -0
- package/dist/web/telemetry/requests.js +119 -0
- package/dist/web/telemetry/sockets.d.ts +56 -0
- package/dist/web/telemetry/sockets.js +101 -0
- package/dist/web/telemetry/view.d.ts +16 -0
- package/dist/web/telemetry/view.js +125 -0
- package/dist/web/wait/conditions.d.ts +87 -0
- package/dist/web/wait/conditions.js +100 -0
- package/dist/web/wait/index.d.ts +6 -0
- package/dist/web/wait/index.js +6 -0
- package/dist/web/wait/settle.d.ts +54 -0
- package/dist/web/wait/settle.js +84 -0
- package/dist/work/ask.d.ts +45 -0
- package/dist/work/ask.js +85 -0
- package/dist/work/author.d.ts +61 -0
- package/dist/work/author.js +144 -0
- package/dist/work/broker.d.ts +151 -0
- package/dist/work/broker.js +165 -0
- package/dist/work/events.d.ts +98 -0
- package/dist/work/events.js +57 -0
- package/dist/work/history.d.ts +69 -0
- package/dist/work/history.js +174 -0
- package/dist/work/index.d.ts +49 -0
- package/dist/work/index.js +33 -0
- package/dist/work/inplay.d.ts +43 -0
- package/dist/work/inplay.js +42 -0
- package/dist/work/memory.d.ts +84 -0
- package/dist/work/memory.js +203 -0
- package/dist/work/objection.d.ts +92 -0
- package/dist/work/objection.js +72 -0
- package/dist/work/provider.d.ts +79 -0
- package/dist/work/provider.js +45 -0
- package/dist/work/providers/git.d.ts +29 -0
- package/dist/work/providers/git.js +87 -0
- package/dist/work/publish.d.ts +53 -0
- package/dist/work/publish.js +139 -0
- package/dist/work/rebase.d.ts +78 -0
- package/dist/work/rebase.js +261 -0
- package/dist/work/reclaim.d.ts +56 -0
- package/dist/work/reclaim.js +52 -0
- package/dist/work/register.d.ts +29 -0
- package/dist/work/register.js +41 -0
- package/dist/work/stack.d.ts +126 -0
- package/dist/work/stack.js +243 -0
- package/dist/work/stacks.d.ts +156 -0
- package/dist/work/stacks.js +524 -0
- package/dist/work/survey.d.ts +52 -0
- package/dist/work/survey.js +58 -0
- package/dist/work/tidy.d.ts +147 -0
- package/dist/work/tidy.js +136 -0
- package/dist/work/tree.d.ts +120 -0
- package/dist/work/tree.js +69 -0
- package/dist/work/unattended.d.ts +40 -0
- package/dist/work/unattended.js +57 -0
- package/package.json +145 -0
|
@@ -0,0 +1,3400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A browser session: one tab, driven by observe-then-act.
|
|
3
|
+
*
|
|
4
|
+
* observe renders the page's accessibility tree as a
|
|
5
|
+
* role-and-name outline (plus optional screenshot and
|
|
6
|
+
* readable text); act targets an element the way the model
|
|
7
|
+
* named it, role plus accessible name, disambiguated by
|
|
8
|
+
* container or ordinal, using the browser's own accessibility
|
|
9
|
+
* matching. Opaque node handles never reach the model.
|
|
10
|
+
*
|
|
11
|
+
* web_read is a one-shot over a session; the browser drive
|
|
12
|
+
* tool holds one open across tool calls. Same code path,
|
|
13
|
+
* different lifetime.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
// The key table is reached through the driver's declared
|
|
18
|
+
// internal subpath rather than its barrel, which marks the
|
|
19
|
+
// table private and strips it from the public types. Copying
|
|
20
|
+
// the 255 entries instead would mean maintaining a second
|
|
21
|
+
// opinion about which keys exist, and being wrong separately.
|
|
22
|
+
import { _keyDefinitions } from "puppeteer-core/internal/common/USKeyboardLayout.js";
|
|
23
|
+
import { FOCUS_PROBE, normalizeAxTree, renderAxOutline, renderReading, scopeTree, spliceFrames, subtreeAt, WALK_COLLECT, WALK_READ, WALK_REMEMBER, WALK_RESTORE, } from "./a11y/index.js";
|
|
24
|
+
import { newContextPage } from "./browser.js";
|
|
25
|
+
import { compareImages, readPng } from "./compare/images.js";
|
|
26
|
+
import { describeDrift, parse as parseProvenance, sidecarFor, stringify, } from "./compare/provenance.js";
|
|
27
|
+
import { injectCookies, isSetUp } from "./cookies/index.js";
|
|
28
|
+
import { ANCESTORS_PROBE, ANIMATIONS_PROBE, CONTENT_PROBE, diffStyles, foldHover, HIDE_TEXT, HOVER_SCAN, judgeActionability, judgeVisibility, MAX_HOVER_CANDIDATES, measureBetween, normalizeAnimations, normalizeBoxModel, normalizeListeners, OCCLUDER_PROBE, OWN_TEXT_PROBE, PAINTING_ELEMENT_PROBE, SELECT_TEXT_PROBE, SETTLE_PROBE, sameBox, } from "./element/index.js";
|
|
29
|
+
import { DIR_MODE, FILE_MODE, pathComponent } from "./envelope/index.js";
|
|
30
|
+
import { captureState, chooseTab, matchesPattern, } from "./environment/index.js";
|
|
31
|
+
import { parseChords, } from "./input/index.js";
|
|
32
|
+
import { defaultBrowserDataRoot } from "./paths.js";
|
|
33
|
+
import { inFlight, isIdle, } from "./wait/index.js";
|
|
34
|
+
/**
|
|
35
|
+
* The styles a snapshot carries by default.
|
|
36
|
+
*
|
|
37
|
+
* Enough to answer why something is not where it should be,
|
|
38
|
+
* without dragging the whole computed style of every node into
|
|
39
|
+
* the answer.
|
|
40
|
+
*/
|
|
41
|
+
const SNAPSHOT_STYLES = [
|
|
42
|
+
"display",
|
|
43
|
+
"visibility",
|
|
44
|
+
"opacity",
|
|
45
|
+
"position",
|
|
46
|
+
"color",
|
|
47
|
+
"background-color",
|
|
48
|
+
"font-size",
|
|
49
|
+
];
|
|
50
|
+
/** Extra stops beyond two full passes, to show a cycle clearly. */
|
|
51
|
+
const WALK_SLACK = 4;
|
|
52
|
+
/**
|
|
53
|
+
* How far the walk goes when nobody said, however large the page.
|
|
54
|
+
*
|
|
55
|
+
* A ceiling on the default rather than on the caller: asked for
|
|
56
|
+
* more stops than this, the walk takes them.
|
|
57
|
+
*/
|
|
58
|
+
const MAX_WALK_STOPS = 400;
|
|
59
|
+
/** How many trailing stops to inspect for being stuck. */
|
|
60
|
+
const WALK_STUCK_SAMPLE = 4;
|
|
61
|
+
/** What a stop outside the candidates has for a style. */
|
|
62
|
+
const BLANK_STYLE = {
|
|
63
|
+
outlineStyle: "none",
|
|
64
|
+
outlineWidth: "0px",
|
|
65
|
+
outlineColor: "",
|
|
66
|
+
boxShadow: "none",
|
|
67
|
+
backgroundColor: "",
|
|
68
|
+
borderColor: "",
|
|
69
|
+
color: "",
|
|
70
|
+
};
|
|
71
|
+
/** How long a wait runs before it gives up and says so. */
|
|
72
|
+
const DEFAULT_WAIT_MS = 10_000;
|
|
73
|
+
/** How often a wait looks again. */
|
|
74
|
+
const WAIT_POLL_MS = 100;
|
|
75
|
+
/**
|
|
76
|
+
* Every key the driver knows how to send.
|
|
77
|
+
*
|
|
78
|
+
* The chord parser is given this rather than keeping its own
|
|
79
|
+
* copy, so there is one table and it is the one that will
|
|
80
|
+
* actually be used to dispatch.
|
|
81
|
+
*/
|
|
82
|
+
const KNOWN_KEYS = new Set(Object.keys(_keyDefinitions));
|
|
83
|
+
import { readFile } from "node:fs/promises";
|
|
84
|
+
import { createRequire } from "node:module";
|
|
85
|
+
import { buildStructure, enabledRules, foldBehind, foldPair, parseRgb, readAxeRun, TARGET_CAPTURE, visualCaptureSource, } from "./audit/index.js";
|
|
86
|
+
import { inventorySource } from "./design/index.js";
|
|
87
|
+
import { describeThrow, evaluationSource, } from "./evaluate/index.js";
|
|
88
|
+
import { categoriesFor, compareHeap, foldLayers, foldProfile, foldTrace, LAYER_SETTLE_MS, nameNode, observerBootstrap, readVitalsSource, } from "./perf/index.js";
|
|
89
|
+
import { captureTiles } from "./screenshot.js";
|
|
90
|
+
import { ArtifactLedger } from "./session/artifacts.js";
|
|
91
|
+
import { EmulationController } from "./session/emulation.js";
|
|
92
|
+
import { claimRecording, describeRecording, recordingInProgress, releaseRecording, } from "./session/recording.js";
|
|
93
|
+
import { PageSettler } from "./session/settling.js";
|
|
94
|
+
import { NetworkShaper } from "./session/shaping.js";
|
|
95
|
+
import { SourceMapStore } from "./session/sourcemaps.js";
|
|
96
|
+
import { SessionTelemetry } from "./session/telemetry.js";
|
|
97
|
+
import { flattenSnapshot, } from "./snapshot/index.js";
|
|
98
|
+
import { asCall, COMPUTED_STYLE_PROBE, curateStyles, INITIALS_PROBE, normalizeCascade, SHORTHAND_PROPERTIES, traceProperty, } from "./styles/index.js";
|
|
99
|
+
import { ambiguityRefusal, notFoundRefusal, resolveTarget, } from "./target/index.js";
|
|
100
|
+
import { diedWithTheTab, failureText, requestStatus, toHar, } from "./telemetry/index.js";
|
|
101
|
+
/**
|
|
102
|
+
* How long to let a state settle before reading it.
|
|
103
|
+
*
|
|
104
|
+
* Forcing a state starts whatever transition the page declared,
|
|
105
|
+
* so a reading taken at once catches the resting values and
|
|
106
|
+
* reports that nothing changed. The wait is on the browser's
|
|
107
|
+
* own promise that its animations have finished; this only caps
|
|
108
|
+
* a page that keeps starting new ones.
|
|
109
|
+
*/
|
|
110
|
+
const SETTLE_CAP_MS = 2000;
|
|
111
|
+
/**
|
|
112
|
+
* How long to wait for an element to become ready, and how
|
|
113
|
+
* often to look. A page that animates a dialog in takes a few
|
|
114
|
+
* hundred milliseconds, and waiting is far cheaper than a click
|
|
115
|
+
* that silently misses.
|
|
116
|
+
*/
|
|
117
|
+
const READY_BUDGET_MS = 2000;
|
|
118
|
+
const READY_POLL_MS = 100;
|
|
119
|
+
/**
|
|
120
|
+
* How long to give Chrome to announce a crash that has already
|
|
121
|
+
* broken a call, and how often to look for the announcement.
|
|
122
|
+
*
|
|
123
|
+
* Only spent on a call that has just failed with the tab dying
|
|
124
|
+
* underneath it, so the wait is never on the healthy path. The
|
|
125
|
+
* announcement arrives in milliseconds; the budget is generous
|
|
126
|
+
* because the alternative to waiting is losing the call.
|
|
127
|
+
*/
|
|
128
|
+
const CRASH_NOTICE_BUDGET_MS = 2000;
|
|
129
|
+
const CRASH_NOTICE_POLL_MS = 25;
|
|
130
|
+
/**
|
|
131
|
+
* Whether an action arrives by pointer.
|
|
132
|
+
*
|
|
133
|
+
* A pointer has to reach the element's centre unobstructed and
|
|
134
|
+
* on screen. Everything else addresses the element directly and
|
|
135
|
+
* would succeed where a click could not.
|
|
136
|
+
*/
|
|
137
|
+
/** Whether claiming the recording permit came back as a refusal. */
|
|
138
|
+
function isRecordingRefused(value) {
|
|
139
|
+
return typeof value === "object" && value !== null && "refusal" in value;
|
|
140
|
+
}
|
|
141
|
+
function usesPointer(action) {
|
|
142
|
+
return action.kind === "click" || action.kind === "hover";
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* What this action needs, which for one action is unusually
|
|
146
|
+
* little.
|
|
147
|
+
*
|
|
148
|
+
* Choosing a file happens in the operating system's dialog, and
|
|
149
|
+
* the input that receives it is nearly always hidden with a
|
|
150
|
+
* styled label over it, which is the recommended way to build
|
|
151
|
+
* one. So an upload asks neither to be seen nor to be reachable
|
|
152
|
+
* by a pointer: it asks that the input exists and takes files.
|
|
153
|
+
*
|
|
154
|
+
* It also does without the driver's handle. The aria selector
|
|
155
|
+
* that produces one does not offer a node it considers hidden,
|
|
156
|
+
* so for the ordinary hidden input the two matchers disagree,
|
|
157
|
+
* our own outline finds it and the selector does not, and the
|
|
158
|
+
* act refuses something it had just listed. The file is set
|
|
159
|
+
* through the protocol against the node the outline resolved.
|
|
160
|
+
*/
|
|
161
|
+
function needsOf(action) {
|
|
162
|
+
if (action.kind === "upload") {
|
|
163
|
+
return { pointer: false, sight: false, handle: false };
|
|
164
|
+
}
|
|
165
|
+
return { pointer: usesPointer(action), sight: true, handle: true };
|
|
166
|
+
}
|
|
167
|
+
/** Chrome cookie injection was asked for but is not set up. */
|
|
168
|
+
export class CookieSetupNeeded extends Error {
|
|
169
|
+
constructor() {
|
|
170
|
+
super("Chrome cookie injection is not set up. Run Chrome cookie " +
|
|
171
|
+
"setup, or open the session without cookies.");
|
|
172
|
+
this.name = "CookieSetupNeeded";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** A driveable browser session over a single tab. */
|
|
176
|
+
/**
|
|
177
|
+
* Where axe-core's bundle sits on disk.
|
|
178
|
+
*
|
|
179
|
+
* Resolved through node rather than assembled from a relative
|
|
180
|
+
* path, so it keeps working wherever the package is installed
|
|
181
|
+
* and however the dependency is hoisted.
|
|
182
|
+
*/
|
|
183
|
+
function axeSource() {
|
|
184
|
+
return createRequire(import.meta.url).resolve("axe-core/axe.min.js");
|
|
185
|
+
}
|
|
186
|
+
export class BrowserSession {
|
|
187
|
+
name;
|
|
188
|
+
page;
|
|
189
|
+
cdp;
|
|
190
|
+
context;
|
|
191
|
+
options;
|
|
192
|
+
/** What every property computes to untouched, read once. */
|
|
193
|
+
initialStyles;
|
|
194
|
+
/** The recovery in flight, so nothing reads a tab mid-replacement. */
|
|
195
|
+
recovery;
|
|
196
|
+
/** The live view collaborators drive the browser through. */
|
|
197
|
+
wires = {
|
|
198
|
+
page: () => this.page,
|
|
199
|
+
cdp: () => this.cdp,
|
|
200
|
+
context: () => this.context,
|
|
201
|
+
ready: () => this.ready(),
|
|
202
|
+
};
|
|
203
|
+
/** The maps the page has declared, for authored positions. */
|
|
204
|
+
sourceMaps = new SourceMapStore(this.wires);
|
|
205
|
+
/** What this session pretends to be, and its keeper. */
|
|
206
|
+
emulation = new EmulationController(this.wires, {
|
|
207
|
+
settle: () => this.settlePage(),
|
|
208
|
+
});
|
|
209
|
+
/** The rules and throttle this session's network answers to. */
|
|
210
|
+
shaper = new NetworkShaper(this.wires);
|
|
211
|
+
/** The disk side of this session: sink, stamps and ledger. */
|
|
212
|
+
artifacts = new ArtifactLedger();
|
|
213
|
+
/** The wait between a change and an honest reading of it. */
|
|
214
|
+
settler = new PageSettler(this.wires, () => this.requests());
|
|
215
|
+
/** Everything this session overhears, and its buffers. */
|
|
216
|
+
telemetry = new SessionTelemetry(this.wires, {
|
|
217
|
+
onCrash: () => {
|
|
218
|
+
this.recovery = this.recover();
|
|
219
|
+
},
|
|
220
|
+
downloadDir: () => this.artifacts.sink().dir,
|
|
221
|
+
keep: (path) => {
|
|
222
|
+
this.artifacts.keep(path);
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
constructor(name,
|
|
226
|
+
// The page and its protocol channel are replaced wholesale
|
|
227
|
+
// when the tab crashes, so neither can be readonly.
|
|
228
|
+
page, cdp, context, options) {
|
|
229
|
+
this.name = name;
|
|
230
|
+
this.page = page;
|
|
231
|
+
this.cdp = cdp;
|
|
232
|
+
this.context = context;
|
|
233
|
+
this.options = options;
|
|
234
|
+
}
|
|
235
|
+
/** Wall clock time of the last operation. See ready(). */
|
|
236
|
+
usedAt = Date.now();
|
|
237
|
+
/**
|
|
238
|
+
* Open a fresh session in a browser context of its own, so
|
|
239
|
+
* its cookies, storage and cache belong to it alone.
|
|
240
|
+
*/
|
|
241
|
+
static async open(name, options = {}) {
|
|
242
|
+
if (options.cookies && !isSetUp())
|
|
243
|
+
throw new CookieSetupNeeded();
|
|
244
|
+
const { page, context } = await newContextPage();
|
|
245
|
+
try {
|
|
246
|
+
const cdp = await page.createCDPSession();
|
|
247
|
+
await cdp.send("Accessibility.enable");
|
|
248
|
+
// The element domain reads boxes and the cascade, which
|
|
249
|
+
// need the DOM and CSS agents running.
|
|
250
|
+
await cdp.send("DOM.enable");
|
|
251
|
+
await cdp.send("CSS.enable");
|
|
252
|
+
const session = new BrowserSession(name, page, cdp, context, options);
|
|
253
|
+
await session.telemetry.listenForAnnouncements();
|
|
254
|
+
await session.telemetry.listenForLogs();
|
|
255
|
+
await session.telemetry.listenForRequests();
|
|
256
|
+
await session.telemetry.listenForDialogs();
|
|
257
|
+
await session.telemetry.listenForLifecycle();
|
|
258
|
+
await session.telemetry.listenForDownloads();
|
|
259
|
+
await session.sourceMaps.listen();
|
|
260
|
+
await session.watchVitals();
|
|
261
|
+
return session;
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
// Do not leak the context if the CDP channel could not be
|
|
265
|
+
// set up; close it before surfacing the failure.
|
|
266
|
+
await context.close().catch(() => { });
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** Decide how dialogs will be answered from here on. */
|
|
271
|
+
setDialogPolicy(policy) {
|
|
272
|
+
this.telemetry.setDialogPolicy(policy);
|
|
273
|
+
}
|
|
274
|
+
/** How dialogs are currently answered. */
|
|
275
|
+
get dialogs() {
|
|
276
|
+
return this.telemetry.dialogs;
|
|
277
|
+
}
|
|
278
|
+
/** Every request the page has made. */
|
|
279
|
+
requests() {
|
|
280
|
+
return this.telemetry.requests();
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Write a capture out as an HTTP Archive.
|
|
284
|
+
*
|
|
285
|
+
* Bodies are fetched for the requests being exported, since an
|
|
286
|
+
* archive without them answers far fewer questions than one
|
|
287
|
+
* with them, and an export is already an explicit ask.
|
|
288
|
+
*/
|
|
289
|
+
async exportHar(requests) {
|
|
290
|
+
const bodies = new Map();
|
|
291
|
+
for (const request of requests) {
|
|
292
|
+
const fetched = await this.bodyOf(request.id);
|
|
293
|
+
if (fetched && fetched.body.length > 0)
|
|
294
|
+
bodies.set(request.id, fetched);
|
|
295
|
+
}
|
|
296
|
+
const path = this.artifacts
|
|
297
|
+
.sink()
|
|
298
|
+
.writeText(`capture-${this.artifacts.nextArchive()}.har`, JSON.stringify(toHar(requests, { bodies }), null, 2));
|
|
299
|
+
return this.artifacts.keep(path);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The body of one recorded reply, fetched on demand.
|
|
303
|
+
*
|
|
304
|
+
* Bodies are not captured as they stream past: most are never
|
|
305
|
+
* asked for, and holding every one would cost far more memory
|
|
306
|
+
* than the rest of the session put together. Chrome keeps them
|
|
307
|
+
* for the current document, so this asks only when asked.
|
|
308
|
+
*/
|
|
309
|
+
async bodyOf(requestId) {
|
|
310
|
+
return this.telemetry.bodyOf(requestId);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* What the page has said, from a cursor onward.
|
|
314
|
+
*
|
|
315
|
+
* The buffer survives navigation on purpose: a message logged
|
|
316
|
+
* just before a redirect is often the one that explains it.
|
|
317
|
+
*/
|
|
318
|
+
logs(since) {
|
|
319
|
+
return this.telemetry.logs(since);
|
|
320
|
+
}
|
|
321
|
+
/** Navigate the tab to a URL and wait for the network to settle. */
|
|
322
|
+
async navigate(url) {
|
|
323
|
+
await this.ready();
|
|
324
|
+
// Cookies are per-domain, so they are injected against the
|
|
325
|
+
// URL we are about to visit rather than once at open.
|
|
326
|
+
if (this.options.cookies)
|
|
327
|
+
await injectCookies(this.page, url);
|
|
328
|
+
// A navigation that never arrives is an answer, not an
|
|
329
|
+
// exception. Offline, or against a host that does not resolve,
|
|
330
|
+
// this threw a raw driver error straight past a doc that
|
|
331
|
+
// promises the opposite two lines below, and the caller got a
|
|
332
|
+
// stack trace where it had asked a question. What the network
|
|
333
|
+
// said is the useful part, so it is returned.
|
|
334
|
+
let response = null;
|
|
335
|
+
try {
|
|
336
|
+
response = await this.page.goto(url, { waitUntil: "networkidle2" });
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
const failure = failureText(error);
|
|
340
|
+
// The tab can die under this very call, and Chrome tells the
|
|
341
|
+
// driver the frame is detached before it announces the crash
|
|
342
|
+
// that detached it. Returning here would hand the caller a
|
|
343
|
+
// driver error for a session that is about to be perfectly
|
|
344
|
+
// healthy, and leave it parked on the replacement's blank
|
|
345
|
+
// page: measured, a crash made a session permanently
|
|
346
|
+
// unnavigable while every later call reported success.
|
|
347
|
+
if (!diedWithTheTab(failure) || !(await this.awaitReplacement())) {
|
|
348
|
+
return { failure };
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
response = await this.page.goto(url, { waitUntil: "networkidle2" });
|
|
352
|
+
}
|
|
353
|
+
catch (afterRecovery) {
|
|
354
|
+
return { failure: failureText(afterRecovery) };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
await this.emulation.reassert();
|
|
358
|
+
// Chrome's idle heuristics fire before a client-rendered app
|
|
359
|
+
// has anything on screen, so this waits for the DOM as well.
|
|
360
|
+
// Without it, navigating to a real application answered with an
|
|
361
|
+
// outline whose only content was "Loading page".
|
|
362
|
+
await this.settlePage();
|
|
363
|
+
// The status was thrown away, so a 404 or a 500 read as a
|
|
364
|
+
// successful arrival and every later check judged the error
|
|
365
|
+
// page. Returned rather than thrown: an error page is
|
|
366
|
+
// sometimes exactly what a caller means to look at, and the
|
|
367
|
+
// answer should say which one they got.
|
|
368
|
+
const status = response?.status();
|
|
369
|
+
return status === undefined ? {} : { status };
|
|
370
|
+
}
|
|
371
|
+
/** Where the session currently is. */
|
|
372
|
+
get url() {
|
|
373
|
+
return this.page.url();
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Fetch the page again.
|
|
377
|
+
*
|
|
378
|
+
* ignoreCache is the useful default for a developer, who
|
|
379
|
+
* reloads to see a change they just made. A reload that served
|
|
380
|
+
* the old file back would be the one thing it must not do.
|
|
381
|
+
*/
|
|
382
|
+
async reload() {
|
|
383
|
+
await this.ready();
|
|
384
|
+
try {
|
|
385
|
+
await this.page.reload({ waitUntil: "networkidle2" });
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
// Same reasoning as navigate: reloading with no network is a
|
|
389
|
+
// thing a tester does deliberately.
|
|
390
|
+
return { failure: failureText(error) };
|
|
391
|
+
}
|
|
392
|
+
await this.emulation.reassert();
|
|
393
|
+
await this.settlePage();
|
|
394
|
+
return {};
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Wait for the page to stop changing, so a read that follows
|
|
398
|
+
* describes where the page ended up rather than where it was.
|
|
399
|
+
*
|
|
400
|
+
* Returns what it found instead of throwing on a page that never
|
|
401
|
+
* settles: something that animates or polls for ever is still
|
|
402
|
+
* worth reading, as long as the answer does not pretend it was
|
|
403
|
+
* final.
|
|
404
|
+
*/
|
|
405
|
+
async settlePage(budgetMs) {
|
|
406
|
+
return this.settler.settle(budgetMs);
|
|
407
|
+
}
|
|
408
|
+
/** What the last settle saw, for a reader that wants to say so. */
|
|
409
|
+
get settledLast() {
|
|
410
|
+
return this.settler.lastSeen;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Step through the history the session accumulated.
|
|
414
|
+
*
|
|
415
|
+
* Refuses rather than doing nothing at either end. Silently
|
|
416
|
+
* staying put looks identical to a page that ignored the
|
|
417
|
+
* request, and the caller then has no way to tell whether
|
|
418
|
+
* going back was possible at all.
|
|
419
|
+
*
|
|
420
|
+
* The refusal is driven by the thrown error, not by a null
|
|
421
|
+
* return. Puppeteer returns null whenever a navigation has no
|
|
422
|
+
* HTTP response, which includes about:blank and same-document
|
|
423
|
+
* moves, so reading null as failure reported a successful step
|
|
424
|
+
* back to about:blank as an impossible one. An absent entry is
|
|
425
|
+
* what throws.
|
|
426
|
+
*/
|
|
427
|
+
async step(direction) {
|
|
428
|
+
await this.ready();
|
|
429
|
+
try {
|
|
430
|
+
if (direction === "back") {
|
|
431
|
+
await this.page.goBack({ waitUntil: "networkidle2" });
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
await this.page.goForward({ waitUntil: "networkidle2" });
|
|
435
|
+
}
|
|
436
|
+
await this.emulation.reassert();
|
|
437
|
+
await this.settlePage();
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
// Not every failure here is an empty history. Stepping back
|
|
441
|
+
// with the network off fails too, and this used to answer
|
|
442
|
+
// that there was nothing behind the page: a confident, wrong
|
|
443
|
+
// account of the session's own history, which is the kind of
|
|
444
|
+
// lie that sends someone looking in the wrong place.
|
|
445
|
+
const said = failureText(error);
|
|
446
|
+
if (!/History entry/i.test(said)) {
|
|
447
|
+
return {
|
|
448
|
+
ok: false,
|
|
449
|
+
refusal: `Going ${direction} did not arrive: ${said}. The page ` +
|
|
450
|
+
"is where it was. Read requests to see what the network " +
|
|
451
|
+
"did.",
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
ok: false,
|
|
456
|
+
refusal: direction === "back"
|
|
457
|
+
? "There is nothing behind this page in the session's " +
|
|
458
|
+
"history. It is where the session started."
|
|
459
|
+
: "There is nothing ahead of this page. Going forward " +
|
|
460
|
+
"only works after going back.",
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
return { ok: true, url: this.page.url() };
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Render the page's accessibility outline, plus url and
|
|
467
|
+
* title. A scope narrows the tree before it is rendered; with
|
|
468
|
+
* none, the whole page is read.
|
|
469
|
+
*/
|
|
470
|
+
async observe(scope = {}, form = "outline") {
|
|
471
|
+
const tree = await this.axTree();
|
|
472
|
+
return await this.describe(scopeTree(tree, scope), form);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Read one branch of the page, named the way the caller names
|
|
476
|
+
* anything else. Unlike a whole-page read this can fail, so
|
|
477
|
+
* it reports what would have worked instead.
|
|
478
|
+
*/
|
|
479
|
+
async observeWithin(target, scope = {}, form = "outline") {
|
|
480
|
+
const tree = await this.axTree();
|
|
481
|
+
const resolution = resolveTarget(tree, target);
|
|
482
|
+
if (resolution.kind === "ambiguous") {
|
|
483
|
+
return { ok: false, refusal: ambiguityRefusal(tree, target) };
|
|
484
|
+
}
|
|
485
|
+
if (resolution.kind === "notFound") {
|
|
486
|
+
return { ok: false, refusal: notFoundRefusal(tree, target) };
|
|
487
|
+
}
|
|
488
|
+
const branch = subtreeAt(tree, resolution.backendDomId);
|
|
489
|
+
if (!branch) {
|
|
490
|
+
return { ok: false, refusal: notFoundRefusal(tree, target) };
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
ok: true,
|
|
494
|
+
observation: await this.describe(scopeTree(branch, scope), form),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
/** Wrap a tree as an observation of where the session is. */
|
|
498
|
+
async describe(tree, form) {
|
|
499
|
+
return {
|
|
500
|
+
url: this.page.url(),
|
|
501
|
+
title: await this.page.title(),
|
|
502
|
+
outline: form === "reading" ? renderReading(tree) : renderAxOutline(tree),
|
|
503
|
+
tree,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/** Perform an action, resolving semantic targets against the a11y tree. */
|
|
507
|
+
async act(action) {
|
|
508
|
+
if (action.kind === "navigate") {
|
|
509
|
+
await this.navigate(action.url);
|
|
510
|
+
return { ok: true };
|
|
511
|
+
}
|
|
512
|
+
// Readiness is established before targeting, because an
|
|
513
|
+
// element that has not appeared yet is the commonest reason
|
|
514
|
+
// an action is early rather than wrong.
|
|
515
|
+
const needs = needsOf(action);
|
|
516
|
+
const ready = await this.awaitReady(action.target, needs.pointer, needs.sight);
|
|
517
|
+
if (!ready.ready) {
|
|
518
|
+
// Never showing up and never existing look the same from
|
|
519
|
+
// here, and only one of them has a spelling suggestion
|
|
520
|
+
// worth making, so ask the targeting to explain itself.
|
|
521
|
+
const missing = await this.resolve(action.target);
|
|
522
|
+
if (!missing.ok)
|
|
523
|
+
return missing;
|
|
524
|
+
await missing.element.dispose();
|
|
525
|
+
return { ok: false, blocked: ready };
|
|
526
|
+
}
|
|
527
|
+
if (!needs.handle) {
|
|
528
|
+
await this.perform(action, undefined, ready.backendDomId);
|
|
529
|
+
if (ready.waitedMs > 0)
|
|
530
|
+
return { ok: true, waitedMs: ready.waitedMs };
|
|
531
|
+
return { ok: true };
|
|
532
|
+
}
|
|
533
|
+
const handle = await this.resolve(action.target);
|
|
534
|
+
if (!handle.ok)
|
|
535
|
+
return handle;
|
|
536
|
+
await this.perform(action, handle.element, ready.backendDomId);
|
|
537
|
+
await handle.element.dispose();
|
|
538
|
+
if (ready.waitedMs > 0)
|
|
539
|
+
return { ok: true, waitedMs: ready.waitedMs };
|
|
540
|
+
return { ok: true };
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Tell each frame of a stack where it was written.
|
|
544
|
+
*
|
|
545
|
+
* A frame with no map keeps its generated position rather than
|
|
546
|
+
* being dropped: half a stack is worse than a raw one.
|
|
547
|
+
*/
|
|
548
|
+
async resolveFrames(frames) {
|
|
549
|
+
return this.sourceMaps.resolveFrames(frames);
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Replace the dead tab with a live one.
|
|
553
|
+
*
|
|
554
|
+
* The context survives a tab crash and can still make pages,
|
|
555
|
+
* so the session keeps its identity, its cookies and every
|
|
556
|
+
* buffer it has filled; only the tab is new. Watchers have to
|
|
557
|
+
* be reinstalled because they were bound to a protocol channel
|
|
558
|
+
* that no longer answers.
|
|
559
|
+
*/
|
|
560
|
+
async recover() {
|
|
561
|
+
// The dead page will not answer, but closing it does work,
|
|
562
|
+
// and leaving it would leak a renderer for the session's life.
|
|
563
|
+
await this.page.close().catch(() => { });
|
|
564
|
+
await this.bindTo(await this.context.newPage());
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Point the session at a tab and make it whole again.
|
|
568
|
+
*
|
|
569
|
+
* Every watcher is bound to one tab's protocol channel, so a
|
|
570
|
+
* session that changes tabs without reinstalling them goes on
|
|
571
|
+
* answering while quietly deaf to everything the new tab does.
|
|
572
|
+
* That is the same wound a crash leaves, which is why recovering
|
|
573
|
+
* from a crash and switching tabs are one piece of code: two
|
|
574
|
+
* copies would drift, and the half that drifted would fail
|
|
575
|
+
* silently in exactly this way.
|
|
576
|
+
*/
|
|
577
|
+
async bindTo(page) {
|
|
578
|
+
const cdp = await page.createCDPSession();
|
|
579
|
+
this.page = page;
|
|
580
|
+
this.cdp = cdp;
|
|
581
|
+
await cdp.send("Accessibility.enable");
|
|
582
|
+
await cdp.send("DOM.enable");
|
|
583
|
+
await cdp.send("CSS.enable");
|
|
584
|
+
await this.telemetry.listenForAnnouncements();
|
|
585
|
+
await this.telemetry.listenForLogs();
|
|
586
|
+
await this.telemetry.listenForRequests();
|
|
587
|
+
await this.telemetry.listenForDialogs();
|
|
588
|
+
// Every listener open() installs has to come back, or the
|
|
589
|
+
// session goes on answering while quietly deaf to part of
|
|
590
|
+
// what the page does. Downloads and source maps were the two
|
|
591
|
+
// that did not, so after a crash a file the page handed back
|
|
592
|
+
// went unrecorded and a cascade trace lost its authored
|
|
593
|
+
// positions, with nothing anywhere saying why.
|
|
594
|
+
await this.telemetry.listenForDownloads();
|
|
595
|
+
await this.sourceMaps.listen();
|
|
596
|
+
// A fresh tab knows nothing of what the old one was
|
|
597
|
+
// pretending, so the standing intent is put back before
|
|
598
|
+
// anything reads from it.
|
|
599
|
+
await this.emulation.apply();
|
|
600
|
+
const { frameTree } = await cdp.send("Page.getFrameTree");
|
|
601
|
+
this.telemetry.readoptLifecycle(frameTree.frame.id);
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Wait for any recovery to finish.
|
|
605
|
+
*
|
|
606
|
+
* Called before anything that touches the tab, so a read that
|
|
607
|
+
* arrives during a crash waits for the replacement instead of
|
|
608
|
+
* hanging on a renderer that is never coming back.
|
|
609
|
+
*/
|
|
610
|
+
/**
|
|
611
|
+
* Wait for the replacement tab, when one is coming.
|
|
612
|
+
*
|
|
613
|
+
* The crash announcement that starts a recovery can arrive
|
|
614
|
+
* after the failure it caused, so asking whether a recovery is
|
|
615
|
+
* under way immediately gets the wrong answer. This gives the
|
|
616
|
+
* announcement its moment and then waits the recovery out,
|
|
617
|
+
* reporting whether there was one, so a caller that died for
|
|
618
|
+
* some other reason is not left waiting on a tab nobody is
|
|
619
|
+
* replacing.
|
|
620
|
+
*/
|
|
621
|
+
async awaitReplacement() {
|
|
622
|
+
const deadline = Date.now() + CRASH_NOTICE_BUDGET_MS;
|
|
623
|
+
while (!this.recovery && Date.now() < deadline) {
|
|
624
|
+
await new Promise((wake) => setTimeout(wake, CRASH_NOTICE_POLL_MS));
|
|
625
|
+
}
|
|
626
|
+
if (!this.recovery)
|
|
627
|
+
return false;
|
|
628
|
+
await this.recovery;
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
async ready() {
|
|
632
|
+
// Every operation passes through here, which makes it the one
|
|
633
|
+
// honest place to record that the session is being used. The
|
|
634
|
+
// registry reaps on idleness, and it used to measure that
|
|
635
|
+
// from when a call started rather than from the last thing
|
|
636
|
+
// the session did, so a health sweep or a long wait could
|
|
637
|
+
// have the browser closed out from under it mid-call.
|
|
638
|
+
this.usedAt = Date.now();
|
|
639
|
+
if (this.recovery)
|
|
640
|
+
await this.recovery;
|
|
641
|
+
}
|
|
642
|
+
/** When this session last did anything, for the idle reaper. */
|
|
643
|
+
get lastUsedAt() {
|
|
644
|
+
return this.usedAt;
|
|
645
|
+
}
|
|
646
|
+
/** Files the page has handed back. */
|
|
647
|
+
downloads() {
|
|
648
|
+
return this.telemetry.downloads();
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Record what the page's JavaScript is doing, for a while.
|
|
652
|
+
*
|
|
653
|
+
* A long task says three seconds went somewhere. This says
|
|
654
|
+
* where. The profiler is started, the page is left alone for
|
|
655
|
+
* the window asked for, and the samples are folded into time
|
|
656
|
+
* per function.
|
|
657
|
+
*
|
|
658
|
+
* Nothing is done to the page in between on purpose: whatever
|
|
659
|
+
* is being profiled has to be the page's own work, and a probe
|
|
660
|
+
* running during the window would appear in its own results.
|
|
661
|
+
*/
|
|
662
|
+
async profile(forMs) {
|
|
663
|
+
await this.ready();
|
|
664
|
+
await this.cdp.send("Profiler.enable");
|
|
665
|
+
// A thousand microseconds is Chrome's own default. Sampling
|
|
666
|
+
// faster buys precision the reader cannot use and costs the
|
|
667
|
+
// page time that then shows up in its own profile.
|
|
668
|
+
await this.cdp.send("Profiler.setSamplingInterval", { interval: 1000 });
|
|
669
|
+
await this.cdp.send("Profiler.start");
|
|
670
|
+
await new Promise((wake) => setTimeout(wake, forMs));
|
|
671
|
+
const { profile } = await this.cdp.send("Profiler.stop");
|
|
672
|
+
await this.cdp.send("Profiler.disable");
|
|
673
|
+
return foldProfile(profile);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Record a trace around some work, and say what it cost.
|
|
677
|
+
*
|
|
678
|
+
* The work runs inside the recording rather than before it,
|
|
679
|
+
* because a trace only holds what happened while it was on.
|
|
680
|
+
* Measured: a timer installed before the recording starts has
|
|
681
|
+
* no install event, so its lateness cannot be explained and a
|
|
682
|
+
* fetch begun earlier has no url. Tracing after the fact is
|
|
683
|
+
* not a weaker answer, it is no answer.
|
|
684
|
+
*
|
|
685
|
+
* The work's own result is handed back whatever the recording
|
|
686
|
+
* did, so a refused permit or a protocol failure costs the
|
|
687
|
+
* caller the trace and never the action.
|
|
688
|
+
*/
|
|
689
|
+
async recordWhile(profiles, work) {
|
|
690
|
+
await this.ready();
|
|
691
|
+
const permit = claimRecording(this.name, profiles);
|
|
692
|
+
if (isRecordingRefused(permit)) {
|
|
693
|
+
return { result: await work(), refusal: permit.refusal };
|
|
694
|
+
}
|
|
695
|
+
const events = [];
|
|
696
|
+
const collect = (batch) => {
|
|
697
|
+
if (batch.value)
|
|
698
|
+
events.push(...batch.value);
|
|
699
|
+
};
|
|
700
|
+
this.cdp.on("Tracing.dataCollected", collect);
|
|
701
|
+
let started = false;
|
|
702
|
+
try {
|
|
703
|
+
await this.cdp.send("Tracing.start", {
|
|
704
|
+
transferMode: "ReportEvents",
|
|
705
|
+
traceConfig: {
|
|
706
|
+
recordMode: "recordContinuously",
|
|
707
|
+
includedCategories: [...categoriesFor(profiles)],
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
started = true;
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
this.cdp.off("Tracing.dataCollected", collect);
|
|
714
|
+
releaseRecording();
|
|
715
|
+
return {
|
|
716
|
+
result: await work(),
|
|
717
|
+
refusal: `The browser refused to start tracing: ${String(error)}`,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
// The action runs whatever the recording does next, and the
|
|
721
|
+
// permit is given back on every path out of here.
|
|
722
|
+
try {
|
|
723
|
+
const result = await work();
|
|
724
|
+
const delivered = new Promise((resolve) => {
|
|
725
|
+
this.cdp.once("Tracing.tracingComplete", () => resolve());
|
|
726
|
+
});
|
|
727
|
+
await this.cdp.send("Tracing.end");
|
|
728
|
+
await delivered;
|
|
729
|
+
return {
|
|
730
|
+
result,
|
|
731
|
+
trace: foldTrace(events, {
|
|
732
|
+
frames: await this.ownFrameIds(),
|
|
733
|
+
profiles,
|
|
734
|
+
}),
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
finally {
|
|
738
|
+
this.cdp.off("Tracing.dataCollected", collect);
|
|
739
|
+
if (started)
|
|
740
|
+
releaseRecording();
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Every frame id this session owns, its own and its children's.
|
|
745
|
+
*
|
|
746
|
+
* This is what separates our work from another session's in a
|
|
747
|
+
* recording that necessarily contains both.
|
|
748
|
+
*/
|
|
749
|
+
async ownFrameIds() {
|
|
750
|
+
const ids = new Set();
|
|
751
|
+
try {
|
|
752
|
+
const { frameTree } = (await this.cdp.send("Page.getFrameTree"));
|
|
753
|
+
const queue = [frameTree];
|
|
754
|
+
while (queue.length > 0) {
|
|
755
|
+
const node = queue.shift();
|
|
756
|
+
if (node === undefined)
|
|
757
|
+
continue;
|
|
758
|
+
ids.add(node.frame.id);
|
|
759
|
+
queue.push(...(node.childFrames ?? []));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
// Without a frame tree nothing can be attributed, which the
|
|
764
|
+
// fold reports as unattributed rather than guessing.
|
|
765
|
+
}
|
|
766
|
+
return ids;
|
|
767
|
+
}
|
|
768
|
+
/** Every websocket conversation the page has held. */
|
|
769
|
+
sockets() {
|
|
770
|
+
return this.telemetry.sockets();
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Measure what the page is holding, against the last measurement.
|
|
774
|
+
*
|
|
775
|
+
* A collection is forced first unless refused, because a heap
|
|
776
|
+
* that grew means nothing while uncollected garbage is still in
|
|
777
|
+
* it. The previous reading is kept on the session so a caller
|
|
778
|
+
* can measure, do the thing they suspect, and measure again
|
|
779
|
+
* without having to carry a number between calls.
|
|
780
|
+
*/
|
|
781
|
+
async heap(collect = true) {
|
|
782
|
+
await this.ready();
|
|
783
|
+
if (collect) {
|
|
784
|
+
// Chrome exposes this on two domains and neither is
|
|
785
|
+
// guaranteed enabled. Failing to collect is not worth
|
|
786
|
+
// failing the read over, but it does change what the
|
|
787
|
+
// reading means, which is what `collected` carries.
|
|
788
|
+
try {
|
|
789
|
+
await this.cdp.send("HeapProfiler.collectGarbage");
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
collect = false;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
// The domain answers with an empty list until it is enabled,
|
|
796
|
+
// which reads as a heap of zero that never changes: a leak
|
|
797
|
+
// check that always says everything is fine. Enabling is
|
|
798
|
+
// idempotent, so it costs nothing to do it on every read.
|
|
799
|
+
// Measured: without this the domain answers with every metric
|
|
800
|
+
// at zero rather than refusing, which reads as a page holding
|
|
801
|
+
// no memory that never changes. A leak check that always says
|
|
802
|
+
// everything is fine is worse than no leak check. Enabling is
|
|
803
|
+
// idempotent, so it costs nothing to do on every read.
|
|
804
|
+
await this.cdp.send("Performance.enable");
|
|
805
|
+
const { metrics } = await this.cdp.send("Performance.getMetrics");
|
|
806
|
+
const read = (named) => metrics.find((metric) => metric.name === named)?.value ?? 0;
|
|
807
|
+
const reading = {
|
|
808
|
+
usedBytes: read("JSHeapUsedSize"),
|
|
809
|
+
totalBytes: read("JSHeapTotalSize"),
|
|
810
|
+
collected: collect,
|
|
811
|
+
at: Date.now(),
|
|
812
|
+
};
|
|
813
|
+
const compared = compareHeap(reading, this.lastHeap);
|
|
814
|
+
this.lastHeap = reading;
|
|
815
|
+
return compared;
|
|
816
|
+
}
|
|
817
|
+
/** The previous heap reading, for the next comparison. */
|
|
818
|
+
lastHeap;
|
|
819
|
+
/**
|
|
820
|
+
* What the compositor is holding for this page, and why.
|
|
821
|
+
*
|
|
822
|
+
* LayerTree pushes nothing when it is enabled: the tree arrives
|
|
823
|
+
* only on layerTreeDidChange, and a page that has finished
|
|
824
|
+
* settling has no change left to report. Measured across seven
|
|
825
|
+
* candidates, a throwaway screenshot is the cheapest way to make
|
|
826
|
+
* Chrome recompose and hand the tree over, and the only one that
|
|
827
|
+
* alters nothing about the page. Scrolling moves the page,
|
|
828
|
+
* overriding device metrics disturbs an emulation the caller may
|
|
829
|
+
* have set, and toggling a style writes to the DOM.
|
|
830
|
+
*
|
|
831
|
+
* The domain goes off again afterwards, so a read leaves no
|
|
832
|
+
* instrumentation running behind it.
|
|
833
|
+
*/
|
|
834
|
+
/**
|
|
835
|
+
* Judge text against the background actually behind its glyphs.
|
|
836
|
+
*
|
|
837
|
+
* For the case axe hands back as needing a person: text over a
|
|
838
|
+
* gradient, a photograph, or anything else with no single
|
|
839
|
+
* background colour. Two shots of the same region, the second with
|
|
840
|
+
* the text hidden, give both the glyph mask and what lies under it.
|
|
841
|
+
*
|
|
842
|
+
* Hiding the text is a paint-only change, so nothing moves between
|
|
843
|
+
* the shots and the subtraction stays aligned. The inline style is
|
|
844
|
+
* put back in a finally, including the case where it had one of its
|
|
845
|
+
* own to begin with.
|
|
846
|
+
*/
|
|
847
|
+
async contrastBehind(target, bar = "AA") {
|
|
848
|
+
await this.ready();
|
|
849
|
+
const tree = await this.axTree();
|
|
850
|
+
const resolution = resolveTarget(tree, target);
|
|
851
|
+
if (resolution.kind === "notFound") {
|
|
852
|
+
return { ok: false, refusal: notFoundRefusal(tree, target) };
|
|
853
|
+
}
|
|
854
|
+
if (resolution.kind === "ambiguous") {
|
|
855
|
+
return { ok: false, refusal: ambiguityRefusal(tree, target) };
|
|
856
|
+
}
|
|
857
|
+
const backendNodeId = resolution.backendDomId;
|
|
858
|
+
const named = await this.objectFor(backendNodeId);
|
|
859
|
+
if (!named) {
|
|
860
|
+
return { ok: false, refusal: notFoundRefusal(tree, target) };
|
|
861
|
+
}
|
|
862
|
+
// Naming a paragraph's text means naming its StaticText, since
|
|
863
|
+
// a paragraph has no accessible name, and that resolves to a
|
|
864
|
+
// text node. The colour and the hiding both belong to the
|
|
865
|
+
// element painting it; the capture below still uses the named
|
|
866
|
+
// node, whose box is the tighter one.
|
|
867
|
+
const objectId = await this.paintingElement(named);
|
|
868
|
+
try {
|
|
869
|
+
const styles = await this.stylesOf(objectId);
|
|
870
|
+
// No fallback here on purpose. parseRgb returns undefined for a
|
|
871
|
+
// colour it cannot read, and the fold treats that as undecidable;
|
|
872
|
+
// substituting black would answer confidently about a colour
|
|
873
|
+
// nobody read.
|
|
874
|
+
const colour = parseRgb(styles?.color ?? "");
|
|
875
|
+
const sizing = {
|
|
876
|
+
fontSizePx: Number.parseFloat(styles?.["font-size"] ?? "16") || 16,
|
|
877
|
+
fontWeight: Number.parseFloat(styles?.["font-weight"] ?? "400") || 400,
|
|
878
|
+
};
|
|
879
|
+
const first = await this.capture(backendNodeId, {});
|
|
880
|
+
await this.hideText(objectId, true);
|
|
881
|
+
try {
|
|
882
|
+
const second = await this.capture(backendNodeId, {});
|
|
883
|
+
const withText = readPng(readFileSync(first.paths[0] ?? ""));
|
|
884
|
+
const bare = readPng(readFileSync(second.paths[0] ?? ""));
|
|
885
|
+
return {
|
|
886
|
+
ok: true,
|
|
887
|
+
report: foldBehind({
|
|
888
|
+
withText,
|
|
889
|
+
bare,
|
|
890
|
+
textColour: colour
|
|
891
|
+
? { r: colour.r, g: colour.g, b: colour.b }
|
|
892
|
+
: undefined,
|
|
893
|
+
sizing,
|
|
894
|
+
bar,
|
|
895
|
+
}),
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
finally {
|
|
899
|
+
await this.hideText(objectId, false);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
finally {
|
|
903
|
+
await this.release(objectId);
|
|
904
|
+
// Promoting a text node makes a second handle, and the node
|
|
905
|
+
// that was named is still ours to let go of.
|
|
906
|
+
if (named !== objectId)
|
|
907
|
+
await this.release(named);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Judge the contrast between two elements the caller named.
|
|
912
|
+
*
|
|
913
|
+
* Distinct from contrastBehind, which subtracts pixels to read the
|
|
914
|
+
* background under one element's glyphs. This compares two
|
|
915
|
+
* elements' stated colours, which is the only way to ask about a
|
|
916
|
+
* boundary: an icon against its card, a border against the page.
|
|
917
|
+
* Both sides are read from the computed style rather than the
|
|
918
|
+
* screen, so nothing is captured and nothing moves.
|
|
919
|
+
*/
|
|
920
|
+
async contrastPair(one, other, bar = "AA") {
|
|
921
|
+
await this.ready();
|
|
922
|
+
const tree = await this.axTree();
|
|
923
|
+
const sides = [];
|
|
924
|
+
for (const target of [one, other]) {
|
|
925
|
+
const resolution = resolveTarget(tree, target);
|
|
926
|
+
if (resolution.kind === "notFound") {
|
|
927
|
+
return { ok: false, target, refusal: notFoundRefusal(tree, target) };
|
|
928
|
+
}
|
|
929
|
+
if (resolution.kind === "ambiguous") {
|
|
930
|
+
return { ok: false, target, refusal: ambiguityRefusal(tree, target) };
|
|
931
|
+
}
|
|
932
|
+
const named = await this.objectFor(resolution.backendDomId);
|
|
933
|
+
if (!named) {
|
|
934
|
+
return { ok: false, target, refusal: notFoundRefusal(tree, target) };
|
|
935
|
+
}
|
|
936
|
+
// Either side may be named as StaticText, which resolves to a
|
|
937
|
+
// text node: no computed style to read, and no child text
|
|
938
|
+
// nodes of its own, so it would also be judged as carrying no
|
|
939
|
+
// text and drawn against the wrong criterion.
|
|
940
|
+
const objectId = await this.paintingElement(named);
|
|
941
|
+
try {
|
|
942
|
+
sides.push(await this.paintedSide(objectId));
|
|
943
|
+
}
|
|
944
|
+
finally {
|
|
945
|
+
await this.release(objectId);
|
|
946
|
+
if (named !== objectId)
|
|
947
|
+
await this.release(named);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
const [first, second] = sides;
|
|
951
|
+
if (!first || !second) {
|
|
952
|
+
// Only reachable if a side went missing after resolving, and
|
|
953
|
+
// the loop above returns for anything it could name, so the
|
|
954
|
+
// one still unaccounted for is the second.
|
|
955
|
+
return {
|
|
956
|
+
ok: false,
|
|
957
|
+
target: other,
|
|
958
|
+
refusal: notFoundRefusal(tree, other),
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
return { ok: true, report: foldPair({ one: first, other: second, bar }) };
|
|
962
|
+
}
|
|
963
|
+
/** What one side of a contrast pairing paints. */
|
|
964
|
+
async paintedSide(objectId) {
|
|
965
|
+
const styles = await this.stylesOf(objectId);
|
|
966
|
+
const { result } = await this.cdp.send("Runtime.callFunctionOn", {
|
|
967
|
+
objectId,
|
|
968
|
+
functionDeclaration: OWN_TEXT_PROBE,
|
|
969
|
+
returnByValue: true,
|
|
970
|
+
});
|
|
971
|
+
return {
|
|
972
|
+
hasText: result.value === true,
|
|
973
|
+
...(styles?.color === undefined ? {} : { color: styles.color }),
|
|
974
|
+
...(styles?.["background-color"] === undefined
|
|
975
|
+
? {}
|
|
976
|
+
: { backgroundColor: styles["background-color"] }),
|
|
977
|
+
...(styles?.["border-color"] === undefined
|
|
978
|
+
? {}
|
|
979
|
+
: { borderColor: styles["border-color"] }),
|
|
980
|
+
sizing: {
|
|
981
|
+
fontSizePx: Number.parseFloat(styles?.["font-size"] ?? "16") || 16,
|
|
982
|
+
fontWeight: Number.parseFloat(styles?.["font-weight"] ?? "400") || 400,
|
|
983
|
+
},
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Make an element's own text stop painting, and put it back.
|
|
988
|
+
*
|
|
989
|
+
* Only colour and shadow are touched, because neither affects
|
|
990
|
+
* layout: the glyphs still occupy the same pixels, they simply
|
|
991
|
+
* leave no mark on them.
|
|
992
|
+
*/
|
|
993
|
+
async hideText(objectId, hidden) {
|
|
994
|
+
try {
|
|
995
|
+
await this.cdp.send("Runtime.callFunctionOn", {
|
|
996
|
+
objectId,
|
|
997
|
+
functionDeclaration: HIDE_TEXT,
|
|
998
|
+
arguments: [{ value: hidden }],
|
|
999
|
+
returnByValue: true,
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
catch {
|
|
1003
|
+
// A page that navigated mid-measurement has already discarded
|
|
1004
|
+
// the element, so there is no style left to restore.
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
async layers() {
|
|
1008
|
+
await this.ready();
|
|
1009
|
+
let latest = [];
|
|
1010
|
+
const onChange = (payload) => {
|
|
1011
|
+
if (payload.layers)
|
|
1012
|
+
latest = payload.layers;
|
|
1013
|
+
};
|
|
1014
|
+
this.cdp.on("LayerTree.layerTreeDidChange", onChange);
|
|
1015
|
+
try {
|
|
1016
|
+
await this.cdp.send("LayerTree.enable");
|
|
1017
|
+
await this.cdp.send("Page.captureScreenshot", { format: "png" });
|
|
1018
|
+
await this.settleLayers();
|
|
1019
|
+
}
|
|
1020
|
+
finally {
|
|
1021
|
+
this.cdp.off("LayerTree.layerTreeDidChange", onChange);
|
|
1022
|
+
// Best effort: a disable that fails leaves the domain on, which
|
|
1023
|
+
// costs a little work per frame but breaks nothing, and is not
|
|
1024
|
+
// worth failing the read over.
|
|
1025
|
+
await this.cdp.send("LayerTree.disable").catch(() => undefined);
|
|
1026
|
+
}
|
|
1027
|
+
// Snapshotted before anything is asked about it. Every await
|
|
1028
|
+
// below lets another change land, and a loop reading the live
|
|
1029
|
+
// array attributes one layer's reasons to another's id.
|
|
1030
|
+
const layers = [...latest];
|
|
1031
|
+
const reasons = {};
|
|
1032
|
+
const elements = {};
|
|
1033
|
+
for (const layer of layers) {
|
|
1034
|
+
reasons[layer.layerId] = await this.reasonsFor(layer.layerId);
|
|
1035
|
+
const named = await this.nameOfNode(layer.backendNodeId);
|
|
1036
|
+
if (named !== undefined)
|
|
1037
|
+
elements[layer.layerId] = named;
|
|
1038
|
+
}
|
|
1039
|
+
return foldLayers({ layers, reasons, elements });
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* What the page does on hover, and whether focus does it too.
|
|
1043
|
+
*
|
|
1044
|
+
* Two halves, because either alone lies. The stylesheets say
|
|
1045
|
+
* which elements might hover, cheaply and in one round trip, but
|
|
1046
|
+
* a declared rule the cascade beat changes nothing a person can
|
|
1047
|
+
* see. So the scan only proposes candidates, and each one is then
|
|
1048
|
+
* held in hover and in focus and read, which is the only account
|
|
1049
|
+
* of what actually happens.
|
|
1050
|
+
*
|
|
1051
|
+
* Bounded because there is a round trip per candidate, measured
|
|
1052
|
+
* at roughly 9ms for the pair of states.
|
|
1053
|
+
*/
|
|
1054
|
+
async hovers(limit = MAX_HOVER_CANDIDATES) {
|
|
1055
|
+
await this.ready();
|
|
1056
|
+
const scan = await this.scanForHover();
|
|
1057
|
+
const measurements = [];
|
|
1058
|
+
const seen = new Set();
|
|
1059
|
+
for (const selector of scan.selectors) {
|
|
1060
|
+
if (measurements.length >= limit)
|
|
1061
|
+
break;
|
|
1062
|
+
for (const found of await this.nodesMatching(selector)) {
|
|
1063
|
+
if (measurements.length >= limit)
|
|
1064
|
+
break;
|
|
1065
|
+
// One element can match two hover selectors. Measuring it
|
|
1066
|
+
// twice would report one treatment as two.
|
|
1067
|
+
if (seen.has(found))
|
|
1068
|
+
continue;
|
|
1069
|
+
seen.add(found);
|
|
1070
|
+
const measured = await this.measureHover(found);
|
|
1071
|
+
if (measured)
|
|
1072
|
+
measurements.push(measured);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return foldHover(measurements, scan.unreadableSheets);
|
|
1076
|
+
}
|
|
1077
|
+
/** Which selectors carry a hover, as the page's own sheets say. */
|
|
1078
|
+
async scanForHover() {
|
|
1079
|
+
try {
|
|
1080
|
+
const { result } = await this.cdp.send("Runtime.evaluate", {
|
|
1081
|
+
expression: HOVER_SCAN,
|
|
1082
|
+
returnByValue: true,
|
|
1083
|
+
});
|
|
1084
|
+
const parsed = JSON.parse(String(result.value));
|
|
1085
|
+
if (typeof parsed === "object" &&
|
|
1086
|
+
parsed !== null &&
|
|
1087
|
+
"selectors" in parsed &&
|
|
1088
|
+
Array.isArray(parsed.selectors)) {
|
|
1089
|
+
const sheets = "unreadableSheets" in parsed &&
|
|
1090
|
+
typeof parsed.unreadableSheets === "number"
|
|
1091
|
+
? parsed.unreadableSheets
|
|
1092
|
+
: 0;
|
|
1093
|
+
return {
|
|
1094
|
+
selectors: parsed.selectors.filter((one) => typeof one === "string"),
|
|
1095
|
+
unreadableSheets: sheets,
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
return { selectors: [], unreadableSheets: 0 };
|
|
1099
|
+
}
|
|
1100
|
+
catch {
|
|
1101
|
+
// A page that navigated mid-scan has no stylesheets to report.
|
|
1102
|
+
return { selectors: [], unreadableSheets: 0 };
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
/** Backend ids matching a selector, or none if it will not parse. */
|
|
1106
|
+
async nodesMatching(selector) {
|
|
1107
|
+
try {
|
|
1108
|
+
const { root } = await this.cdp.send("DOM.getDocument", { depth: 0 });
|
|
1109
|
+
const { nodeIds } = await this.cdp.send("DOM.querySelectorAll", {
|
|
1110
|
+
nodeId: root.nodeId,
|
|
1111
|
+
selector,
|
|
1112
|
+
});
|
|
1113
|
+
const backend = [];
|
|
1114
|
+
for (const nodeId of nodeIds) {
|
|
1115
|
+
const { node } = await this.cdp.send("DOM.describeNode", { nodeId });
|
|
1116
|
+
if (node.backendNodeId !== undefined)
|
|
1117
|
+
backend.push(node.backendNodeId);
|
|
1118
|
+
}
|
|
1119
|
+
return backend;
|
|
1120
|
+
}
|
|
1121
|
+
catch {
|
|
1122
|
+
// A selector Chrome will not parse, which a stylesheet can
|
|
1123
|
+
// legally contain once its hover part is stripped off.
|
|
1124
|
+
return [];
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
/** Hold hover, then focus, and read what each changed. */
|
|
1128
|
+
async measureHover(backendNodeId) {
|
|
1129
|
+
const nodeId = await this.frontendNodeFor(backendNodeId);
|
|
1130
|
+
if (nodeId === undefined)
|
|
1131
|
+
return undefined;
|
|
1132
|
+
const objectId = await this.objectFor(backendNodeId);
|
|
1133
|
+
if (objectId === undefined)
|
|
1134
|
+
return undefined;
|
|
1135
|
+
const named = await this.nameOfNode(backendNodeId);
|
|
1136
|
+
const atRest = await this.stylesOf(objectId);
|
|
1137
|
+
if (!atRest)
|
|
1138
|
+
return undefined;
|
|
1139
|
+
const resting = curateStyles(atRest, {});
|
|
1140
|
+
const read = async (state) => {
|
|
1141
|
+
await this.cdp.send("CSS.forcePseudoState", {
|
|
1142
|
+
nodeId,
|
|
1143
|
+
forcedPseudoClasses: [state],
|
|
1144
|
+
});
|
|
1145
|
+
await this.settleForcedState(objectId);
|
|
1146
|
+
const held = await this.stylesOf(objectId);
|
|
1147
|
+
return held ? diffStyles(resting, curateStyles(held, {})) : [];
|
|
1148
|
+
};
|
|
1149
|
+
try {
|
|
1150
|
+
const hover = await read("hover");
|
|
1151
|
+
const focus = await read("focus");
|
|
1152
|
+
return { element: named ?? `node ${backendNodeId}`, hover, focus };
|
|
1153
|
+
}
|
|
1154
|
+
finally {
|
|
1155
|
+
// Always released. A page left stuck in a forced hover would
|
|
1156
|
+
// corrupt every later reading of it, and a restoration written
|
|
1157
|
+
// after the reads would not run when one of them throws.
|
|
1158
|
+
await this.cdp
|
|
1159
|
+
.send("CSS.forcePseudoState", { nodeId, forcedPseudoClasses: [] })
|
|
1160
|
+
.catch(() => undefined);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
/** Why Chrome composited one layer, or nothing if it will not say. */
|
|
1164
|
+
async reasonsFor(layerId) {
|
|
1165
|
+
try {
|
|
1166
|
+
const answer = await this.cdp.send("LayerTree.compositingReasons", {
|
|
1167
|
+
layerId,
|
|
1168
|
+
});
|
|
1169
|
+
return answer.compositingReasons ?? [];
|
|
1170
|
+
}
|
|
1171
|
+
catch {
|
|
1172
|
+
// The layer went away between the snapshot and the question,
|
|
1173
|
+
// which is silence about it rather than a reason to fail.
|
|
1174
|
+
return [];
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
/** A short name for the element behind a layer. */
|
|
1178
|
+
async nameOfNode(backendNodeId) {
|
|
1179
|
+
if (backendNodeId === undefined)
|
|
1180
|
+
return undefined;
|
|
1181
|
+
try {
|
|
1182
|
+
const { node } = await this.cdp.send("DOM.describeNode", {
|
|
1183
|
+
backendNodeId,
|
|
1184
|
+
});
|
|
1185
|
+
return nameNode(node.nodeName, node.attributes);
|
|
1186
|
+
}
|
|
1187
|
+
catch {
|
|
1188
|
+
// A node that has left the document cannot be named, and a
|
|
1189
|
+
// layer without a name is still worth reporting by id.
|
|
1190
|
+
return undefined;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
/** Give the compositor a moment to finish reporting. */
|
|
1194
|
+
settleLayers() {
|
|
1195
|
+
return new Promise((resolve) => setTimeout(resolve, LAYER_SETTLE_MS));
|
|
1196
|
+
}
|
|
1197
|
+
/** Socket events dropped to stay within the buffer's budget. */
|
|
1198
|
+
get socketFramesDropped() {
|
|
1199
|
+
return this.telemetry.socketFramesDropped;
|
|
1200
|
+
}
|
|
1201
|
+
/** Where the page has been. */
|
|
1202
|
+
get history() {
|
|
1203
|
+
return this.telemetry.history;
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Put the emulation back exactly as it was.
|
|
1207
|
+
*
|
|
1208
|
+
* emulate merges, which is right for a caller adding one
|
|
1209
|
+
* condition and wrong for anything that has to undo itself: a
|
|
1210
|
+
* merge cannot clear a field, so a sweep that widened the
|
|
1211
|
+
* viewport could never restore a session that had none. This
|
|
1212
|
+
* replaces the state wholesale.
|
|
1213
|
+
*/
|
|
1214
|
+
async restoreEmulation(state) {
|
|
1215
|
+
await this.emulation.restore(state);
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Pretend to be a different visitor, then report what the page
|
|
1219
|
+
* actually experiences.
|
|
1220
|
+
*
|
|
1221
|
+
* The intent is kept whole and re-applied in full, because
|
|
1222
|
+
* Chrome's media emulation forgets anything a call omits, so
|
|
1223
|
+
* asking for reduced motion after asking for dark mode would
|
|
1224
|
+
* otherwise turn the dark mode back off.
|
|
1225
|
+
*/
|
|
1226
|
+
async emulate(change = {},
|
|
1227
|
+
/** Overrides to take off, which an absent key cannot express. */
|
|
1228
|
+
clear = []) {
|
|
1229
|
+
return this.emulation.change(change, clear);
|
|
1230
|
+
}
|
|
1231
|
+
/** What this session has asked the browser to pretend. */
|
|
1232
|
+
get emulated() {
|
|
1233
|
+
return this.emulation.asked;
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Read what the page has kept.
|
|
1237
|
+
*
|
|
1238
|
+
* A store that cannot be read says why rather than coming
|
|
1239
|
+
* back empty, since an empty store and an unreadable one lead
|
|
1240
|
+
* to opposite conclusions.
|
|
1241
|
+
*/
|
|
1242
|
+
async storage(wanted) {
|
|
1243
|
+
await this.ready();
|
|
1244
|
+
const snapshot = { unavailable: {} };
|
|
1245
|
+
if (wanted.local) {
|
|
1246
|
+
snapshot.local = await this.domStorage(true, snapshot.unavailable);
|
|
1247
|
+
}
|
|
1248
|
+
if (wanted.session) {
|
|
1249
|
+
snapshot.session = await this.domStorage(false, snapshot.unavailable);
|
|
1250
|
+
}
|
|
1251
|
+
if (wanted.cookies) {
|
|
1252
|
+
try {
|
|
1253
|
+
const { cookies } = await this.cdp.send("Network.getCookies");
|
|
1254
|
+
snapshot.cookies = cookies;
|
|
1255
|
+
}
|
|
1256
|
+
catch (err) {
|
|
1257
|
+
snapshot.unavailable.cookies = String(err);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
if (wanted.clipboard) {
|
|
1261
|
+
const read = await this.clipboard();
|
|
1262
|
+
if (read.ok)
|
|
1263
|
+
snapshot.clipboard = read.text;
|
|
1264
|
+
else
|
|
1265
|
+
snapshot.unavailable.clipboard = read.why;
|
|
1266
|
+
}
|
|
1267
|
+
const { unavailable, ...rest } = snapshot;
|
|
1268
|
+
return Object.keys(unavailable).length > 0
|
|
1269
|
+
? { ...rest, unavailable }
|
|
1270
|
+
: rest;
|
|
1271
|
+
}
|
|
1272
|
+
/** One of the two DOM stores, keyed by this frame's origin. */
|
|
1273
|
+
async domStorage(isLocalStorage, unavailable) {
|
|
1274
|
+
const label = isLocalStorage ? "local storage" : "session storage";
|
|
1275
|
+
try {
|
|
1276
|
+
await this.cdp.send("DOMStorage.enable");
|
|
1277
|
+
const { entries } = await this.cdp.send("DOMStorage.getDOMStorageItems", {
|
|
1278
|
+
storageId: { securityOrigin: await this.origin(), isLocalStorage },
|
|
1279
|
+
});
|
|
1280
|
+
// The protocol types an entry as a loose string array; it
|
|
1281
|
+
// is always a key and a value, which is what the pair type
|
|
1282
|
+
// says and what the renderer reads.
|
|
1283
|
+
return entries.map((entry) => [entry[0] ?? "", entry[1] ?? ""]);
|
|
1284
|
+
}
|
|
1285
|
+
catch (err) {
|
|
1286
|
+
// A page on about:blank or a file url has no security
|
|
1287
|
+
// origin to key a store by, which is a fact about where we
|
|
1288
|
+
// are rather than a failure to report.
|
|
1289
|
+
unavailable[label] = String(err);
|
|
1290
|
+
return undefined;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
/** Put a value into one of the DOM stores. */
|
|
1294
|
+
async setStored(isLocalStorage, key, value) {
|
|
1295
|
+
await this.ready();
|
|
1296
|
+
await this.cdp.send("DOMStorage.enable");
|
|
1297
|
+
await this.cdp.send("DOMStorage.setDOMStorageItem", {
|
|
1298
|
+
storageId: { securityOrigin: await this.origin(), isLocalStorage },
|
|
1299
|
+
key,
|
|
1300
|
+
value,
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Measure the space between two elements.
|
|
1305
|
+
*
|
|
1306
|
+
* The border box is the one measured, because that is the edge
|
|
1307
|
+
* a designer sees and points at. Margins are the space itself,
|
|
1308
|
+
* so measuring between margin boxes would report the gap that
|
|
1309
|
+
* remains after the gap, which is nearly always zero.
|
|
1310
|
+
*/
|
|
1311
|
+
async measure(a, b) {
|
|
1312
|
+
// Which target failed matters: the caller named two, and a
|
|
1313
|
+
// refusal that does not say which one leaves them guessing.
|
|
1314
|
+
const first = await this.inspect(a);
|
|
1315
|
+
if (!first.ok)
|
|
1316
|
+
return { ok: false, target: a, refusal: first.refusal };
|
|
1317
|
+
const second = await this.inspect(b);
|
|
1318
|
+
if (!second.ok)
|
|
1319
|
+
return { ok: false, target: b, refusal: second.refusal };
|
|
1320
|
+
const boxA = first.inspection.box;
|
|
1321
|
+
const boxB = second.inspection.box;
|
|
1322
|
+
if (boxA === undefined || boxB === undefined) {
|
|
1323
|
+
// An element with no box is not laid out, so there is no
|
|
1324
|
+
// space between it and anything. Saying which one it was
|
|
1325
|
+
// saves the caller inspecting both to find out.
|
|
1326
|
+
const missing = boxA === undefined ? a : b;
|
|
1327
|
+
return {
|
|
1328
|
+
ok: false,
|
|
1329
|
+
problem: `${missing.role} ${missing.name} has no box, so it is not ` +
|
|
1330
|
+
"laid out and there is no space between it and anything.",
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
return { ok: true, measurement: measureBetween(boxA.border, boxB.border) };
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Every tab this session's context is holding open.
|
|
1337
|
+
*
|
|
1338
|
+
* A page that opens another one is doing something ordinary: a
|
|
1339
|
+
* target of _blank, a sign-in or a payment handed to a second
|
|
1340
|
+
* window. Until this existed the new tab was real, live and
|
|
1341
|
+
* entirely invisible, and the click that made it looked like a
|
|
1342
|
+
* click that did nothing.
|
|
1343
|
+
*/
|
|
1344
|
+
async tabs() {
|
|
1345
|
+
await this.ready();
|
|
1346
|
+
const pages = await this.context.pages();
|
|
1347
|
+
return await Promise.all(pages.map(async (page, at) => ({
|
|
1348
|
+
index: at + 1,
|
|
1349
|
+
url: page.url(),
|
|
1350
|
+
// A tab still loading has no title yet, and asking a closing
|
|
1351
|
+
// one throws. Either way the url still identifies it, which
|
|
1352
|
+
// is what the caller picks by.
|
|
1353
|
+
title: await page.title().catch(() => ""),
|
|
1354
|
+
current: page === this.page,
|
|
1355
|
+
})));
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Drive a different tab from now on.
|
|
1359
|
+
*
|
|
1360
|
+
* The tab left behind is not closed. A sign-in that opened a
|
|
1361
|
+
* second window usually wants the first one back afterwards, and
|
|
1362
|
+
* closing it would make switching a one-way trip.
|
|
1363
|
+
*/
|
|
1364
|
+
async switchTab(index) {
|
|
1365
|
+
const open = await this.tabs();
|
|
1366
|
+
const chosen = chooseTab(open, index);
|
|
1367
|
+
if ("refusal" in chosen)
|
|
1368
|
+
return chosen;
|
|
1369
|
+
const pages = await this.context.pages();
|
|
1370
|
+
const page = pages[chosen.index - 1];
|
|
1371
|
+
if (page === undefined) {
|
|
1372
|
+
return {
|
|
1373
|
+
refusal: `Tab ${index} closed between being listed and being chosen.`,
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
if (page === this.page)
|
|
1377
|
+
return { ...chosen, current: true };
|
|
1378
|
+
await page.bringToFront();
|
|
1379
|
+
await this.bindTo(page);
|
|
1380
|
+
return { ...chosen, current: true };
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* Keep everything that makes this session signed in.
|
|
1384
|
+
*
|
|
1385
|
+
* Cookies and the two DOM stores together, because being logged
|
|
1386
|
+
* in is rarely all in one of them: a session cookie and a token
|
|
1387
|
+
* in local storage is the ordinary arrangement, and saving half
|
|
1388
|
+
* of it produces a state that restores without signing anyone
|
|
1389
|
+
* in.
|
|
1390
|
+
*/
|
|
1391
|
+
async saveState() {
|
|
1392
|
+
await this.ready();
|
|
1393
|
+
const snapshot = await this.storage({
|
|
1394
|
+
local: true,
|
|
1395
|
+
session: true,
|
|
1396
|
+
cookies: true,
|
|
1397
|
+
});
|
|
1398
|
+
return captureState(await this.origin(), snapshot);
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Wear a state saved earlier, and report exactly what landed.
|
|
1402
|
+
*
|
|
1403
|
+
* Cookies go to the browser, so they apply whatever page is
|
|
1404
|
+
* open. The DOM stores are scoped to an origin, so they can only
|
|
1405
|
+
* be written while the session is on the origin they came from,
|
|
1406
|
+
* and the answer says when they were not rather than counting
|
|
1407
|
+
* them as restored. A caller who is told they are signed in and
|
|
1408
|
+
* is not will blame the site.
|
|
1409
|
+
*/
|
|
1410
|
+
async loadState(state) {
|
|
1411
|
+
await this.ready();
|
|
1412
|
+
if (state.cookies.length > 0) {
|
|
1413
|
+
await this.cdp.send("Network.setCookies", {
|
|
1414
|
+
cookies: state.cookies.map(({ sameSite, ...rest }) => ({
|
|
1415
|
+
...rest,
|
|
1416
|
+
// A saved file can hold any string here, and the protocol
|
|
1417
|
+
// takes three. An unrecognised one is dropped rather than
|
|
1418
|
+
// passed on: without the attribute the browser applies its
|
|
1419
|
+
// own default, which is what a cookie with a nonsense
|
|
1420
|
+
// value would have got anyway, and sending it refuses the
|
|
1421
|
+
// whole batch and signs nobody in.
|
|
1422
|
+
...(sameSite === "Strict" || sameSite === "Lax" || sameSite === "None"
|
|
1423
|
+
? { sameSite }
|
|
1424
|
+
: {}),
|
|
1425
|
+
})),
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
const here = await this.origin();
|
|
1429
|
+
if (here !== state.origin) {
|
|
1430
|
+
return {
|
|
1431
|
+
cookies: state.cookies.length,
|
|
1432
|
+
local: 0,
|
|
1433
|
+
session: 0,
|
|
1434
|
+
wrongOrigin: here,
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
for (const [key, value] of state.local) {
|
|
1438
|
+
await this.setStored(true, key, value);
|
|
1439
|
+
}
|
|
1440
|
+
for (const [key, value] of state.session) {
|
|
1441
|
+
await this.setStored(false, key, value);
|
|
1442
|
+
}
|
|
1443
|
+
return {
|
|
1444
|
+
cookies: state.cookies.length,
|
|
1445
|
+
local: state.local.length,
|
|
1446
|
+
session: state.session.length,
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
/** Empty the stores named, and only those. */
|
|
1450
|
+
async clearStorage(wanted) {
|
|
1451
|
+
await this.ready();
|
|
1452
|
+
if (wanted.local || wanted.session) {
|
|
1453
|
+
await this.cdp.send("DOMStorage.enable");
|
|
1454
|
+
const securityOrigin = await this.origin();
|
|
1455
|
+
if (wanted.local) {
|
|
1456
|
+
await this.cdp.send("DOMStorage.clear", {
|
|
1457
|
+
storageId: { securityOrigin, isLocalStorage: true },
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
if (wanted.session) {
|
|
1461
|
+
await this.cdp.send("DOMStorage.clear", {
|
|
1462
|
+
storageId: { securityOrigin, isLocalStorage: false },
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
if (wanted.cookies)
|
|
1467
|
+
await this.cdp.send("Network.clearBrowserCookies");
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Read the clipboard.
|
|
1471
|
+
*
|
|
1472
|
+
* Chrome refuses this unless the permission is granted against
|
|
1473
|
+
* the browser context, not merely the origin, and unless the
|
|
1474
|
+
* read is attributed to a user gesture. Both were measured;
|
|
1475
|
+
* either alone still gets a refusal.
|
|
1476
|
+
*/
|
|
1477
|
+
async clipboard() {
|
|
1478
|
+
try {
|
|
1479
|
+
await this.grantClipboard();
|
|
1480
|
+
const response = await this.cdp.send("Runtime.evaluate", {
|
|
1481
|
+
expression: "navigator.clipboard.readText()",
|
|
1482
|
+
awaitPromise: true,
|
|
1483
|
+
userGesture: true,
|
|
1484
|
+
returnByValue: true,
|
|
1485
|
+
});
|
|
1486
|
+
// Runtime.evaluate resolves with exceptionDetails rather
|
|
1487
|
+
// than rejecting, so a refused permission arrived here as
|
|
1488
|
+
// an undefined value and was reported as an empty
|
|
1489
|
+
// clipboard. "Nothing is on the clipboard" and "the page
|
|
1490
|
+
// would not let us look" are different answers, and only
|
|
1491
|
+
// one of them is about the page.
|
|
1492
|
+
if (response.exceptionDetails) {
|
|
1493
|
+
return {
|
|
1494
|
+
ok: false,
|
|
1495
|
+
why: describeThrow(response.exceptionDetails).message,
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
return { ok: true, text: String(response.result.value ?? "") };
|
|
1499
|
+
}
|
|
1500
|
+
catch (err) {
|
|
1501
|
+
return { ok: false, why: String(err) };
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
/** Put text on the clipboard. */
|
|
1505
|
+
async writeClipboard(text) {
|
|
1506
|
+
await this.ready();
|
|
1507
|
+
await this.grantClipboard();
|
|
1508
|
+
const response = await this.cdp.send("Runtime.evaluate", {
|
|
1509
|
+
expression: `navigator.clipboard.writeText(${JSON.stringify(text)})`,
|
|
1510
|
+
awaitPromise: true,
|
|
1511
|
+
userGesture: true,
|
|
1512
|
+
});
|
|
1513
|
+
// This inspected nothing at all, so a refused write was
|
|
1514
|
+
// reported to the caller as a success.
|
|
1515
|
+
if (response.exceptionDetails) {
|
|
1516
|
+
const threw = describeThrow(response.exceptionDetails);
|
|
1517
|
+
throw new Error(`Could not write the clipboard: ${threw.message}`);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
/** Ask for clipboard access against this context. */
|
|
1521
|
+
async grantClipboard() {
|
|
1522
|
+
await this.cdp.send("Browser.grantPermissions", {
|
|
1523
|
+
origin: await this.origin(),
|
|
1524
|
+
browserContextId: this.context.id,
|
|
1525
|
+
permissions: ["clipboardReadWrite", "clipboardSanitizedWrite"],
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
/** The origin the current page's stores are keyed by. */
|
|
1529
|
+
async origin() {
|
|
1530
|
+
return new URL(this.page.url()).origin;
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Bend the network: mock a reply, block a request, slow it all
|
|
1534
|
+
* down, or stop pretending.
|
|
1535
|
+
*
|
|
1536
|
+
* Interception is only attached while there is a rule to
|
|
1537
|
+
* apply. Every paused request has to be answered or the page
|
|
1538
|
+
* waits on it forever, so an interceptor with nothing to say
|
|
1539
|
+
* is a liability rather than a neutral bystander.
|
|
1540
|
+
*/
|
|
1541
|
+
async shape(change) {
|
|
1542
|
+
return this.shaper.shape(change);
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Press a chord, or a sequence of them.
|
|
1546
|
+
*
|
|
1547
|
+
* Dispatch goes through the driver's keyboard rather than the
|
|
1548
|
+
* protocol directly, because the driver owns the layout table
|
|
1549
|
+
* that turns a key name into a code, a virtual key number and
|
|
1550
|
+
* the text it produces. Keeping a second opinion about that
|
|
1551
|
+
* would be a second thing to be wrong about.
|
|
1552
|
+
*/
|
|
1553
|
+
async press(keys) {
|
|
1554
|
+
await this.ready();
|
|
1555
|
+
const parsed = parseChords(keys, KNOWN_KEYS);
|
|
1556
|
+
if ("refusal" in parsed)
|
|
1557
|
+
return parsed;
|
|
1558
|
+
const pressed = [];
|
|
1559
|
+
for (const chord of parsed.chords) {
|
|
1560
|
+
for (const modifier of chord.modifiers) {
|
|
1561
|
+
await this.page.keyboard.down(modifier);
|
|
1562
|
+
}
|
|
1563
|
+
try {
|
|
1564
|
+
await this.page.keyboard.press(chord.key);
|
|
1565
|
+
}
|
|
1566
|
+
finally {
|
|
1567
|
+
// Whatever the key press did, the modifiers come back up.
|
|
1568
|
+
// A chord that threw mid-press used to leave Control or
|
|
1569
|
+
// Meta logically held for the life of a session that is
|
|
1570
|
+
// long lived by design, so every later click became a
|
|
1571
|
+
// modified click and the symptom appeared calls away
|
|
1572
|
+
// from the cause.
|
|
1573
|
+
for (const modifier of [...chord.modifiers].reverse()) {
|
|
1574
|
+
await this.page.keyboard.up(modifier).catch(() => {
|
|
1575
|
+
// The page may be gone; there is no key state left
|
|
1576
|
+
// to corrupt if it is.
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
pressed.push([...chord.modifiers, chord.key].join("+"));
|
|
1581
|
+
}
|
|
1582
|
+
return { pressed };
|
|
1583
|
+
}
|
|
1584
|
+
/** Send raw text as keystrokes, wherever focus happens to be. */
|
|
1585
|
+
async typeRaw(text) {
|
|
1586
|
+
await this.ready();
|
|
1587
|
+
await this.page.keyboard.type(text);
|
|
1588
|
+
}
|
|
1589
|
+
/** Dispatch a composed mouse gesture. */
|
|
1590
|
+
async pointerGesture(steps) {
|
|
1591
|
+
await this.ready();
|
|
1592
|
+
for (const step of steps) {
|
|
1593
|
+
await this.cdp.send("Input.dispatchMouseEvent", {
|
|
1594
|
+
type: step.type,
|
|
1595
|
+
x: step.x,
|
|
1596
|
+
y: step.y,
|
|
1597
|
+
button: step.button,
|
|
1598
|
+
clickCount: step.clickCount,
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
/** Scroll by wheel, which is not the same as scrolling by script. */
|
|
1603
|
+
async wheel(at, byX, byY) {
|
|
1604
|
+
await this.ready();
|
|
1605
|
+
await this.cdp.send("Input.dispatchMouseEvent", {
|
|
1606
|
+
type: "mouseWheel",
|
|
1607
|
+
x: at.x,
|
|
1608
|
+
y: at.y,
|
|
1609
|
+
deltaX: byX,
|
|
1610
|
+
deltaY: byY,
|
|
1611
|
+
button: "none",
|
|
1612
|
+
clickCount: 0,
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Dispatch a composed touch gesture.
|
|
1617
|
+
*
|
|
1618
|
+
* Touch events reach the page whether or not touch emulation
|
|
1619
|
+
* is on, so this never refuses. What emulation changes is what
|
|
1620
|
+
* the page believes about itself: maxTouchPoints and the
|
|
1621
|
+
* coarse-pointer media query. A page that decides at load time
|
|
1622
|
+
* whether to attach touch handlers will not have attached any,
|
|
1623
|
+
* and the gesture lands on nothing, so the caller is told
|
|
1624
|
+
* rather than stopped.
|
|
1625
|
+
*/
|
|
1626
|
+
async touchGesture(steps) {
|
|
1627
|
+
await this.ready();
|
|
1628
|
+
for (const step of steps) {
|
|
1629
|
+
await this.cdp.send("Input.dispatchTouchEvent", {
|
|
1630
|
+
type: step.type,
|
|
1631
|
+
touchPoints: step.points.map((point) => ({
|
|
1632
|
+
x: point.x,
|
|
1633
|
+
y: point.y,
|
|
1634
|
+
id: point.id,
|
|
1635
|
+
})),
|
|
1636
|
+
});
|
|
1637
|
+
if (step.pauseMs) {
|
|
1638
|
+
await new Promise((resolve) => setTimeout(resolve, step.pauseMs));
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* Walk the page with the Tab key and report what happened.
|
|
1644
|
+
*
|
|
1645
|
+
* This is one of the two reads that deliberately change the
|
|
1646
|
+
* page, since there is no way to learn where focus goes
|
|
1647
|
+
* without moving it. Focus is put back afterwards and the
|
|
1648
|
+
* result says so.
|
|
1649
|
+
*
|
|
1650
|
+
* The walk runs to twice the number of focusable things plus a
|
|
1651
|
+
* little, which is enough for a healthy page to come round
|
|
1652
|
+
* twice and for a trap to show its cycle. Stopping sooner
|
|
1653
|
+
* would report a trap as a short page.
|
|
1654
|
+
*/
|
|
1655
|
+
async keyboardWalk(maxStops) {
|
|
1656
|
+
await this.ready();
|
|
1657
|
+
// Remember where the caller's focus and viewport were before
|
|
1658
|
+
// disturbing either, so the page can be handed back as found.
|
|
1659
|
+
await this.evaluateJson(WALK_REMEMBER);
|
|
1660
|
+
// Start from the top of the document so the first Tab lands
|
|
1661
|
+
// where a reader arriving at the page would land, not
|
|
1662
|
+
// wherever something happened to leave focus.
|
|
1663
|
+
//
|
|
1664
|
+
// This has to happen before the resting styles are collected,
|
|
1665
|
+
// not after. A dialog opened with showModal focuses its first
|
|
1666
|
+
// control, so collecting first recorded that control's focused
|
|
1667
|
+
// appearance as its resting one, and comparing the two later
|
|
1668
|
+
// found no difference and reported a perfectly visible focus
|
|
1669
|
+
// ring as missing.
|
|
1670
|
+
await this.page.evaluate(() => {
|
|
1671
|
+
if (document.activeElement instanceof HTMLElement) {
|
|
1672
|
+
document.activeElement.blur();
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
const collected = await this.evaluateJson(WALK_COLLECT);
|
|
1676
|
+
// The ceiling belongs to the default, not to the caller. It
|
|
1677
|
+
// used to apply to both, so a page with more controls than
|
|
1678
|
+
// the ceiling could not be walked to the end by any argument,
|
|
1679
|
+
// while the report that ran out of budget told the caller to
|
|
1680
|
+
// raise maxStops and walk the rest. Every larger number gave
|
|
1681
|
+
// the same four hundred stops and the same advice again.
|
|
1682
|
+
const cap = maxStops ??
|
|
1683
|
+
Math.min(collected.candidates.length * 2 + WALK_SLACK, MAX_WALK_STOPS);
|
|
1684
|
+
const stops = [];
|
|
1685
|
+
for (let step = 0; step < cap; step += 1) {
|
|
1686
|
+
await this.page.keyboard.press("Tab");
|
|
1687
|
+
const stop = await this.evaluateJson(WALK_READ);
|
|
1688
|
+
if (stop.focused === null) {
|
|
1689
|
+
stops.push({ ...stop, focused: BLANK_STYLE });
|
|
1690
|
+
continue;
|
|
1691
|
+
}
|
|
1692
|
+
stops.push(stop);
|
|
1693
|
+
}
|
|
1694
|
+
// If the walk ended stuck somewhere smaller than the page,
|
|
1695
|
+
// find out whether a keyboard user has any way back. That is
|
|
1696
|
+
// the difference between a modal and a dead end.
|
|
1697
|
+
//
|
|
1698
|
+
// The test used to be "any index repeats in the last four
|
|
1699
|
+
// stops", which every page whose tab cycle is three stops or
|
|
1700
|
+
// shorter satisfies on a healthy run, so Escape was pressed on
|
|
1701
|
+
// ordinary login forms. A page is only worth probing when the
|
|
1702
|
+
// loop it settled into is smaller than the set of things it
|
|
1703
|
+
// could have reached.
|
|
1704
|
+
let escapeFreed;
|
|
1705
|
+
const tail = stops.slice(-WALK_STUCK_SAMPLE).map((stop) => stop.index);
|
|
1706
|
+
const reached = new Set(stops.map((stop) => stop.index).filter((index) => index >= 0));
|
|
1707
|
+
const loopedSmall = tail.length > 0 &&
|
|
1708
|
+
new Set(tail).size < tail.length &&
|
|
1709
|
+
reached.size < collected.candidates.length;
|
|
1710
|
+
if (loopedSmall) {
|
|
1711
|
+
const before = tail.at(-1);
|
|
1712
|
+
await this.page.keyboard.press("Escape");
|
|
1713
|
+
await this.page.keyboard.press("Tab");
|
|
1714
|
+
const after = await this.evaluateJson(WALK_READ);
|
|
1715
|
+
escapeFreed = after.index !== before && !tail.includes(after.index);
|
|
1716
|
+
}
|
|
1717
|
+
await this.evaluateJson(WALK_RESTORE);
|
|
1718
|
+
// A walk that used its whole budget has not necessarily seen
|
|
1719
|
+
// the page. Saying so is what stops everything it did not
|
|
1720
|
+
// reach being reported as unreachable.
|
|
1721
|
+
const exhausted = stops.length >= cap && reached.size < collected.candidates.length;
|
|
1722
|
+
return {
|
|
1723
|
+
candidates: collected.candidates,
|
|
1724
|
+
stops,
|
|
1725
|
+
unreachable: collected.unreachable,
|
|
1726
|
+
...(escapeFreed === undefined ? {} : { escapeFreed }),
|
|
1727
|
+
...(exhausted ? { cappedAt: cap } : {}),
|
|
1728
|
+
// Which way the page reads has to come from the page. The
|
|
1729
|
+
// side-by-side order that is wrong in English is correct in
|
|
1730
|
+
// Arabic, and guessing left-to-right would report every
|
|
1731
|
+
// right-to-left toolbar as backwards.
|
|
1732
|
+
direction: await this.readingDirection(),
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
/** Which way the document says it reads. */
|
|
1736
|
+
async readingDirection() {
|
|
1737
|
+
try {
|
|
1738
|
+
const reads = await this.page.evaluate(() => getComputedStyle(document.documentElement).direction);
|
|
1739
|
+
return reads === "rtl" ? "rtl" : "ltr";
|
|
1740
|
+
}
|
|
1741
|
+
catch {
|
|
1742
|
+
// Left-to-right is the safe default: it is what most pages
|
|
1743
|
+
// are, and being wrong here only costs a finding nobody
|
|
1744
|
+
// asked for rather than a missed barrier.
|
|
1745
|
+
return "ltr";
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* The whole page flattened, frames and shadow content
|
|
1750
|
+
* included.
|
|
1751
|
+
*
|
|
1752
|
+
* The style properties have to be named up front because the
|
|
1753
|
+
* protocol returns their values as a bare array positioned
|
|
1754
|
+
* against the request. Asking for a handful keeps the snapshot
|
|
1755
|
+
* small; asking for none still gives geometry and structure.
|
|
1756
|
+
*/
|
|
1757
|
+
async snapshot(styleProperties = SNAPSHOT_STYLES) {
|
|
1758
|
+
await this.ready();
|
|
1759
|
+
const raw = await this.cdp.send("DOMSnapshot.captureSnapshot", {
|
|
1760
|
+
computedStyles: [...styleProperties],
|
|
1761
|
+
includeDOMRects: true,
|
|
1762
|
+
});
|
|
1763
|
+
return flattenSnapshot(raw, styleProperties);
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Run an expression in the page and describe what came back.
|
|
1767
|
+
*
|
|
1768
|
+
* The value is serialized inside the page rather than by the
|
|
1769
|
+
* protocol, because the protocol does not decline politely: a
|
|
1770
|
+
* circular object rejects the whole call with "object
|
|
1771
|
+
* reference chain is too long", losing the evaluation and any
|
|
1772
|
+
* account of it together.
|
|
1773
|
+
*
|
|
1774
|
+
* The protocol can still fail for its own reasons, so that is
|
|
1775
|
+
* caught too and returned as a refusal rather than thrown. An
|
|
1776
|
+
* expression that kills the session is not a useful tool.
|
|
1777
|
+
*/
|
|
1778
|
+
async evaluate(expression) {
|
|
1779
|
+
await this.ready();
|
|
1780
|
+
try {
|
|
1781
|
+
const response = await this.cdp.send("Runtime.evaluate", {
|
|
1782
|
+
expression: evaluationSource(expression),
|
|
1783
|
+
returnByValue: true,
|
|
1784
|
+
awaitPromise: true,
|
|
1785
|
+
userGesture: true,
|
|
1786
|
+
});
|
|
1787
|
+
if (response.exceptionDetails) {
|
|
1788
|
+
const threw = describeThrow(response.exceptionDetails);
|
|
1789
|
+
return {
|
|
1790
|
+
ok: false,
|
|
1791
|
+
threw: {
|
|
1792
|
+
...threw,
|
|
1793
|
+
frames: await this.resolveFrames(threw.frames),
|
|
1794
|
+
},
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
const value = response.result.value;
|
|
1798
|
+
if (!value) {
|
|
1799
|
+
return {
|
|
1800
|
+
ok: false,
|
|
1801
|
+
refused: "The page returned nothing the serializer could read.",
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
return { ok: true, result: value };
|
|
1805
|
+
}
|
|
1806
|
+
catch (error) {
|
|
1807
|
+
return {
|
|
1808
|
+
ok: false,
|
|
1809
|
+
refused: `The browser refused to run that: ` +
|
|
1810
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
/**
|
|
1815
|
+
* Run axe-core against the page and read back its findings.
|
|
1816
|
+
*
|
|
1817
|
+
* axe is injected fresh on every call rather than kept across
|
|
1818
|
+
* navigations. It is half a megabyte, but a navigation clears
|
|
1819
|
+
* the world, and an audit that silently reported nothing
|
|
1820
|
+
* because the library went away with the last page would be
|
|
1821
|
+
* worse than a slow one.
|
|
1822
|
+
*
|
|
1823
|
+
* Only violations and incomplete results are asked for. Passes
|
|
1824
|
+
* and inapplicable rules together outweigh them by an order of
|
|
1825
|
+
* magnitude and answer a question nobody asks.
|
|
1826
|
+
*
|
|
1827
|
+
* The experimental rules are switched on. axe leaves them off,
|
|
1828
|
+
* and five of them carry real WCAG criteria, so leaving them
|
|
1829
|
+
* off means 2.5.3 (label in name) is never checked and 1.3.1
|
|
1830
|
+
* is checked less thoroughly than it looks. An accessibility
|
|
1831
|
+
* reviewer reported label in name as missing, and it was, in
|
|
1832
|
+
* the sense that matters: the rule sat in the bundle unused.
|
|
1833
|
+
* Their findings are reported as needing a person rather than
|
|
1834
|
+
* as failures, since axe's own doubt travels with them.
|
|
1835
|
+
*/
|
|
1836
|
+
async audit(bar = "AAA") {
|
|
1837
|
+
await this.ready();
|
|
1838
|
+
const source = await readFile(axeSource(), "utf8");
|
|
1839
|
+
await this.cdp.send("Runtime.evaluate", { expression: source });
|
|
1840
|
+
const response = await this.cdp.send("Runtime.evaluate", {
|
|
1841
|
+
expression: "axe.run(document, { " +
|
|
1842
|
+
'resultTypes: ["violations", "incomplete"], ' +
|
|
1843
|
+
`rules: ${JSON.stringify(enabledRules(bar))} })`,
|
|
1844
|
+
awaitPromise: true,
|
|
1845
|
+
returnByValue: true,
|
|
1846
|
+
});
|
|
1847
|
+
if (response.exceptionDetails) {
|
|
1848
|
+
const threw = describeThrow(response.exceptionDetails);
|
|
1849
|
+
throw new Error(`axe could not run: ${threw.message}`);
|
|
1850
|
+
}
|
|
1851
|
+
return readAxeRun((response.result.value ?? {}));
|
|
1852
|
+
}
|
|
1853
|
+
/**
|
|
1854
|
+
* The page as the structural rules need to see it.
|
|
1855
|
+
*
|
|
1856
|
+
* Two accounts from the browser, joined on the backend node id
|
|
1857
|
+
* they share: the snapshot for attributes and layout, the
|
|
1858
|
+
* accessibility tree for roles and names. Neither carries
|
|
1859
|
+
* both, and every structural rule needs both.
|
|
1860
|
+
*/
|
|
1861
|
+
/**
|
|
1862
|
+
* What holds focus at this moment, moving nothing.
|
|
1863
|
+
*
|
|
1864
|
+
* The keyboard walk answers what a whole page does; this answers
|
|
1865
|
+
* where focus is right now, which is the question between
|
|
1866
|
+
* actions. It reads rather than presses, so it can be asked after
|
|
1867
|
+
* a click or a navigation without disturbing what it reports.
|
|
1868
|
+
*/
|
|
1869
|
+
async focusHolder() {
|
|
1870
|
+
await this.ready();
|
|
1871
|
+
const { result } = await this.cdp.send("Runtime.evaluate", {
|
|
1872
|
+
expression: FOCUS_PROBE,
|
|
1873
|
+
returnByValue: true,
|
|
1874
|
+
});
|
|
1875
|
+
return result.value;
|
|
1876
|
+
}
|
|
1877
|
+
async structure() {
|
|
1878
|
+
// Every sibling read waits out a crash recovery first, and
|
|
1879
|
+
// this one did not. Promise.all evaluates this.cdp.send when
|
|
1880
|
+
// the array is built, so a call landing during recovery sent
|
|
1881
|
+
// getFullAXTree down the channel recover() was replacing.
|
|
1882
|
+
await this.ready();
|
|
1883
|
+
const [nodes, tree] = await Promise.all([
|
|
1884
|
+
this.snapshot(),
|
|
1885
|
+
this.cdp.send("Accessibility.getFullAXTree"),
|
|
1886
|
+
]);
|
|
1887
|
+
const facts = [];
|
|
1888
|
+
for (const axNode of tree.nodes) {
|
|
1889
|
+
if (axNode.backendDOMNodeId === undefined)
|
|
1890
|
+
continue;
|
|
1891
|
+
const focusable = axNode.properties?.find((property) => property.name === "focusable");
|
|
1892
|
+
facts.push({
|
|
1893
|
+
backendNodeId: axNode.backendDOMNodeId,
|
|
1894
|
+
...(axNode.role?.value === undefined
|
|
1895
|
+
? {}
|
|
1896
|
+
: { role: axNode.role.value }),
|
|
1897
|
+
...(axNode.name?.value === undefined
|
|
1898
|
+
? {}
|
|
1899
|
+
: { name: axNode.name.value }),
|
|
1900
|
+
// Left absent when the tree did not say, rather than
|
|
1901
|
+
// flattened to false. An ignored node carries no
|
|
1902
|
+
// properties at all, and reporting that as "cannot take
|
|
1903
|
+
// focus" is how the hidden-but-focusable rule ended up
|
|
1904
|
+
// unable to see its own subject.
|
|
1905
|
+
...(focusable === undefined
|
|
1906
|
+
? {}
|
|
1907
|
+
: { focusable: focusable.value?.value === true }),
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
return buildStructure(nodes, facts);
|
|
1911
|
+
}
|
|
1912
|
+
/**
|
|
1913
|
+
* What the layout actually did, as the browser measured it.
|
|
1914
|
+
*
|
|
1915
|
+
* Read in one page-side pass rather than a protocol call per
|
|
1916
|
+
* element: a page of any size would otherwise cost thousands
|
|
1917
|
+
* of round trips to answer one question.
|
|
1918
|
+
*/
|
|
1919
|
+
/**
|
|
1920
|
+
* Every pointer target on the page, with the facts WCAG 2.5.8
|
|
1921
|
+
* needs to judge its size.
|
|
1922
|
+
*
|
|
1923
|
+
* Separate from layout() because the two ask different
|
|
1924
|
+
* questions: layout wants everything drawn, this wants only
|
|
1925
|
+
* what a finger has to hit, plus the two exceptions the
|
|
1926
|
+
* criterion turns on.
|
|
1927
|
+
*/
|
|
1928
|
+
async targets() {
|
|
1929
|
+
await this.ready();
|
|
1930
|
+
const response = await this.cdp.send("Runtime.evaluate", {
|
|
1931
|
+
expression: TARGET_CAPTURE,
|
|
1932
|
+
returnByValue: true,
|
|
1933
|
+
});
|
|
1934
|
+
if (response.exceptionDetails) {
|
|
1935
|
+
const threw = describeThrow(response.exceptionDetails);
|
|
1936
|
+
throw new Error(`Could not measure the targets: ${threw.message}`);
|
|
1937
|
+
}
|
|
1938
|
+
return response.result.value;
|
|
1939
|
+
}
|
|
1940
|
+
async layout() {
|
|
1941
|
+
await this.ready();
|
|
1942
|
+
const response = await this.cdp.send("Runtime.evaluate", {
|
|
1943
|
+
expression: visualCaptureSource(),
|
|
1944
|
+
returnByValue: true,
|
|
1945
|
+
});
|
|
1946
|
+
if (response.exceptionDetails) {
|
|
1947
|
+
const threw = describeThrow(response.exceptionDetails);
|
|
1948
|
+
throw new Error(`Could not read the layout: ${threw.message}`);
|
|
1949
|
+
}
|
|
1950
|
+
return response.result.value;
|
|
1951
|
+
}
|
|
1952
|
+
/**
|
|
1953
|
+
* What the page is built from, sampled element by element.
|
|
1954
|
+
*
|
|
1955
|
+
* Read in one page-side pass, and filtered there rather than
|
|
1956
|
+
* here: an inherited value is not a decision, and carrying
|
|
1957
|
+
* every element's inherited colour back only to discard it
|
|
1958
|
+
* would be most of the payload.
|
|
1959
|
+
*/
|
|
1960
|
+
async styleSamples() {
|
|
1961
|
+
await this.ready();
|
|
1962
|
+
const response = await this.cdp.send("Runtime.evaluate", {
|
|
1963
|
+
expression: inventorySource(),
|
|
1964
|
+
returnByValue: true,
|
|
1965
|
+
});
|
|
1966
|
+
if (response.exceptionDetails) {
|
|
1967
|
+
const threw = describeThrow(response.exceptionDetails);
|
|
1968
|
+
throw new Error(`Could not sample the styles: ${threw.message}`);
|
|
1969
|
+
}
|
|
1970
|
+
return (response.result.value ?? []);
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Install the performance observers ahead of every page.
|
|
1974
|
+
*
|
|
1975
|
+
* Largest contentful paint and layout shift are events rather
|
|
1976
|
+
* than state, so an observer registered after a page loads has
|
|
1977
|
+
* missed them. The buffered flag recovers the timings but not
|
|
1978
|
+
* the element that painted or the nodes that moved, which is
|
|
1979
|
+
* the half worth having, so this goes in through the hook that
|
|
1980
|
+
* runs before the document does.
|
|
1981
|
+
*/
|
|
1982
|
+
async watchVitals() {
|
|
1983
|
+
await this.cdp.send("Page.addScriptToEvaluateOnNewDocument", {
|
|
1984
|
+
source: observerBootstrap(),
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
/** What the current page cost to show. */
|
|
1988
|
+
async vitals() {
|
|
1989
|
+
await this.ready();
|
|
1990
|
+
const response = await this.cdp.send("Runtime.evaluate", {
|
|
1991
|
+
expression: readVitalsSource(),
|
|
1992
|
+
returnByValue: true,
|
|
1993
|
+
});
|
|
1994
|
+
if (response.exceptionDetails) {
|
|
1995
|
+
const threw = describeThrow(response.exceptionDetails);
|
|
1996
|
+
return { shifts: [], longTasks: [], paints: {}, error: threw.message };
|
|
1997
|
+
}
|
|
1998
|
+
return response.result.value;
|
|
1999
|
+
}
|
|
2000
|
+
/** Run a page-side source string and read back its value. */
|
|
2001
|
+
async evaluateJson(source) {
|
|
2002
|
+
const { result } = await this.cdp.send("Runtime.evaluate", {
|
|
2003
|
+
expression: source,
|
|
2004
|
+
returnByValue: true,
|
|
2005
|
+
});
|
|
2006
|
+
return result.value;
|
|
2007
|
+
}
|
|
2008
|
+
/** Whether the page believes it is being touched. */
|
|
2009
|
+
get touchEmulated() {
|
|
2010
|
+
return this.emulation.touch;
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Wait for the page to reach a state, and say what happened.
|
|
2014
|
+
*
|
|
2015
|
+
* The timeout is a real answer rather than an error. A page
|
|
2016
|
+
* that never reaches the state is the finding, so the outcome
|
|
2017
|
+
* carries what was true instead.
|
|
2018
|
+
*/
|
|
2019
|
+
async waitFor(condition, timeoutMs = DEFAULT_WAIT_MS) {
|
|
2020
|
+
await this.ready();
|
|
2021
|
+
const startedAt = Date.now();
|
|
2022
|
+
const waited = () => Date.now() - startedAt;
|
|
2023
|
+
if (condition.kind === "duration") {
|
|
2024
|
+
await new Promise((resolve) => setTimeout(resolve, condition.ms));
|
|
2025
|
+
return { met: true, waitedMs: waited(), condition };
|
|
2026
|
+
}
|
|
2027
|
+
// Where the request log stood when the wait began. Waiting
|
|
2028
|
+
// for a request used to search the whole session's log, which
|
|
2029
|
+
// is never cleared, so the second lap of an observe-act-wait
|
|
2030
|
+
// loop was satisfied instantly by the previous lap's response
|
|
2031
|
+
// and reported its old status. That is a green wait in the
|
|
2032
|
+
// log hiding a race, which is the most expensive shape of
|
|
2033
|
+
// test defect to chase.
|
|
2034
|
+
const since = this.telemetry.protocolNow();
|
|
2035
|
+
while (waited() < timeoutMs) {
|
|
2036
|
+
const reached = await this.check(condition, since);
|
|
2037
|
+
if (reached.met) {
|
|
2038
|
+
return {
|
|
2039
|
+
met: true,
|
|
2040
|
+
waitedMs: waited(),
|
|
2041
|
+
condition,
|
|
2042
|
+
...(reached.detail === undefined ? {} : { detail: reached.detail }),
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS));
|
|
2046
|
+
}
|
|
2047
|
+
const missed = await this.check(condition);
|
|
2048
|
+
return {
|
|
2049
|
+
met: false,
|
|
2050
|
+
waitedMs: waited(),
|
|
2051
|
+
condition,
|
|
2052
|
+
...(missed.saw === undefined ? {} : { saw: missed.saw }),
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
/** Look once: is the condition true right now? */
|
|
2056
|
+
async check(condition,
|
|
2057
|
+
/**
|
|
2058
|
+
* Protocol time the wait began. A request that finished
|
|
2059
|
+
* before this belongs to an earlier lap and cannot satisfy
|
|
2060
|
+
* the condition.
|
|
2061
|
+
*/
|
|
2062
|
+
since) {
|
|
2063
|
+
switch (condition.kind) {
|
|
2064
|
+
case "selector":
|
|
2065
|
+
case "gone": {
|
|
2066
|
+
// A probe that could not run is not an absent element. A
|
|
2067
|
+
// detached frame or a dead renderer used to come back as
|
|
2068
|
+
// null, which satisfied kind "gone" outright, so the wait
|
|
2069
|
+
// succeeded precisely when the page was broken and the
|
|
2070
|
+
// real failure surfaced several calls later.
|
|
2071
|
+
let found;
|
|
2072
|
+
try {
|
|
2073
|
+
found = await this.page.$(condition.selector);
|
|
2074
|
+
}
|
|
2075
|
+
catch (error) {
|
|
2076
|
+
return {
|
|
2077
|
+
met: false,
|
|
2078
|
+
saw: `The page could not be asked: ${String(error)}`,
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
2081
|
+
const present = found !== null;
|
|
2082
|
+
if (found)
|
|
2083
|
+
await found.dispose();
|
|
2084
|
+
const met = condition.kind === "selector" ? present : !present;
|
|
2085
|
+
return {
|
|
2086
|
+
met,
|
|
2087
|
+
...(met
|
|
2088
|
+
? {}
|
|
2089
|
+
: {
|
|
2090
|
+
saw: present
|
|
2091
|
+
? "It is still there."
|
|
2092
|
+
: "Nothing matches that selector.",
|
|
2093
|
+
}),
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
case "text": {
|
|
2097
|
+
const has = await this.page
|
|
2098
|
+
.evaluate((needle) => (document.body?.innerText ?? "").includes(needle), condition.text)
|
|
2099
|
+
.catch(() => false);
|
|
2100
|
+
return {
|
|
2101
|
+
met: has,
|
|
2102
|
+
...(has ? {} : { saw: "The page does not contain it." }),
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
case "idle": {
|
|
2106
|
+
const requests = this.requests();
|
|
2107
|
+
const met = isIdle(requests, condition.quietMs, this.telemetry.protocolNow());
|
|
2108
|
+
const busy = inFlight(requests);
|
|
2109
|
+
return {
|
|
2110
|
+
met,
|
|
2111
|
+
...(met
|
|
2112
|
+
? {}
|
|
2113
|
+
: {
|
|
2114
|
+
saw: busy.length > 0
|
|
2115
|
+
? `${busy.length} still in flight, including ` +
|
|
2116
|
+
`${busy[0]?.url}.`
|
|
2117
|
+
: "The page keeps starting new requests.",
|
|
2118
|
+
}),
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
case "request": {
|
|
2122
|
+
const fresh = this.requests().filter((request) => since === undefined || request.startedAt >= since);
|
|
2123
|
+
const matched = fresh.filter((request) => matchesPattern({
|
|
2124
|
+
pattern: condition.pattern,
|
|
2125
|
+
url: request.url,
|
|
2126
|
+
}) && request.state !== "pending");
|
|
2127
|
+
const last = matched.at(-1);
|
|
2128
|
+
if (!last) {
|
|
2129
|
+
const pending = fresh.filter((request) => matchesPattern({
|
|
2130
|
+
pattern: condition.pattern,
|
|
2131
|
+
url: request.url,
|
|
2132
|
+
}));
|
|
2133
|
+
return {
|
|
2134
|
+
met: false,
|
|
2135
|
+
saw: pending.length > 0
|
|
2136
|
+
? `${pending.length} matching, none finished yet.`
|
|
2137
|
+
: "Nothing has requested it.",
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
if (condition.status !== undefined &&
|
|
2141
|
+
last.status !== condition.status) {
|
|
2142
|
+
// Keep waiting rather than declaring the condition met.
|
|
2143
|
+
// A retry may still answer with the status asked for, and
|
|
2144
|
+
// if none does, the timeout reports the status that kept
|
|
2145
|
+
// arriving, which is the finding.
|
|
2146
|
+
return {
|
|
2147
|
+
met: false,
|
|
2148
|
+
saw: `${last.method} ${last.url} answered ${requestStatus(last)}.`,
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
return {
|
|
2152
|
+
met: true,
|
|
2153
|
+
// A cancelled request usually carries no failure text,
|
|
2154
|
+
// and printing the field raw said "cancelled: undefined"
|
|
2155
|
+
// to someone waiting on a request that never arrived.
|
|
2156
|
+
detail: last.state === "complete"
|
|
2157
|
+
? `${last.method} ${last.url} answered ${requestStatus(last)}.`
|
|
2158
|
+
: `${last.method} ${last.url} ${requestStatus(last)}.`,
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
case "attribute": {
|
|
2162
|
+
// Read through one evaluate rather than a handle, so an
|
|
2163
|
+
// element replaced between finding it and reading it is
|
|
2164
|
+
// simply absent on the next poll instead of throwing on a
|
|
2165
|
+
// stale handle.
|
|
2166
|
+
let held;
|
|
2167
|
+
try {
|
|
2168
|
+
held = await this.page.evaluate((selector, attribute) => {
|
|
2169
|
+
const found = document.querySelector(selector);
|
|
2170
|
+
if (found === null)
|
|
2171
|
+
return undefined;
|
|
2172
|
+
return found.getAttribute(attribute);
|
|
2173
|
+
}, condition.selector, condition.attribute);
|
|
2174
|
+
}
|
|
2175
|
+
catch (error) {
|
|
2176
|
+
return {
|
|
2177
|
+
met: false,
|
|
2178
|
+
saw: `The page could not be asked: ${String(error)}`,
|
|
2179
|
+
};
|
|
2180
|
+
}
|
|
2181
|
+
if (held === undefined) {
|
|
2182
|
+
// Absent element and absent attribute are different
|
|
2183
|
+
// answers. Treating the first as "attribute is gone"
|
|
2184
|
+
// would satisfy a wait for a control that never rendered.
|
|
2185
|
+
return { met: false, saw: "Nothing matches that selector." };
|
|
2186
|
+
}
|
|
2187
|
+
const met = condition.value === undefined
|
|
2188
|
+
? held === null
|
|
2189
|
+
: held === condition.value;
|
|
2190
|
+
return {
|
|
2191
|
+
met,
|
|
2192
|
+
...(met
|
|
2193
|
+
? {}
|
|
2194
|
+
: {
|
|
2195
|
+
saw: held === null
|
|
2196
|
+
? `It has no ${condition.attribute}.`
|
|
2197
|
+
: `Its ${condition.attribute} is "${held}".`,
|
|
2198
|
+
}),
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
case "count": {
|
|
2202
|
+
let seen;
|
|
2203
|
+
try {
|
|
2204
|
+
seen = await this.page.evaluate((selector) => document.querySelectorAll(selector).length, condition.selector);
|
|
2205
|
+
}
|
|
2206
|
+
catch (error) {
|
|
2207
|
+
return {
|
|
2208
|
+
met: false,
|
|
2209
|
+
saw: `The page could not be asked: ${String(error)}`,
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
const met = seen === condition.count;
|
|
2213
|
+
return {
|
|
2214
|
+
met,
|
|
2215
|
+
...(met ? {} : { saw: `There are ${seen}.` }),
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
case "animations": {
|
|
2219
|
+
// Catching to zero declared the animations settled
|
|
2220
|
+
// whenever the probe itself failed, which is the same
|
|
2221
|
+
// false pass as the selector arm above.
|
|
2222
|
+
let running;
|
|
2223
|
+
try {
|
|
2224
|
+
running = await this.page.evaluate(() => document
|
|
2225
|
+
.getAnimations()
|
|
2226
|
+
.filter((animation) => animation.playState === "running")
|
|
2227
|
+
.length);
|
|
2228
|
+
}
|
|
2229
|
+
catch (error) {
|
|
2230
|
+
return {
|
|
2231
|
+
met: false,
|
|
2232
|
+
saw: `The page could not be asked: ${String(error)}`,
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
return {
|
|
2236
|
+
met: running === 0,
|
|
2237
|
+
...(running === 0
|
|
2238
|
+
? {}
|
|
2239
|
+
: { saw: `${running} animations are still running.` }),
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
case "duration":
|
|
2243
|
+
return { met: true };
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
/**
|
|
2247
|
+
* Everything this session has accumulated, in one reading.
|
|
2248
|
+
*
|
|
2249
|
+
* Emulation, interception and dialog policy all change what
|
|
2250
|
+
* every other reading means, and none of them is visible in
|
|
2251
|
+
* those readings, so they are gathered here.
|
|
2252
|
+
*/
|
|
2253
|
+
async status() {
|
|
2254
|
+
await this.ready();
|
|
2255
|
+
const logs = this.logs();
|
|
2256
|
+
const heard = await this.heard(0);
|
|
2257
|
+
const requests = this.requests();
|
|
2258
|
+
const gaps = await this.emulation.currentGaps();
|
|
2259
|
+
return {
|
|
2260
|
+
name: this.name,
|
|
2261
|
+
url: this.page.url(),
|
|
2262
|
+
title: await this.page.title().catch(() => ""),
|
|
2263
|
+
emulation: this.emulation.asked,
|
|
2264
|
+
...(gaps.length === 0 ? {} : { gaps }),
|
|
2265
|
+
rules: this.shaper.current.rules,
|
|
2266
|
+
...(this.shaper.current.throttle === undefined
|
|
2267
|
+
? {}
|
|
2268
|
+
: { throttle: this.shaper.current.throttle }),
|
|
2269
|
+
dialogPolicy: this.telemetry.dialogs.policy,
|
|
2270
|
+
dialogsSeen: this.telemetry.dialogs.seen.length,
|
|
2271
|
+
logs: { count: logs.entries.length, cursor: logs.cursor },
|
|
2272
|
+
announcements: { count: heard.entries.length, cursor: heard.cursor },
|
|
2273
|
+
requests: {
|
|
2274
|
+
count: requests.length,
|
|
2275
|
+
failed: requests.filter((request) => request.state === "failed").length,
|
|
2276
|
+
},
|
|
2277
|
+
history: this.history,
|
|
2278
|
+
artifacts: this.artifacts.written,
|
|
2279
|
+
recording: describeRecording(recordingInProgress(), this.name),
|
|
2280
|
+
};
|
|
2281
|
+
}
|
|
2282
|
+
/** How the network is currently being bent. */
|
|
2283
|
+
get shaping() {
|
|
2284
|
+
return this.shaper.current;
|
|
2285
|
+
}
|
|
2286
|
+
/**
|
|
2287
|
+
* What the page has announced since a cursor, with the cursor
|
|
2288
|
+
* to read from next time and how many were dropped.
|
|
2289
|
+
*/
|
|
2290
|
+
async heard(since = 0) {
|
|
2291
|
+
return this.telemetry.heard(since);
|
|
2292
|
+
}
|
|
2293
|
+
/** Close the tab and the context holding its state. */
|
|
2294
|
+
async close() {
|
|
2295
|
+
try {
|
|
2296
|
+
await this.cdp.detach();
|
|
2297
|
+
}
|
|
2298
|
+
catch {
|
|
2299
|
+
// The session may already be gone; closing the context is enough.
|
|
2300
|
+
}
|
|
2301
|
+
// Closing the context disposes its pages along with the
|
|
2302
|
+
// cookies, storage and cache they accumulated.
|
|
2303
|
+
await this.context.close();
|
|
2304
|
+
}
|
|
2305
|
+
/**
|
|
2306
|
+
* Everything worth knowing about one element.
|
|
2307
|
+
*
|
|
2308
|
+
* Each fact is asked of the browser rather than worked out
|
|
2309
|
+
* here: the box from the layout engine, the occluder from a
|
|
2310
|
+
* hit test, the styles from the cascade, the announcement from
|
|
2311
|
+
* the accessibility tree.
|
|
2312
|
+
*/
|
|
2313
|
+
async inspect(target, options = {}) {
|
|
2314
|
+
const tree = await this.axTree();
|
|
2315
|
+
const resolution = resolveTarget(tree, target);
|
|
2316
|
+
if (resolution.kind === "notFound") {
|
|
2317
|
+
return { ok: false, refusal: notFoundRefusal(tree, target) };
|
|
2318
|
+
}
|
|
2319
|
+
if (resolution.kind === "ambiguous") {
|
|
2320
|
+
return { ok: false, refusal: ambiguityRefusal(tree, target) };
|
|
2321
|
+
}
|
|
2322
|
+
const node = subtreeAt(tree, resolution.backendDomId) ?? tree;
|
|
2323
|
+
const objectId = await this.objectFor(resolution.backendDomId);
|
|
2324
|
+
try {
|
|
2325
|
+
return {
|
|
2326
|
+
ok: true,
|
|
2327
|
+
inspection: await this.inspectNode(resolution.backendDomId, objectId, node, options),
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
finally {
|
|
2331
|
+
await this.release(objectId);
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
/**
|
|
2335
|
+
* Which fonts the browser actually painted with.
|
|
2336
|
+
*
|
|
2337
|
+
* A computed style reports the stack that was asked for, not
|
|
2338
|
+
* what was used. "SF Pro, Helvetica, sans-serif" reads the same
|
|
2339
|
+
* whether the first one loaded or the page quietly fell back to
|
|
2340
|
+
* the third, which is most of the answer to why a screenshot
|
|
2341
|
+
* from one machine does not match another.
|
|
2342
|
+
*/
|
|
2343
|
+
async fontsOf(backendNodeId) {
|
|
2344
|
+
const nodeId = await this.frontendNodeFor(backendNodeId);
|
|
2345
|
+
if (nodeId === undefined)
|
|
2346
|
+
return undefined;
|
|
2347
|
+
try {
|
|
2348
|
+
const { fonts } = await this.cdp.send("CSS.getPlatformFontsForNode", {
|
|
2349
|
+
nodeId,
|
|
2350
|
+
});
|
|
2351
|
+
if (fonts.length === 0)
|
|
2352
|
+
return undefined;
|
|
2353
|
+
return fonts.map((font) => ({
|
|
2354
|
+
family: font.familyName,
|
|
2355
|
+
glyphs: font.glyphCount,
|
|
2356
|
+
}));
|
|
2357
|
+
}
|
|
2358
|
+
catch {
|
|
2359
|
+
// An element with no text has no fonts, and a detached one
|
|
2360
|
+
// cannot be asked. Neither is worth failing an inspection.
|
|
2361
|
+
return undefined;
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* The element's own words, and the attributes it carries.
|
|
2366
|
+
*
|
|
2367
|
+
* The accessible name answers what a control is called, which
|
|
2368
|
+
* is a different question from what it says. A counter reading
|
|
2369
|
+
* "42", a status reading "3 items updated" and an input holding
|
|
2370
|
+
* a typed email all have names that say none of that, so
|
|
2371
|
+
* asserting on them meant dropping to a raw evaluate. Data
|
|
2372
|
+
* attributes are here for the same reason: teams hang test
|
|
2373
|
+
* state on them, and the accessibility tree cannot see them.
|
|
2374
|
+
*/
|
|
2375
|
+
async contentOf(objectId) {
|
|
2376
|
+
try {
|
|
2377
|
+
const { result } = await this.cdp.send("Runtime.callFunctionOn", {
|
|
2378
|
+
objectId,
|
|
2379
|
+
returnByValue: true,
|
|
2380
|
+
functionDeclaration: CONTENT_PROBE,
|
|
2381
|
+
});
|
|
2382
|
+
const read = result.value;
|
|
2383
|
+
if (read === undefined)
|
|
2384
|
+
return undefined;
|
|
2385
|
+
return {
|
|
2386
|
+
...(read.text === "" ? {} : { text: read.text }),
|
|
2387
|
+
...(read.value === undefined ? {} : { value: read.value }),
|
|
2388
|
+
...(read.attributes.length === 0
|
|
2389
|
+
? {}
|
|
2390
|
+
: { attributes: Object.fromEntries(read.attributes) }),
|
|
2391
|
+
};
|
|
2392
|
+
}
|
|
2393
|
+
catch {
|
|
2394
|
+
// A detached node cannot be read, and an inspection that
|
|
2395
|
+
// reports everything else is worth more than a refusal.
|
|
2396
|
+
return undefined;
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
/**
|
|
2400
|
+
* A handle on the element inside this session.
|
|
2401
|
+
*
|
|
2402
|
+
* Object identifiers belong to the protocol session that
|
|
2403
|
+
* minted them, so puppeteer's own handles are unusable here.
|
|
2404
|
+
* A backend node id is the identity both sides agree on.
|
|
2405
|
+
*/
|
|
2406
|
+
async objectFor(backendNodeId) {
|
|
2407
|
+
try {
|
|
2408
|
+
const { object } = await this.cdp.send("DOM.resolveNode", {
|
|
2409
|
+
backendNodeId,
|
|
2410
|
+
});
|
|
2411
|
+
return object.objectId;
|
|
2412
|
+
}
|
|
2413
|
+
catch {
|
|
2414
|
+
// The node can go out of the document between being named
|
|
2415
|
+
// and being looked at; the inspection reports what it can.
|
|
2416
|
+
return undefined;
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
/**
|
|
2420
|
+
* Swap a text node for the element that paints it.
|
|
2421
|
+
*
|
|
2422
|
+
* A `StaticText` target resolves to a text node, which is right
|
|
2423
|
+
* for reading its box and wrong for everything that touches
|
|
2424
|
+
* style. Returns the original handle when it is already an
|
|
2425
|
+
* element, so callers can promote unconditionally.
|
|
2426
|
+
*/
|
|
2427
|
+
async paintingElement(objectId) {
|
|
2428
|
+
try {
|
|
2429
|
+
const { result } = await this.cdp.send("Runtime.callFunctionOn", {
|
|
2430
|
+
objectId,
|
|
2431
|
+
functionDeclaration: PAINTING_ELEMENT_PROBE,
|
|
2432
|
+
});
|
|
2433
|
+
return result.objectId ?? objectId;
|
|
2434
|
+
}
|
|
2435
|
+
catch {
|
|
2436
|
+
// A detached node has no painting element either, and the
|
|
2437
|
+
// caller's own reads will report what it could not find.
|
|
2438
|
+
return objectId;
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
/** Let go of a handle, whether or not the page still has it. */
|
|
2442
|
+
async release(objectId) {
|
|
2443
|
+
if (!objectId)
|
|
2444
|
+
return;
|
|
2445
|
+
await this.cdp
|
|
2446
|
+
.send("Runtime.releaseObject", { objectId })
|
|
2447
|
+
// Releasing a handle the page already dropped is not a
|
|
2448
|
+
// failure worth surfacing.
|
|
2449
|
+
.catch(() => { });
|
|
2450
|
+
}
|
|
2451
|
+
/** Gather every fact about an element the browser will give. */
|
|
2452
|
+
async inspectNode(backendNodeId, objectId, node, options) {
|
|
2453
|
+
const box = await this.boxOf(backendNodeId);
|
|
2454
|
+
const styles = objectId ? await this.stylesOf(objectId) : undefined;
|
|
2455
|
+
const viewport = await this.viewport();
|
|
2456
|
+
const coveredBy = box && objectId ? await this.occluderOf(objectId) : undefined;
|
|
2457
|
+
const visibility = judgeVisibility({
|
|
2458
|
+
rendered: box !== undefined,
|
|
2459
|
+
...(box === undefined ? {} : { border: box.border }),
|
|
2460
|
+
...(viewport === undefined ? {} : { viewport }),
|
|
2461
|
+
...(coveredBy === undefined ? {} : { coveredBy }),
|
|
2462
|
+
...(styles?.opacity === undefined
|
|
2463
|
+
? {}
|
|
2464
|
+
: { opacity: Number(styles.opacity) }),
|
|
2465
|
+
...(styles?.visibility === undefined
|
|
2466
|
+
? {}
|
|
2467
|
+
: { visibility: styles.visibility }),
|
|
2468
|
+
});
|
|
2469
|
+
const initials = await this.initials();
|
|
2470
|
+
const curated = styles === undefined
|
|
2471
|
+
? undefined
|
|
2472
|
+
: curateStyles(styles, {
|
|
2473
|
+
...(initials === undefined ? {} : { initials }),
|
|
2474
|
+
...(options.styles === undefined ? {} : { only: options.styles }),
|
|
2475
|
+
});
|
|
2476
|
+
const wantsBehaviour = options.behaviour === true && objectId !== undefined;
|
|
2477
|
+
const variants = options.states && objectId && curated
|
|
2478
|
+
? await this.variantsOf(backendNodeId, objectId, curated, options.states, initials, options)
|
|
2479
|
+
: undefined;
|
|
2480
|
+
const content = objectId ? await this.contentOf(objectId) : undefined;
|
|
2481
|
+
const fonts = await this.fontsOf(backendNodeId);
|
|
2482
|
+
return {
|
|
2483
|
+
node,
|
|
2484
|
+
visibility,
|
|
2485
|
+
...(content ?? {}),
|
|
2486
|
+
...(fonts === undefined ? {} : { fonts }),
|
|
2487
|
+
...(box === undefined ? {} : { box }),
|
|
2488
|
+
...(curated === undefined ? {} : { styles: curated }),
|
|
2489
|
+
...(wantsBehaviour && objectId
|
|
2490
|
+
? {
|
|
2491
|
+
listeners: await this.listenersOf(objectId),
|
|
2492
|
+
delegated: await this.delegatedTo(objectId),
|
|
2493
|
+
animations: await this.animationsOf(objectId),
|
|
2494
|
+
}
|
|
2495
|
+
: {}),
|
|
2496
|
+
...(variants === undefined ? {} : { variants }),
|
|
2497
|
+
...(options.why === undefined
|
|
2498
|
+
? {}
|
|
2499
|
+
: {
|
|
2500
|
+
trace: await this.traceOf(backendNodeId, options.why, styles),
|
|
2501
|
+
}),
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
/**
|
|
2505
|
+
* Photograph the page, or one element of it.
|
|
2506
|
+
*
|
|
2507
|
+
* Images never come back inline: a screenshot is far larger
|
|
2508
|
+
* than any response budget and would crowd out the reading it
|
|
2509
|
+
* was meant to illustrate. They go to the session's bundle
|
|
2510
|
+
* directory and the answer carries the paths.
|
|
2511
|
+
*
|
|
2512
|
+
* A full-page capture is tiled, because a long page makes an
|
|
2513
|
+
* image taller than a model will accept. The tiling and its
|
|
2514
|
+
* ceiling are the ones web_read already uses, so a page reads
|
|
2515
|
+
* the same either way.
|
|
2516
|
+
*/
|
|
2517
|
+
async shoot(options = {}) {
|
|
2518
|
+
const target = options.target;
|
|
2519
|
+
let held;
|
|
2520
|
+
try {
|
|
2521
|
+
if (target) {
|
|
2522
|
+
const tree = await this.axTree();
|
|
2523
|
+
const resolution = resolveTarget(tree, target);
|
|
2524
|
+
if (resolution.kind === "notFound") {
|
|
2525
|
+
return { ok: false, refusal: notFoundRefusal(tree, target) };
|
|
2526
|
+
}
|
|
2527
|
+
if (resolution.kind === "ambiguous") {
|
|
2528
|
+
return { ok: false, refusal: ambiguityRefusal(tree, target) };
|
|
2529
|
+
}
|
|
2530
|
+
held = resolution.backendDomId;
|
|
2531
|
+
}
|
|
2532
|
+
if (options.state !== undefined) {
|
|
2533
|
+
await this.holdState(held, options.state);
|
|
2534
|
+
}
|
|
2535
|
+
return { ok: true, shot: await this.capture(held, options) };
|
|
2536
|
+
}
|
|
2537
|
+
finally {
|
|
2538
|
+
if (options.state !== undefined)
|
|
2539
|
+
await this.holdState(held, undefined);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
/** Force or release a state for the duration of a capture. */
|
|
2543
|
+
async holdState(backendNodeId, state) {
|
|
2544
|
+
if (backendNodeId === undefined)
|
|
2545
|
+
return;
|
|
2546
|
+
const nodeId = await this.frontendNodeFor(backendNodeId);
|
|
2547
|
+
if (nodeId === undefined)
|
|
2548
|
+
return;
|
|
2549
|
+
await this.cdp
|
|
2550
|
+
.send("CSS.forcePseudoState", {
|
|
2551
|
+
nodeId,
|
|
2552
|
+
forcedPseudoClasses: state ? [state] : [],
|
|
2553
|
+
})
|
|
2554
|
+
// A page that navigated took the forced state with it.
|
|
2555
|
+
.catch(() => { });
|
|
2556
|
+
const objectId = await this.objectFor(backendNodeId);
|
|
2557
|
+
try {
|
|
2558
|
+
// Photographing mid-transition catches a state part way
|
|
2559
|
+
// there, which is the one thing a picture must not do.
|
|
2560
|
+
if (objectId)
|
|
2561
|
+
await this.settleForcedState(objectId);
|
|
2562
|
+
}
|
|
2563
|
+
finally {
|
|
2564
|
+
await this.release(objectId);
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
/** Take the picture and write it out. */
|
|
2568
|
+
async capture(backendNodeId, options) {
|
|
2569
|
+
const sink = this.artifacts.sink();
|
|
2570
|
+
const stamp = this.artifacts.nextShot();
|
|
2571
|
+
const keep = (path) => this.artifacts.keep(path);
|
|
2572
|
+
if (backendNodeId !== undefined) {
|
|
2573
|
+
const box = await this.boxOf(backendNodeId);
|
|
2574
|
+
const clip = box?.border;
|
|
2575
|
+
// The box model measures from the viewport and a screenshot
|
|
2576
|
+
// clip is measured from the document, so the two agree only
|
|
2577
|
+
// while the page sits at the top. Scrolled, a clip taken
|
|
2578
|
+
// straight from the box lands wherever the page has moved
|
|
2579
|
+
// from: a picture of the right size, of real content, of the
|
|
2580
|
+
// wrong element, with nothing about it to say so.
|
|
2581
|
+
const scroll = clip ? await this.scrollOffset() : undefined;
|
|
2582
|
+
const data = await this.page.screenshot({
|
|
2583
|
+
type: "png",
|
|
2584
|
+
encoding: "base64",
|
|
2585
|
+
...(clip
|
|
2586
|
+
? {
|
|
2587
|
+
clip: {
|
|
2588
|
+
x: clip.x + (scroll?.x ?? 0),
|
|
2589
|
+
y: clip.y + (scroll?.y ?? 0),
|
|
2590
|
+
width: clip.width,
|
|
2591
|
+
height: clip.height,
|
|
2592
|
+
},
|
|
2593
|
+
}
|
|
2594
|
+
: {}),
|
|
2595
|
+
});
|
|
2596
|
+
return {
|
|
2597
|
+
paths: [keep(sink.writeBinary(`element-${stamp}.png`, String(data)))],
|
|
2598
|
+
truncated: false,
|
|
2599
|
+
width: Math.round(clip?.width ?? 0),
|
|
2600
|
+
height: Math.round(clip?.height ?? 0),
|
|
2601
|
+
};
|
|
2602
|
+
}
|
|
2603
|
+
if (options.fullPage) {
|
|
2604
|
+
const captured = await captureTiles(this.page);
|
|
2605
|
+
return {
|
|
2606
|
+
paths: captured.tiles.map((tile, index) => keep(sink.writeBinary(`page-${stamp}-${String(index + 1).padStart(2, "0")}.png`, tile))),
|
|
2607
|
+
truncated: captured.truncated,
|
|
2608
|
+
width: 0,
|
|
2609
|
+
height: 0,
|
|
2610
|
+
};
|
|
2611
|
+
}
|
|
2612
|
+
const data = await this.page.screenshot({
|
|
2613
|
+
type: "png",
|
|
2614
|
+
encoding: "base64",
|
|
2615
|
+
});
|
|
2616
|
+
const seen = await this.viewport();
|
|
2617
|
+
return {
|
|
2618
|
+
paths: [keep(sink.writeBinary(`viewport-${stamp}.png`, String(data)))],
|
|
2619
|
+
truncated: false,
|
|
2620
|
+
width: seen?.width ?? 0,
|
|
2621
|
+
height: seen?.height ?? 0,
|
|
2622
|
+
};
|
|
2623
|
+
}
|
|
2624
|
+
/**
|
|
2625
|
+
* Compare the page now against a baseline of it, or record
|
|
2626
|
+
* one when there is none.
|
|
2627
|
+
*
|
|
2628
|
+
* A missing baseline is not a failure. The first run of any
|
|
2629
|
+
* comparison has nothing to compare against, and refusing
|
|
2630
|
+
* would make the tool need a setup step it can perform itself.
|
|
2631
|
+
*
|
|
2632
|
+
* The elements are read after the shot rather than before, so
|
|
2633
|
+
* a region is attributed against the layout that was actually
|
|
2634
|
+
* photographed.
|
|
2635
|
+
*/
|
|
2636
|
+
async compareToBaseline(label, options = {}) {
|
|
2637
|
+
await this.ready();
|
|
2638
|
+
const safe = pathComponent(label);
|
|
2639
|
+
const baselinePath = path.join(this.baselineDir(), `${safe}.png`);
|
|
2640
|
+
const shot = Buffer.from(String(await this.page.screenshot({ type: "png", encoding: "base64" })), "base64");
|
|
2641
|
+
const takenUnder = this.provenance();
|
|
2642
|
+
if (options.update || !existsSync(baselinePath)) {
|
|
2643
|
+
mkdirSync(path.dirname(baselinePath), {
|
|
2644
|
+
recursive: true,
|
|
2645
|
+
mode: DIR_MODE,
|
|
2646
|
+
});
|
|
2647
|
+
writeFileSync(baselinePath, shot, { mode: FILE_MODE });
|
|
2648
|
+
writeFileSync(sidecarFor(baselinePath), stringify(takenUnder), {
|
|
2649
|
+
mode: FILE_MODE,
|
|
2650
|
+
});
|
|
2651
|
+
this.artifacts.keep(baselinePath);
|
|
2652
|
+
return { comparison: undefined, recorded: baselinePath, artifacts: [] };
|
|
2653
|
+
}
|
|
2654
|
+
// Refuse rather than diff two different subjects. A baseline
|
|
2655
|
+
// recorded before this library stored provenance has none, and
|
|
2656
|
+
// is compared as before rather than being thrown away.
|
|
2657
|
+
const sidecar = sidecarFor(baselinePath);
|
|
2658
|
+
const was = existsSync(sidecar)
|
|
2659
|
+
? parseProvenance(readFileSync(sidecar, "utf8"))
|
|
2660
|
+
: undefined;
|
|
2661
|
+
const differs = was && describeDrift(was, takenUnder);
|
|
2662
|
+
if (differs) {
|
|
2663
|
+
return {
|
|
2664
|
+
comparison: { kind: "incomparable", because: differs },
|
|
2665
|
+
artifacts: [],
|
|
2666
|
+
};
|
|
2667
|
+
}
|
|
2668
|
+
// The screenshot is of the viewport; the rects are in
|
|
2669
|
+
// document coordinates. The two only coincide at scroll
|
|
2670
|
+
// origin, and driving the page before checking it is the
|
|
2671
|
+
// loop this tool teaches, so every region was attributed to
|
|
2672
|
+
// whatever sat at those coordinates at the top of the page
|
|
2673
|
+
// once anything had scrolled.
|
|
2674
|
+
const { nodes, viewport } = await this.layout();
|
|
2675
|
+
const offsetX = viewport.scrollX ?? 0;
|
|
2676
|
+
const offsetY = viewport.scrollY ?? 0;
|
|
2677
|
+
const placed = nodes.map((node) => ({
|
|
2678
|
+
selector: node.selector,
|
|
2679
|
+
rect: {
|
|
2680
|
+
...node.rect,
|
|
2681
|
+
x: node.rect.x - offsetX,
|
|
2682
|
+
y: node.rect.y - offsetY,
|
|
2683
|
+
},
|
|
2684
|
+
}));
|
|
2685
|
+
const { comparison, image } = compareImages(readPng(readFileSync(baselinePath)), readPng(shot), placed, {
|
|
2686
|
+
...(options.threshold === undefined
|
|
2687
|
+
? {}
|
|
2688
|
+
: { threshold: options.threshold }),
|
|
2689
|
+
scale: this.emulation.asked.viewport?.deviceScaleFactor ?? 1,
|
|
2690
|
+
});
|
|
2691
|
+
if (image === undefined)
|
|
2692
|
+
return { comparison, artifacts: [] };
|
|
2693
|
+
// The three together, because a diff on its own shows where
|
|
2694
|
+
// something changed and never what it changed from.
|
|
2695
|
+
const sink = this.artifacts.sink();
|
|
2696
|
+
const stamp = this.artifacts.nextShot();
|
|
2697
|
+
const artifacts = [
|
|
2698
|
+
sink.writeBinary(`${safe}-${stamp}-baseline.png`, readFileSync(baselinePath).toString("base64")),
|
|
2699
|
+
sink.writeBinary(`${safe}-${stamp}-current.png`, shot.toString("base64")),
|
|
2700
|
+
sink.writeBinary(`${safe}-${stamp}-diff.png`, image.toString("base64")),
|
|
2701
|
+
];
|
|
2702
|
+
for (const artifact of artifacts)
|
|
2703
|
+
this.artifacts.keep(artifact);
|
|
2704
|
+
return { comparison, artifacts };
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* Where this session's baselines live.
|
|
2708
|
+
*
|
|
2709
|
+
* Under the data directory, not the bundle root. This used to
|
|
2710
|
+
* sit at BUNDLE_ROOT/baselines, which the bundle reaper treats
|
|
2711
|
+
* as an ownerless directory (the name is not a pid) and deletes
|
|
2712
|
+
* once it is six hours old. Writing a baseline touches the
|
|
2713
|
+
* session subdirectory rather than its parent, so the parent's
|
|
2714
|
+
* age kept climbing and a stable set of baselines was reaped on
|
|
2715
|
+
* a timer. The next comparison then found nothing, recorded the
|
|
2716
|
+
* current page as truth and reported a warning, which is data
|
|
2717
|
+
* loss wearing a first run's clothes.
|
|
2718
|
+
*
|
|
2719
|
+
* A baseline is a durable artifact the user asked for and may
|
|
2720
|
+
* want to look at, which is what dataDir is for.
|
|
2721
|
+
*/
|
|
2722
|
+
baselineDir() {
|
|
2723
|
+
// The session name is chosen by whoever is driving. Joined raw,
|
|
2724
|
+
// a name of "../../../../tmp/evil" put the baseline write at
|
|
2725
|
+
// /Users/tmp/evil: the label beside it was being cleaned and
|
|
2726
|
+
// this was not, which is why both now go through one rule.
|
|
2727
|
+
return path.join(this.options.dataRoot ?? defaultBrowserDataRoot(), "baselines", pathComponent(this.name));
|
|
2728
|
+
}
|
|
2729
|
+
/**
|
|
2730
|
+
* What a baseline was taken under, stored beside the image.
|
|
2731
|
+
*
|
|
2732
|
+
* Without this a comparison cannot tell a regression from a
|
|
2733
|
+
* change of subject: record on one page, navigate to another,
|
|
2734
|
+
* and the diff reports confident failures attributed to the
|
|
2735
|
+
* second page's elements. The size check alone cannot see it,
|
|
2736
|
+
* because two pages at one viewport are the same size.
|
|
2737
|
+
*/
|
|
2738
|
+
provenance() {
|
|
2739
|
+
const viewport = this.emulation.asked.viewport;
|
|
2740
|
+
return {
|
|
2741
|
+
url: this.url,
|
|
2742
|
+
...(viewport?.width === undefined ? {} : { width: viewport.width }),
|
|
2743
|
+
...(viewport?.height === undefined ? {} : { height: viewport.height }),
|
|
2744
|
+
deviceScaleFactor: viewport?.deviceScaleFactor ?? 1,
|
|
2745
|
+
};
|
|
2746
|
+
}
|
|
2747
|
+
/** Carry out an action against an element judged ready. */
|
|
2748
|
+
async perform(action, element, backendNodeId) {
|
|
2749
|
+
if (action.kind === "upload") {
|
|
2750
|
+
// Set through our own protocol channel against the node the
|
|
2751
|
+
// outline resolved. The driver's aria selector does not
|
|
2752
|
+
// offer a node it considers hidden, and a hidden input is
|
|
2753
|
+
// the ordinary shape of an upload, so there is no handle to
|
|
2754
|
+
// hold here and none is needed.
|
|
2755
|
+
const objectId = await this.objectFor(backendNodeId);
|
|
2756
|
+
if (!objectId)
|
|
2757
|
+
return;
|
|
2758
|
+
try {
|
|
2759
|
+
await this.cdp.send("DOM.setFileInputFiles", {
|
|
2760
|
+
objectId,
|
|
2761
|
+
files: [...action.files],
|
|
2762
|
+
});
|
|
2763
|
+
}
|
|
2764
|
+
finally {
|
|
2765
|
+
await this.release(objectId);
|
|
2766
|
+
}
|
|
2767
|
+
return;
|
|
2768
|
+
}
|
|
2769
|
+
if (!element)
|
|
2770
|
+
return;
|
|
2771
|
+
switch (action.kind) {
|
|
2772
|
+
case "click":
|
|
2773
|
+
await element.click();
|
|
2774
|
+
return;
|
|
2775
|
+
case "type":
|
|
2776
|
+
await element.type(action.text);
|
|
2777
|
+
return;
|
|
2778
|
+
case "hover":
|
|
2779
|
+
await element.hover();
|
|
2780
|
+
return;
|
|
2781
|
+
case "focus":
|
|
2782
|
+
await element.focus();
|
|
2783
|
+
return;
|
|
2784
|
+
case "select":
|
|
2785
|
+
await element.select(action.text);
|
|
2786
|
+
return;
|
|
2787
|
+
case "scrollTo":
|
|
2788
|
+
await element.scrollIntoView();
|
|
2789
|
+
return;
|
|
2790
|
+
case "clear": {
|
|
2791
|
+
// Select what is there and delete it, so the page sees
|
|
2792
|
+
// the same input and change events it would from a
|
|
2793
|
+
// person at a keyboard. The field is asked to select
|
|
2794
|
+
// itself rather than triple clicked, so clearing works
|
|
2795
|
+
// under an overlay, and rather than sending a select-all
|
|
2796
|
+
// shortcut, so there is no platform to guess at.
|
|
2797
|
+
await element.focus();
|
|
2798
|
+
// Resolved through our own session: an identifier minted
|
|
2799
|
+
// by puppeteer's connection means nothing on this one.
|
|
2800
|
+
const objectId = await this.objectFor(backendNodeId);
|
|
2801
|
+
try {
|
|
2802
|
+
if (objectId) {
|
|
2803
|
+
await this.cdp.send("Runtime.callFunctionOn", {
|
|
2804
|
+
objectId,
|
|
2805
|
+
functionDeclaration: SELECT_TEXT_PROBE,
|
|
2806
|
+
returnByValue: true,
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
finally {
|
|
2811
|
+
await this.release(objectId);
|
|
2812
|
+
}
|
|
2813
|
+
await this.page.keyboard.press("Backspace");
|
|
2814
|
+
return;
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
/**
|
|
2819
|
+
* Wait until an element can actually be acted on.
|
|
2820
|
+
*
|
|
2821
|
+
* A click on an element that is not ready does nothing and
|
|
2822
|
+
* says nothing, leaving a caller believing the page was acted
|
|
2823
|
+
* on. So readiness is established first, and when the budget
|
|
2824
|
+
* runs out the condition still in the way is reported rather
|
|
2825
|
+
* than the action being attempted regardless.
|
|
2826
|
+
*/
|
|
2827
|
+
async awaitReady(target, pointer, sight = true) {
|
|
2828
|
+
const started = Date.now();
|
|
2829
|
+
let previous;
|
|
2830
|
+
let scrolled = false;
|
|
2831
|
+
let last = {
|
|
2832
|
+
ready: false,
|
|
2833
|
+
blocker: "it is not in the page",
|
|
2834
|
+
};
|
|
2835
|
+
while (Date.now() - started < READY_BUDGET_MS) {
|
|
2836
|
+
const look = await this.readinessOf(target, previous, pointer, sight);
|
|
2837
|
+
previous = look.box;
|
|
2838
|
+
last = judgeActionability(look.facts);
|
|
2839
|
+
if (last.ready && look.backendDomId !== undefined) {
|
|
2840
|
+
return {
|
|
2841
|
+
ready: true,
|
|
2842
|
+
waitedMs: Date.now() - started,
|
|
2843
|
+
backendDomId: look.backendDomId,
|
|
2844
|
+
};
|
|
2845
|
+
}
|
|
2846
|
+
// Waiting cannot bring an element into the viewport, so
|
|
2847
|
+
// polling one that is merely below the fold spends the whole
|
|
2848
|
+
// budget to reach the verdict it already had. Scrolling is
|
|
2849
|
+
// what the caller means by acting on it, and what a person
|
|
2850
|
+
// does. Once only: if it is still out of view after being
|
|
2851
|
+
// scrolled to, something else is moving it and the blocker
|
|
2852
|
+
// is the honest answer.
|
|
2853
|
+
if (!scrolled && look.facts.visibility?.state === "off screen") {
|
|
2854
|
+
scrolled = true;
|
|
2855
|
+
const found = await this.resolve(target);
|
|
2856
|
+
if (found.ok) {
|
|
2857
|
+
await found.element.scrollIntoView();
|
|
2858
|
+
await found.element.dispose();
|
|
2859
|
+
previous = undefined;
|
|
2860
|
+
continue;
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
await new Promise((resolve) => setTimeout(resolve, READY_POLL_MS));
|
|
2864
|
+
}
|
|
2865
|
+
return {
|
|
2866
|
+
ready: false,
|
|
2867
|
+
waitedMs: Date.now() - started,
|
|
2868
|
+
blocker: last.blocker ?? "it never became ready",
|
|
2869
|
+
};
|
|
2870
|
+
}
|
|
2871
|
+
/** One look at whether an element is ready. */
|
|
2872
|
+
async readinessOf(target, previous, pointer, sight = true) {
|
|
2873
|
+
const tree = await this.axTree();
|
|
2874
|
+
const resolution = resolveTarget(tree, target);
|
|
2875
|
+
if (resolution.kind !== "resolved") {
|
|
2876
|
+
return {
|
|
2877
|
+
facts: { present: false, enabled: false, settled: false },
|
|
2878
|
+
box: undefined,
|
|
2879
|
+
};
|
|
2880
|
+
}
|
|
2881
|
+
const node = subtreeAt(tree, resolution.backendDomId);
|
|
2882
|
+
const box = await this.boxOf(resolution.backendDomId);
|
|
2883
|
+
// Where the click lands only matters when there is a click.
|
|
2884
|
+
// Focusing, typing, choosing an option and scrolling all
|
|
2885
|
+
// reach an element the pointer could not, so holding them to
|
|
2886
|
+
// a pointer's standard refuses work that would have
|
|
2887
|
+
// succeeded. Being rendered at a real size still matters to
|
|
2888
|
+
// every action, so that part of the verdict always applies.
|
|
2889
|
+
const viewport = pointer ? await this.viewport() : undefined;
|
|
2890
|
+
const coveredBy = pointer && box
|
|
2891
|
+
? await this.coveredAtCentre(resolution.backendDomId)
|
|
2892
|
+
: undefined;
|
|
2893
|
+
const visibility = sight
|
|
2894
|
+
? judgeVisibility({
|
|
2895
|
+
rendered: box !== undefined,
|
|
2896
|
+
...(box === undefined ? {} : { border: box.border }),
|
|
2897
|
+
...(viewport === undefined ? {} : { viewport }),
|
|
2898
|
+
...(coveredBy === undefined ? {} : { coveredBy }),
|
|
2899
|
+
})
|
|
2900
|
+
: undefined;
|
|
2901
|
+
return {
|
|
2902
|
+
facts: {
|
|
2903
|
+
present: true,
|
|
2904
|
+
...(visibility === undefined ? {} : { visibility }),
|
|
2905
|
+
// The tree reports what the browser exposes to assistive
|
|
2906
|
+
// technology, which is the same disabled a person meets.
|
|
2907
|
+
enabled: node?.properties.disabled !== true,
|
|
2908
|
+
settled: sameBox(previous, box?.border),
|
|
2909
|
+
},
|
|
2910
|
+
box: box?.border,
|
|
2911
|
+
backendDomId: resolution.backendDomId,
|
|
2912
|
+
};
|
|
2913
|
+
}
|
|
2914
|
+
/**
|
|
2915
|
+
* Whether something is painted over the element's centre.
|
|
2916
|
+
*
|
|
2917
|
+
* Only the centre, unlike a full inspection: this runs on
|
|
2918
|
+
* every poll, and the centre is where a click is aimed.
|
|
2919
|
+
*/
|
|
2920
|
+
async coveredAtCentre(backendNodeId) {
|
|
2921
|
+
const objectId = await this.objectFor(backendNodeId);
|
|
2922
|
+
if (!objectId)
|
|
2923
|
+
return undefined;
|
|
2924
|
+
try {
|
|
2925
|
+
return await this.hitTest(objectId, false);
|
|
2926
|
+
}
|
|
2927
|
+
finally {
|
|
2928
|
+
await this.release(objectId);
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
/** What is listening on the element. */
|
|
2932
|
+
async listenersOf(objectId) {
|
|
2933
|
+
try {
|
|
2934
|
+
const { listeners } = (await this.cdp.send("DOMDebugger.getEventListeners", { objectId }));
|
|
2935
|
+
return normalizeListeners(listeners);
|
|
2936
|
+
}
|
|
2937
|
+
catch {
|
|
2938
|
+
// Without the debugger agent this one section is missing;
|
|
2939
|
+
// the rest of the inspection still stands.
|
|
2940
|
+
return [];
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
/**
|
|
2944
|
+
* What is listening further up, which events from here reach.
|
|
2945
|
+
*
|
|
2946
|
+
* The protocol reads listeners off one object at a time and can
|
|
2947
|
+
* walk down into children but not up, so the ancestors are
|
|
2948
|
+
* collected in the page and each one asked separately. A live
|
|
2949
|
+
* button whose click was handled on the body reported nothing
|
|
2950
|
+
* listening, which is the answer that most invites the wrong
|
|
2951
|
+
* conclusion from an inspection meant to explain a dead click.
|
|
2952
|
+
*/
|
|
2953
|
+
async delegatedTo(objectId) {
|
|
2954
|
+
try {
|
|
2955
|
+
const { result } = (await this.cdp.send("Runtime.callFunctionOn", {
|
|
2956
|
+
objectId,
|
|
2957
|
+
functionDeclaration: ANCESTORS_PROBE,
|
|
2958
|
+
returnByValue: false,
|
|
2959
|
+
}));
|
|
2960
|
+
if (!result.objectId)
|
|
2961
|
+
return [];
|
|
2962
|
+
try {
|
|
2963
|
+
return await this.listenersUp(result.objectId);
|
|
2964
|
+
}
|
|
2965
|
+
finally {
|
|
2966
|
+
await this.release(result.objectId);
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
catch {
|
|
2970
|
+
// The element's own handlers are the answer more often than
|
|
2971
|
+
// not, and they are already in hand. Losing the ancestors
|
|
2972
|
+
// costs a section, not the inspection.
|
|
2973
|
+
return [];
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
/** Ask each ancestor in the array what is listening on it. */
|
|
2977
|
+
async listenersUp(arrayId) {
|
|
2978
|
+
const { result } = (await this.cdp.send("Runtime.getProperties", {
|
|
2979
|
+
objectId: arrayId,
|
|
2980
|
+
ownProperties: true,
|
|
2981
|
+
}));
|
|
2982
|
+
const found = [];
|
|
2983
|
+
for (const property of result) {
|
|
2984
|
+
// getProperties hands back 'length' and the indices together.
|
|
2985
|
+
if (!/^\d+$/.test(property.name))
|
|
2986
|
+
continue;
|
|
2987
|
+
const ancestor = property.value?.objectId;
|
|
2988
|
+
if (!ancestor)
|
|
2989
|
+
continue;
|
|
2990
|
+
try {
|
|
2991
|
+
const listeners = await this.listenersOf(ancestor);
|
|
2992
|
+
if (listeners.length > 0) {
|
|
2993
|
+
found.push({
|
|
2994
|
+
element: property.value?.description ?? "an ancestor",
|
|
2995
|
+
listeners,
|
|
2996
|
+
});
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
finally {
|
|
3000
|
+
await this.release(ancestor);
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
return found;
|
|
3004
|
+
}
|
|
3005
|
+
/** What is moving on the element. */
|
|
3006
|
+
async animationsOf(objectId) {
|
|
3007
|
+
try {
|
|
3008
|
+
const { result } = await this.cdp.send("Runtime.callFunctionOn", {
|
|
3009
|
+
objectId,
|
|
3010
|
+
functionDeclaration: ANIMATIONS_PROBE,
|
|
3011
|
+
returnByValue: true,
|
|
3012
|
+
});
|
|
3013
|
+
return normalizeAnimations((result.value ?? []));
|
|
3014
|
+
}
|
|
3015
|
+
catch {
|
|
3016
|
+
// A page that navigated mid-inspection has nothing left to
|
|
3017
|
+
// report here.
|
|
3018
|
+
return [];
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
/**
|
|
3022
|
+
* How the element looks in each state, against how it looks at
|
|
3023
|
+
* rest.
|
|
3024
|
+
*
|
|
3025
|
+
* The forced state is always released afterwards, including
|
|
3026
|
+
* when a reading throws part way through. Leaving a page stuck
|
|
3027
|
+
* in a forced hover would quietly corrupt every later
|
|
3028
|
+
* observation of it.
|
|
3029
|
+
*/
|
|
3030
|
+
async variantsOf(backendNodeId, objectId, atRest, states, initials, options) {
|
|
3031
|
+
const nodeId = await this.frontendNodeFor(backendNodeId);
|
|
3032
|
+
if (nodeId === undefined)
|
|
3033
|
+
return [];
|
|
3034
|
+
const variants = [];
|
|
3035
|
+
try {
|
|
3036
|
+
for (const state of states) {
|
|
3037
|
+
await this.cdp.send("CSS.forcePseudoState", {
|
|
3038
|
+
nodeId,
|
|
3039
|
+
forcedPseudoClasses: [state],
|
|
3040
|
+
});
|
|
3041
|
+
await this.settleForcedState(objectId);
|
|
3042
|
+
const held = await this.stylesOf(objectId);
|
|
3043
|
+
if (!held)
|
|
3044
|
+
continue;
|
|
3045
|
+
variants.push({
|
|
3046
|
+
state,
|
|
3047
|
+
changes: diffStyles(atRest, curateStyles(held, {
|
|
3048
|
+
...(initials === undefined ? {} : { initials }),
|
|
3049
|
+
...(options.styles === undefined ? {} : { only: options.styles }),
|
|
3050
|
+
})),
|
|
3051
|
+
});
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
finally {
|
|
3055
|
+
await this.cdp
|
|
3056
|
+
.send("CSS.forcePseudoState", { nodeId, forcedPseudoClasses: [] })
|
|
3057
|
+
// A page that navigated took the forced state with it.
|
|
3058
|
+
.catch(() => { });
|
|
3059
|
+
// Releasing the state starts the transition back, so the
|
|
3060
|
+
// element is only truly as it was found once that has
|
|
3061
|
+
// finished. Without this the next reading catches it part
|
|
3062
|
+
// way home and reports a colour nobody chose.
|
|
3063
|
+
await this.settleForcedState(objectId);
|
|
3064
|
+
}
|
|
3065
|
+
return variants;
|
|
3066
|
+
}
|
|
3067
|
+
/**
|
|
3068
|
+
* Let whatever the forced state started finish before reading.
|
|
3069
|
+
*
|
|
3070
|
+
* Without this the reading catches a transition at its resting
|
|
3071
|
+
* values and reports that the state changes nothing, which is
|
|
3072
|
+
* exactly backwards.
|
|
3073
|
+
*/
|
|
3074
|
+
async settleForcedState(objectId) {
|
|
3075
|
+
try {
|
|
3076
|
+
await this.cdp.send("Runtime.callFunctionOn", {
|
|
3077
|
+
objectId,
|
|
3078
|
+
functionDeclaration: SETTLE_PROBE,
|
|
3079
|
+
arguments: [{ value: SETTLE_CAP_MS }],
|
|
3080
|
+
awaitPromise: true,
|
|
3081
|
+
returnByValue: true,
|
|
3082
|
+
});
|
|
3083
|
+
}
|
|
3084
|
+
catch {
|
|
3085
|
+
// A reading taken early is worse than one taken late, but
|
|
3086
|
+
// neither is worth abandoning the inspection over.
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
/** The front-end node id, which the CSS agent works in. */
|
|
3090
|
+
async frontendNodeFor(backendNodeId) {
|
|
3091
|
+
try {
|
|
3092
|
+
// A front-end id only exists once the document has been
|
|
3093
|
+
// walked at least once.
|
|
3094
|
+
await this.cdp.send("DOM.getDocument", { depth: 0 });
|
|
3095
|
+
const { nodeIds } = await this.cdp.send("DOM.pushNodesByBackendIdsToFrontend", { backendNodeIds: [backendNodeId] });
|
|
3096
|
+
return nodeIds[0];
|
|
3097
|
+
}
|
|
3098
|
+
catch {
|
|
3099
|
+
// Without a front-end id the CSS agent cannot be addressed.
|
|
3100
|
+
return undefined;
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
/** The element's four boxes, or nothing when it has none. */
|
|
3104
|
+
async boxOf(backendNodeId) {
|
|
3105
|
+
try {
|
|
3106
|
+
const { model } = (await this.cdp.send("DOM.getBoxModel", {
|
|
3107
|
+
backendNodeId,
|
|
3108
|
+
}));
|
|
3109
|
+
return normalizeBoxModel(model);
|
|
3110
|
+
}
|
|
3111
|
+
catch {
|
|
3112
|
+
// An element with display none has no box at all, and the
|
|
3113
|
+
// protocol says so by refusing. That is an answer, not a
|
|
3114
|
+
// failure: the visibility verdict reports it as unrendered.
|
|
3115
|
+
return undefined;
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
/** Every computed property of the element, as the browser has it. */
|
|
3119
|
+
async stylesOf(objectId) {
|
|
3120
|
+
try {
|
|
3121
|
+
const { result } = await this.cdp.send("Runtime.callFunctionOn", {
|
|
3122
|
+
objectId,
|
|
3123
|
+
functionDeclaration: COMPUTED_STYLE_PROBE,
|
|
3124
|
+
arguments: [{ value: SHORTHAND_PROPERTIES }],
|
|
3125
|
+
returnByValue: true,
|
|
3126
|
+
});
|
|
3127
|
+
return result.value;
|
|
3128
|
+
}
|
|
3129
|
+
catch {
|
|
3130
|
+
// A page that navigated mid-inspection leaves nothing to
|
|
3131
|
+
// read; the rest of the inspection still stands.
|
|
3132
|
+
return undefined;
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
/**
|
|
3136
|
+
* What every property computes to untouched, read once per
|
|
3137
|
+
* session. These decide which values were actually chosen.
|
|
3138
|
+
*/
|
|
3139
|
+
async initials() {
|
|
3140
|
+
if (this.initialStyles)
|
|
3141
|
+
return this.initialStyles;
|
|
3142
|
+
try {
|
|
3143
|
+
const { result } = await this.cdp.send("Runtime.evaluate", {
|
|
3144
|
+
expression: asCall(INITIALS_PROBE, SHORTHAND_PROPERTIES),
|
|
3145
|
+
returnByValue: true,
|
|
3146
|
+
});
|
|
3147
|
+
this.initialStyles = result.value;
|
|
3148
|
+
return this.initialStyles;
|
|
3149
|
+
}
|
|
3150
|
+
catch {
|
|
3151
|
+
// Without these nothing is suppressed as a default, which
|
|
3152
|
+
// is verbose but never wrong.
|
|
3153
|
+
return undefined;
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
/** Why one property has the value it has. */
|
|
3157
|
+
async traceOf(backendNodeId, property, styles) {
|
|
3158
|
+
try {
|
|
3159
|
+
const nodeId = await this.frontendNodeFor(backendNodeId);
|
|
3160
|
+
if (nodeId === undefined)
|
|
3161
|
+
return undefined;
|
|
3162
|
+
const raw = (await this.cdp.send("CSS.getMatchedStylesForNode", {
|
|
3163
|
+
nodeId,
|
|
3164
|
+
}));
|
|
3165
|
+
const trace = traceProperty(normalizeCascade(raw), property, styles?.[property]);
|
|
3166
|
+
return trace === undefined ? undefined : await this.authorTrace(trace);
|
|
3167
|
+
}
|
|
3168
|
+
catch {
|
|
3169
|
+
// The cascade domain needs the CSS agent; without it the
|
|
3170
|
+
// rest of the inspection is still worth returning.
|
|
3171
|
+
return undefined;
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
/**
|
|
3175
|
+
* Tell each declaration in a trace where it was written.
|
|
3176
|
+
*
|
|
3177
|
+
* A build step that rewrote the CSS makes the reported line
|
|
3178
|
+
* useless in the same way a minified stack frame is: it names
|
|
3179
|
+
* a file nobody edits. The winner is usually the declaration
|
|
3180
|
+
* somebody wants to go and change, so it is the one that most
|
|
3181
|
+
* needs an address that exists.
|
|
3182
|
+
*/
|
|
3183
|
+
async authorTrace(trace) {
|
|
3184
|
+
const declarations = await Promise.all(trace.declarations.map(async (declaration) => {
|
|
3185
|
+
const { source } = declaration;
|
|
3186
|
+
if (source?.styleSheet === undefined || source.line === undefined) {
|
|
3187
|
+
return declaration;
|
|
3188
|
+
}
|
|
3189
|
+
const authored = await this.sourceMaps.authoredForSheet(source.styleSheet, { line: source.line, column: source.column ?? 0 });
|
|
3190
|
+
if (authored === undefined)
|
|
3191
|
+
return declaration;
|
|
3192
|
+
return { ...declaration, source: { ...source, authored } };
|
|
3193
|
+
}));
|
|
3194
|
+
// The winner is compared by identity, so it has to be
|
|
3195
|
+
// re-found among the rebuilt declarations rather than kept.
|
|
3196
|
+
const winnerAt = trace.winner
|
|
3197
|
+
? trace.declarations.indexOf(trace.winner)
|
|
3198
|
+
: -1;
|
|
3199
|
+
return {
|
|
3200
|
+
...trace,
|
|
3201
|
+
declarations,
|
|
3202
|
+
...(winnerAt < 0 ? {} : { winner: declarations[winnerAt] }),
|
|
3203
|
+
};
|
|
3204
|
+
}
|
|
3205
|
+
/** The area a person can currently see. */
|
|
3206
|
+
async viewport() {
|
|
3207
|
+
try {
|
|
3208
|
+
const metrics = (await this.cdp.send("Page.getLayoutMetrics"));
|
|
3209
|
+
const seen = metrics.cssVisualViewport;
|
|
3210
|
+
if (!seen)
|
|
3211
|
+
return undefined;
|
|
3212
|
+
return { width: seen.clientWidth, height: seen.clientHeight };
|
|
3213
|
+
}
|
|
3214
|
+
catch {
|
|
3215
|
+
// Without the viewport an element is judged on everything
|
|
3216
|
+
// else known about it rather than not at all.
|
|
3217
|
+
return undefined;
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
/**
|
|
3221
|
+
* How far the page has been scrolled, in CSS pixels.
|
|
3222
|
+
*
|
|
3223
|
+
* Read from the browser's own layout metrics rather than from the
|
|
3224
|
+
* page, so a capture does not have to run script to be correct.
|
|
3225
|
+
* Answers zero when the metrics are unavailable, which is what the
|
|
3226
|
+
* offset was assumed to be before it was asked for at all.
|
|
3227
|
+
*/
|
|
3228
|
+
async scrollOffset() {
|
|
3229
|
+
try {
|
|
3230
|
+
const metrics = (await this.cdp.send("Page.getLayoutMetrics"));
|
|
3231
|
+
const seen = metrics.cssVisualViewport;
|
|
3232
|
+
return { x: seen?.pageX ?? 0, y: seen?.pageY ?? 0 };
|
|
3233
|
+
}
|
|
3234
|
+
catch {
|
|
3235
|
+
// A page that went away mid-capture has no offset to report,
|
|
3236
|
+
// and the capture around this will fail on its own terms.
|
|
3237
|
+
// Zero is what the clip assumed before the offset was asked
|
|
3238
|
+
// for at all, so it is the honest fallback rather than a new
|
|
3239
|
+
// guess.
|
|
3240
|
+
return { x: 0, y: 0 };
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
/**
|
|
3244
|
+
* What is painted over the element, if anything.
|
|
3245
|
+
*
|
|
3246
|
+
* The centre and the corners, since a full inspection should
|
|
3247
|
+
* report an element clipped at one edge. Anything the element
|
|
3248
|
+
* contains counts as itself, since a click there still reaches
|
|
3249
|
+
* it.
|
|
3250
|
+
*/
|
|
3251
|
+
async occluderOf(objectId) {
|
|
3252
|
+
return await this.hitTest(objectId, true);
|
|
3253
|
+
}
|
|
3254
|
+
/**
|
|
3255
|
+
* Who receives a click, when it is not us.
|
|
3256
|
+
*
|
|
3257
|
+
* The point is chosen inside the page rather than out here. Both
|
|
3258
|
+
* halves of the question used to be asked separately, the box
|
|
3259
|
+
* from the box model and the hit from `DOM.getNodeForLocation`,
|
|
3260
|
+
* and they disagree by the scroll offset, so anything the driver
|
|
3261
|
+
* had to scroll to was reported as covered by whatever sat at
|
|
3262
|
+
* the wrong point.
|
|
3263
|
+
*/
|
|
3264
|
+
async hitTest(objectId, alsoCorners) {
|
|
3265
|
+
try {
|
|
3266
|
+
const { result } = await this.cdp.send("Runtime.callFunctionOn", {
|
|
3267
|
+
objectId,
|
|
3268
|
+
functionDeclaration: OCCLUDER_PROBE,
|
|
3269
|
+
arguments: [{ value: alsoCorners }],
|
|
3270
|
+
returnByValue: true,
|
|
3271
|
+
});
|
|
3272
|
+
return result.value ?? undefined;
|
|
3273
|
+
}
|
|
3274
|
+
catch {
|
|
3275
|
+
// An element that has gone from the page between resolving
|
|
3276
|
+
// and asking cannot be occluded by anything, and the next
|
|
3277
|
+
// poll reports it as absent.
|
|
3278
|
+
return undefined;
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
async resolve(target) {
|
|
3282
|
+
// Confirm uniqueness against our own outline so an ambiguous
|
|
3283
|
+
// target becomes a prompt to narrow it rather than a wrong click.
|
|
3284
|
+
const tree = await this.axTree();
|
|
3285
|
+
const resolution = resolveTarget(tree, target);
|
|
3286
|
+
if (resolution.kind === "notFound") {
|
|
3287
|
+
return { ok: false, refusal: notFoundRefusal(tree, target) };
|
|
3288
|
+
}
|
|
3289
|
+
if (resolution.kind === "ambiguous") {
|
|
3290
|
+
return { ok: false, refusal: ambiguityRefusal(tree, target) };
|
|
3291
|
+
}
|
|
3292
|
+
// Drive the real element through puppeteer's aria selector, which
|
|
3293
|
+
// brings the scrolling, visibility and hit testing that a raw
|
|
3294
|
+
// protocol click does not. But the selector is page-wide and knows
|
|
3295
|
+
// nothing of a container, and its ordering is its own, so the
|
|
3296
|
+
// candidate that matches is chosen by the identity the resolution
|
|
3297
|
+
// already settled rather than by position in a second list.
|
|
3298
|
+
// Choosing by index here is how a click lands on the wrong row.
|
|
3299
|
+
// A target with no name is written with none, not with the
|
|
3300
|
+
// word "undefined": interpolating a missing name produced
|
|
3301
|
+
// aria/undefined[role="button"], which matches nothing, so an
|
|
3302
|
+
// element the outline had just offered refused every act.
|
|
3303
|
+
const handles = await this.page.$$(`aria/${target.name ?? ""}[role="${target.role}"]`);
|
|
3304
|
+
let element;
|
|
3305
|
+
for (const handle of handles) {
|
|
3306
|
+
if (element === undefined &&
|
|
3307
|
+
(await this.backendIdOf(handle)) === resolution.backendDomId) {
|
|
3308
|
+
element = handle;
|
|
3309
|
+
continue;
|
|
3310
|
+
}
|
|
3311
|
+
await handle.dispose();
|
|
3312
|
+
}
|
|
3313
|
+
// The outline named a node the aria selector does not offer: the
|
|
3314
|
+
// page moved, or the two matchers disagree. Refusing is the only
|
|
3315
|
+
// honest answer, because any handle we still hold is a guess.
|
|
3316
|
+
if (!element)
|
|
3317
|
+
return { ok: false, refusal: notFoundRefusal(tree, target) };
|
|
3318
|
+
return { ok: true, element };
|
|
3319
|
+
}
|
|
3320
|
+
/**
|
|
3321
|
+
* The backend node id behind a handle, or undefined if it has gone.
|
|
3322
|
+
*
|
|
3323
|
+
* This asks puppeteer rather than sending DOM.describeNode ourselves
|
|
3324
|
+
* because a remote object id is scoped to the session that minted it,
|
|
3325
|
+
* and these handles were minted by puppeteer's session, not ours.
|
|
3326
|
+
* Resolving them through this.cdp fails for every handle, which reads
|
|
3327
|
+
* as "no candidate matched" and refuses every act.
|
|
3328
|
+
*/
|
|
3329
|
+
async backendIdOf(handle) {
|
|
3330
|
+
try {
|
|
3331
|
+
return await handle.backendNodeId();
|
|
3332
|
+
}
|
|
3333
|
+
catch {
|
|
3334
|
+
// The node left the document between the outline and this
|
|
3335
|
+
// lookup. It cannot be the one we resolved, so say so.
|
|
3336
|
+
return undefined;
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
async axTree() {
|
|
3340
|
+
// Nearly every reading starts here, which makes it the right
|
|
3341
|
+
// place to wait out a crash recovery rather than issuing a
|
|
3342
|
+
// call to a renderer that will never answer.
|
|
3343
|
+
await this.ready();
|
|
3344
|
+
const { nodes } = (await this.cdp.send("Accessibility.getFullAXTree"));
|
|
3345
|
+
return normalizeAxTree(spliceFrames(nodes, await this.frameTrees()));
|
|
3346
|
+
}
|
|
3347
|
+
/**
|
|
3348
|
+
* Each same-origin child frame's tree, and what owns it.
|
|
3349
|
+
*
|
|
3350
|
+
* Chrome answers for one frame at a time, so a page built from
|
|
3351
|
+
* embeds read as a row of empty boxes: the outline stopped at
|
|
3352
|
+
* the Iframe node and said nothing about stopping. Everything
|
|
3353
|
+
* downstream inherited that, so a keyboard walk skipped an
|
|
3354
|
+
* embedded form and an audit passed a page it had not seen.
|
|
3355
|
+
*
|
|
3356
|
+
* Breadth-first, parents before children, because the splice
|
|
3357
|
+
* attaches each frame to an owner that must already be in the
|
|
3358
|
+
* tree by the time its turn comes.
|
|
3359
|
+
*
|
|
3360
|
+
* A frame that cannot be read is left out rather than reported
|
|
3361
|
+
* as empty. Cross-origin frames are the common case and are a
|
|
3362
|
+
* real limit, not a fault; the count of what could not be read
|
|
3363
|
+
* belongs to the callers that already report it.
|
|
3364
|
+
*/
|
|
3365
|
+
async frameTrees() {
|
|
3366
|
+
let tree;
|
|
3367
|
+
try {
|
|
3368
|
+
tree = (await this.cdp.send("Page.getFrameTree"));
|
|
3369
|
+
}
|
|
3370
|
+
catch {
|
|
3371
|
+
// No frame tree means nothing to splice, which is the answer
|
|
3372
|
+
// for the overwhelming majority of pages.
|
|
3373
|
+
return [];
|
|
3374
|
+
}
|
|
3375
|
+
const queue = [...(tree.frameTree.childFrames ?? [])];
|
|
3376
|
+
const collected = [];
|
|
3377
|
+
while (queue.length > 0) {
|
|
3378
|
+
const node = queue.shift();
|
|
3379
|
+
if (!node)
|
|
3380
|
+
break;
|
|
3381
|
+
queue.push(...(node.childFrames ?? []));
|
|
3382
|
+
const frameId = node.frame.id;
|
|
3383
|
+
try {
|
|
3384
|
+
const { backendNodeId } = (await this.cdp.send("DOM.getFrameOwner", {
|
|
3385
|
+
frameId,
|
|
3386
|
+
}));
|
|
3387
|
+
const { nodes } = (await this.cdp.send("Accessibility.getFullAXTree", {
|
|
3388
|
+
frameId,
|
|
3389
|
+
}));
|
|
3390
|
+
collected.push({ ownerBackendNodeId: backendNodeId, nodes });
|
|
3391
|
+
}
|
|
3392
|
+
catch {
|
|
3393
|
+
// A cross-origin frame refuses both calls, and a frame can
|
|
3394
|
+
// be torn down between listing it and asking about it.
|
|
3395
|
+
// Neither is a reason to fail the whole read.
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
return collected;
|
|
3399
|
+
}
|
|
3400
|
+
}
|