@reticlehq/server 3.9.0 → 3.10.1
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/SKILL.md +13 -12
- package/dist/command/cli/adhoc-suite.js +2 -0
- package/dist/command/cli/adhoc-verdict.d.ts +2 -43
- package/dist/command/cli/adhoc-verdict.js +50 -187
- package/dist/command/cli/answers/mcp-connection-hint.d.ts +2 -0
- package/dist/command/cli/answers/mcp-connection-hint.js +6 -0
- package/dist/command/cli/cli-doctor.js +1 -1
- package/dist/command/cli/cli-flow-commands.js +2 -9
- package/dist/command/cli/cli-kill.js +1 -1
- package/dist/command/cli/cli-parse-compare.d.ts +22 -0
- package/dist/command/cli/cli-parse-compare.js +75 -0
- package/dist/command/cli/cli-parse-grade.d.ts +36 -0
- package/dist/command/cli/cli-parse-grade.js +75 -0
- package/dist/command/cli/cli-parse-grammar.d.ts +2 -0
- package/dist/command/cli/cli-parse-grammar.js +18 -20
- package/dist/command/cli/cli-parse-verify.d.ts +10 -0
- package/dist/command/cli/cli-parse-verify.js +63 -14
- package/dist/command/cli/cli-parse.d.ts +17 -15
- package/dist/command/cli/cli-parse.js +44 -27
- package/dist/command/cli/cli-update-commands.d.ts +1 -1
- package/dist/command/cli/cli-update-commands.js +29 -20
- package/dist/command/cli/cli-usage.js +58 -29
- package/dist/command/cli/cli-verify.d.ts +11 -2
- package/dist/command/cli/cli-verify.js +27 -8
- package/dist/command/cli/cloud-cli.js +2 -119
- package/dist/command/cli/daemon-link/daemon-client.d.ts +45 -0
- package/dist/command/cli/daemon-link/daemon-client.js +205 -0
- package/dist/command/cli/doctor/doctor-daemon-line.js +6 -3
- package/dist/command/cli/doctor-repairs.js +1 -1
- package/dist/command/cli/ensure-daemon.d.ts +26 -0
- package/dist/command/cli/ensure-daemon.js +57 -0
- package/dist/command/cli/finished-run/grade-command.d.ts +23 -0
- package/dist/command/cli/finished-run/grade-command.js +180 -0
- package/dist/command/cli/lifecycle/daemon-lifecycle.d.ts +1 -1
- package/dist/command/cli/lifecycle/daemon-lifecycle.js +1 -1
- package/dist/command/cli/ports/port-holder.d.ts +9 -0
- package/dist/command/cli/ports/port-holder.js +27 -3
- package/dist/command/cli/status/status-lines.d.ts +1 -0
- package/dist/command/cli/status/status-lines.js +22 -0
- package/dist/command/cli/status-fields.d.ts +5 -1
- package/dist/command/cli/status-fields.js +7 -3
- package/dist/command/cli/two-builds/compare-command.d.ts +48 -0
- package/dist/command/cli/two-builds/compare-command.js +314 -0
- package/dist/command/cli/verify-against.d.ts +61 -0
- package/dist/command/cli/verify-against.js +187 -0
- package/dist/command/cli/verify-no-session.d.ts +2 -0
- package/dist/command/cli/verify-no-session.js +13 -0
- package/dist/command/cli/verify-results.d.ts +35 -2
- package/dist/command/cli/verify-results.js +76 -10
- package/dist/command/cli.js +46 -41
- package/dist/command/daemon/daemon-resolve.d.ts +8 -1
- package/dist/command/daemon/daemon-resolve.js +27 -1
- package/dist/command/daemon/daemon.d.ts +12 -1
- package/dist/command/daemon/daemon.js +35 -4
- package/dist/command/daemon/lifetime/death-cause.js +21 -2
- package/dist/command/daemon/lifetime/heartbeat.js +7 -2
- package/dist/command/daemon/lifetime/startup-failure.d.ts +29 -0
- package/dist/command/daemon/lifetime/startup-failure.js +62 -0
- package/dist/command/setup/bringup/dev-server-files.d.ts +9 -0
- package/dist/command/setup/bringup/dev-server-files.js +17 -0
- package/dist/command/setup/bringup/static-page-server.d.ts +14 -2
- package/dist/command/setup/bringup/static-page-server.js +43 -9
- package/dist/command/setup/init/init-runtime.js +43 -2
- package/dist/command/setup/node-effects.d.ts +3 -2
- package/dist/command/setup/node-effects.js +9 -3
- package/dist/command/setup/remaining-steps.d.ts +8 -0
- package/dist/command/setup/remaining-steps.js +10 -0
- package/dist/command/setup/run-setup.d.ts +2 -0
- package/dist/command/setup/run-setup.js +9 -1
- package/dist/command/setup/setup-command.js +6 -3
- package/dist/command/update/updater.js +6 -0
- package/dist/command/version/version-skew.js +1 -1
- package/dist/features/compare/action-plan.d.ts +260 -0
- package/dist/features/compare/action-plan.js +543 -0
- package/dist/features/compare/behaviour-compare.d.ts +24 -0
- package/dist/features/compare/behaviour-compare.js +85 -0
- package/dist/features/compare/behaviour-diff.d.ts +97 -0
- package/dist/features/compare/behaviour-diff.js +202 -0
- package/dist/features/compare/behaviour-report.d.ts +40 -0
- package/dist/features/compare/behaviour-report.js +128 -0
- package/dist/features/compare/build-readings.d.ts +106 -0
- package/dist/features/compare/build-readings.js +250 -0
- package/dist/features/compare/compare-diff.d.ts +62 -0
- package/dist/features/compare/compare-diff.js +177 -0
- package/dist/features/compare/compare-report-html.d.ts +6 -0
- package/dist/features/compare/compare-report-html.js +102 -0
- package/dist/features/compare/compare-report.d.ts +15 -0
- package/dist/features/compare/compare-report.js +73 -0
- package/dist/features/compare/compare-run.d.ts +60 -0
- package/dist/features/compare/compare-run.js +123 -0
- package/dist/features/compare/outside/effect-lines.d.ts +26 -0
- package/dist/features/compare/outside/effect-lines.js +103 -0
- package/dist/features/compare/outside/state-sources.d.ts +76 -0
- package/dist/features/compare/outside/state-sources.js +164 -0
- package/dist/features/compare/outside/value-dictionary.d.ts +51 -0
- package/dist/features/compare/outside/value-dictionary.js +227 -0
- package/dist/features/compare/visible-content.d.ts +20 -0
- package/dist/features/compare/visible-content.js +125 -0
- package/dist/features/crawl/crawl-empty.js +2 -1
- package/dist/features/crawl/crawl-exhaustive.d.ts +55 -0
- package/dist/features/crawl/crawl-exhaustive.js +138 -0
- package/dist/features/crawl/crawl-tools.d.ts +9 -2
- package/dist/features/crawl/crawl-tools.js +28 -12
- package/dist/features/crawl/crawl.d.ts +8 -9
- package/dist/features/crawl/crawl.js +61 -65
- package/dist/features/exhaust/app-coverage.d.ts +5 -1
- package/dist/features/exhaust/app-coverage.js +3 -1
- package/dist/features/exhaust/code-coverage.js +8 -4
- package/dist/features/exhaust/edge-cases.d.ts +133 -0
- package/dist/features/exhaust/edge-cases.js +310 -0
- package/dist/features/exhaust/edges/edge-drive.d.ts +42 -0
- package/dist/features/exhaust/edges/edge-drive.js +364 -0
- package/dist/features/exhaust/edges/edge-run.d.ts +59 -0
- package/dist/features/exhaust/edges/edge-run.js +169 -0
- package/dist/features/exhaust/explorer.d.ts +87 -14
- package/dist/features/exhaust/explorer.js +159 -25
- package/dist/features/exhaust/failure-branches.d.ts +5 -0
- package/dist/features/exhaust/failure-branches.js +29 -11
- package/dist/features/exhaust/invariants.d.ts +112 -0
- package/dist/features/exhaust/invariants.js +188 -0
- package/dist/features/exhaust/ledger.d.ts +68 -0
- package/dist/features/exhaust/ledger.js +121 -13
- package/dist/features/exhaust/session-fold.d.ts +10 -0
- package/dist/features/exhaust/session-fold.js +46 -3
- package/dist/features/exhaust/session-port.d.ts +3 -1
- package/dist/features/exhaust/session-port.js +97 -28
- package/dist/features/grade/grade-answer.d.ts +99 -0
- package/dist/features/grade/grade-answer.js +112 -0
- package/dist/features/grade/grade-noise.d.ts +27 -0
- package/dist/features/grade/grade-noise.js +55 -0
- package/dist/features/grade/grade-renumber.d.ts +12 -0
- package/dist/features/grade/grade-renumber.js +86 -0
- package/dist/features/grade/grade-state.d.ts +50 -0
- package/dist/features/grade/grade-state.js +93 -0
- package/dist/features/grade/grade-suggest.d.ts +33 -0
- package/dist/features/grade/grade-suggest.js +98 -0
- package/dist/features/visual/visual-tools.js +66 -19
- package/dist/index.js +7 -4
- package/dist/judgement/capsule/causal-summary.js +9 -2
- package/dist/judgement/runs/artifact/replay-mapping.d.ts +13 -1
- package/dist/judgement/runs/artifact/replay-mapping.js +1 -1
- package/dist/judgement/runs/reticle-runner.js +16 -2
- package/dist/judgement/runs/runner-port.d.ts +2 -0
- package/dist/judgement/runs/runner-port.js +23 -5
- package/dist/language/flows/affected-tools.d.ts +1 -1
- package/dist/language/flows/affected-tools.js +49 -30
- package/dist/language/flows/change/git-changed.d.ts +2 -1
- package/dist/language/flows/change/git-changed.js +19 -2
- package/dist/language/flows/fields/flow-secret-field.d.ts +1 -13
- package/dist/language/flows/fields/flow-secret-field.js +77 -21
- package/dist/language/flows/flow-anchor.d.ts +60 -2
- package/dist/language/flows/flow-anchor.js +125 -20
- package/dist/language/flows/flow-classify.d.ts +20 -0
- package/dist/language/flows/flow-classify.js +90 -12
- package/dist/language/flows/flow-cross-step.d.ts +7 -1
- package/dist/language/flows/flow-cross-step.js +25 -1
- package/dist/language/flows/flow-journey.d.ts +38 -4
- package/dist/language/flows/flow-journey.js +138 -8
- package/dist/language/flows/flow-replay-run.d.ts +0 -16
- package/dist/language/flows/flow-replay-run.js +76 -14
- package/dist/language/flows/flow-replay-types.d.ts +4 -2
- package/dist/language/flows/flow-replay.d.ts +19 -1
- package/dist/language/flows/flow-replay.js +169 -52
- package/dist/language/flows/flow-replayable.js +7 -1
- package/dist/language/flows/flow-start-path.d.ts +34 -5
- package/dist/language/flows/flow-start-path.js +202 -15
- package/dist/language/flows/flow-step-runners.d.ts +14 -13
- package/dist/language/flows/flow-step-runners.js +184 -109
- package/dist/language/flows/flow-store-for-session.d.ts +26 -25
- package/dist/language/flows/flow-store-for-session.js +44 -0
- package/dist/language/flows/flow-tools.js +46 -33
- package/dist/language/flows/flows.js +176 -76
- package/dist/language/flows/recording/tape/recordings.d.ts +19 -3
- package/dist/language/flows/recording/tape/recordings.js +71 -5
- package/dist/language/flows/replay.d.ts +19 -5
- package/dist/language/flows/replay.js +99 -7
- package/dist/language/flows/steps/page-steps.d.ts +68 -0
- package/dist/language/flows/steps/page-steps.js +198 -0
- package/dist/language/flows/suite/sequential-run.d.ts +68 -0
- package/dist/language/flows/suite/sequential-run.js +96 -0
- package/dist/language/flows/verify-change-tools.js +26 -15
- package/dist/memory/cloud/background-sync.d.ts +13 -0
- package/dist/memory/cloud/background-sync.js +24 -3
- package/dist/memory/cloud/sync-daemon.js +26 -27
- package/dist/memory/journal/attach-journal.js +2 -1
- package/dist/memory/journal/drive-flow.d.ts +7 -0
- package/dist/memory/journal/drive-flow.js +44 -13
- package/dist/memory/journal/journal-recorder.d.ts +4 -2
- package/dist/memory/journal/session-journal.d.ts +7 -0
- package/dist/memory/journal/session-journal.js +58 -10
- package/dist/memory/project/root-claims.d.ts +9 -0
- package/dist/memory/project/root-claims.js +9 -4
- package/dist/portal/input/download-capture.d.ts +7 -0
- package/dist/portal/input/download-capture.js +92 -0
- package/dist/portal/input/network-detail.d.ts +6 -0
- package/dist/portal/input/network-detail.js +12 -1
- package/dist/portal/input/network-mock-tools.js +49 -13
- package/dist/portal/input/network-mock.d.ts +8 -0
- package/dist/portal/input/network-mock.js +28 -9
- package/dist/portal/input/page-capture.d.ts +24 -0
- package/dist/portal/input/page-capture.js +33 -0
- package/dist/portal/input/real-input.d.ts +22 -20
- package/dist/portal/input/real-input.js +41 -31
- package/dist/portal/input/viewport-tools.d.ts +8 -0
- package/dist/portal/input/viewport-tools.js +75 -18
- package/dist/portal/pool/browser-pool.d.ts +11 -29
- package/dist/portal/pool/browser-pool.js +35 -17
- package/dist/portal/pool/context-permissions.d.ts +17 -3
- package/dist/portal/pool/context-permissions.js +36 -5
- package/dist/portal/pool/determinism.d.ts +52 -0
- package/dist/portal/pool/determinism.js +259 -0
- package/dist/portal/pool/lease-emulation.d.ts +34 -1
- package/dist/portal/pool/lease-emulation.js +18 -0
- package/dist/portal/pool/lease-marker-stamp.d.ts +6 -0
- package/dist/portal/pool/lease-marker-stamp.js +15 -1
- package/dist/portal/pool/lease-page-error.d.ts +44 -17
- package/dist/portal/pool/lease-page-error.js +147 -5
- package/dist/portal/pool/page-error-journal.d.ts +7 -1
- package/dist/portal/pool/page-error-journal.js +27 -0
- package/dist/portal/pool/playwright-launcher.js +51 -8
- package/dist/portal/pool/pool-contract.d.ts +27 -1
- package/dist/portal/pool/pool-lease.d.ts +36 -1
- package/dist/portal/pool/zero-install.d.ts +8 -0
- package/dist/portal/pool/zero-install.js +23 -1
- package/dist/portal/session/dev-server/dev-command.d.ts +7 -6
- package/dist/portal/session/dev-server/dev-command.js +61 -15
- package/dist/portal/session/dev-server/dev-server-probe.d.ts +7 -0
- package/dist/portal/session/dev-server/dev-server-probe.js +9 -0
- package/dist/portal/session/dev-server/project-ports.d.ts +21 -0
- package/dist/portal/session/dev-server/project-ports.js +62 -0
- package/dist/portal/session/facts/departed-network.d.ts +66 -0
- package/dist/portal/session/facts/departed-network.js +142 -0
- package/dist/portal/session/no-session-diagnosis.d.ts +9 -0
- package/dist/portal/session/no-session-diagnosis.js +67 -25
- package/dist/portal/session/no-session-next-action.d.ts +2 -0
- package/dist/portal/session/no-session-next-action.js +12 -3
- package/dist/portal/session/no-session-watch.d.ts +6 -0
- package/dist/portal/session/no-session-watch.js +38 -3
- package/dist/portal/session/serving-directory.d.ts +10 -0
- package/dist/portal/session/serving-directory.js +39 -8
- package/dist/portal/session/session-manager.d.ts +10 -0
- package/dist/portal/session/session-manager.js +174 -63
- package/dist/start-wiring.d.ts +2 -1
- package/dist/start-wiring.js +2 -1
- package/dist/surface/mcp/mcp.js +3 -2
- package/dist/surface/mcp/shared-params.d.ts +2 -0
- package/dist/surface/mcp/shared-params.js +31 -0
- package/dist/surface/tools/act/act-observation.d.ts +40 -2
- package/dist/surface/tools/act/act-observation.js +118 -12
- package/dist/surface/tools/act/act-preflight.js +1 -1
- package/dist/surface/tools/act/act-sequence-retry.d.ts +23 -2
- package/dist/surface/tools/act/act-sequence-retry.js +61 -5
- package/dist/surface/tools/act/after-match.d.ts +4 -1
- package/dist/surface/tools/act/after-match.js +8 -1
- package/dist/surface/tools/act/already-true.js +1 -1
- package/dist/surface/tools/act/navigation/reload-result.d.ts +12 -1
- package/dist/surface/tools/act/navigation/reload-result.js +25 -2
- package/dist/surface/tools/act/resolve-target.d.ts +15 -1
- package/dist/surface/tools/act/resolve-target.js +24 -1
- package/dist/surface/tools/act/sequence-grade.d.ts +12 -0
- package/dist/surface/tools/act/sequence-grade.js +37 -26
- package/dist/surface/tools/act/settle-in-flight.js +27 -15
- package/dist/surface/tools/act-command.d.ts +23 -0
- package/dist/surface/tools/act-command.js +50 -0
- package/dist/surface/tools/act-sequence-tool.js +78 -50
- package/dist/surface/tools/act-tools.d.ts +1 -13
- package/dist/surface/tools/act-tools.js +74 -86
- package/dist/surface/tools/agent/build-staleness.d.ts +1 -1
- package/dist/surface/tools/agent/build-staleness.js +1 -1
- package/dist/surface/tools/assert/assert-verdict.js +16 -37
- package/dist/surface/tools/browser-tools.js +12 -6
- package/dist/surface/tools/compare-tools.d.ts +11 -0
- package/dist/surface/tools/compare-tools.js +94 -0
- package/dist/surface/tools/coverage-tools.js +1 -1
- package/dist/surface/tools/dynamic-tools.js +9 -2
- package/dist/surface/tools/error-recovery.d.ts +12 -1
- package/dist/surface/tools/error-recovery.js +91 -21
- package/dist/surface/tools/explore-tools.js +1 -1
- package/dist/surface/tools/invoke-tool.js +3 -2
- package/dist/surface/tools/lease-coverage-tools.d.ts +49 -0
- package/dist/surface/tools/lease-coverage-tools.js +128 -0
- package/dist/surface/tools/lease-hint.d.ts +35 -1
- package/dist/surface/tools/lease-hint.js +42 -0
- package/dist/surface/tools/lease-readiness.d.ts +26 -0
- package/dist/surface/tools/lease-readiness.js +79 -0
- package/dist/surface/tools/lease-seed.d.ts +14 -1
- package/dist/surface/tools/lease-seed.js +35 -3
- package/dist/surface/tools/lease-tools.d.ts +3 -41
- package/dist/surface/tools/lease-tools.js +62 -110
- package/dist/surface/tools/lease-url-identity.d.ts +46 -0
- package/dist/surface/tools/lease-url-identity.js +97 -0
- package/dist/surface/tools/merge-tools.js +99 -2
- package/dist/surface/tools/merged-name-redirect.js +5 -1
- package/dist/surface/tools/observe-tools.js +15 -9
- package/dist/surface/tools/read/console-pre-attach.d.ts +19 -0
- package/dist/surface/tools/read/console-pre-attach.js +23 -0
- package/dist/surface/tools/read-tools.js +33 -30
- package/dist/surface/tools/real-input-attempt.d.ts +0 -8
- package/dist/surface/tools/real-input-attempt.js +143 -28
- package/dist/surface/tools/session-ports.d.ts +14 -0
- package/dist/surface/tools/session-ports.js +53 -0
- package/dist/surface/tools/tool-kit.d.ts +6 -0
- package/dist/surface/tools/tool-kit.js +2 -3
- package/dist/surface/tools/tool-surface.js +0 -1
- package/dist/surface/tools/tools.js +28 -5
- package/dist/surface/tools/type-spelling.d.ts +13 -0
- package/dist/surface/tools/type-spelling.js +36 -0
- package/dist/telemetry/agent-client.d.ts +6 -0
- package/dist/telemetry/agent-client.js +10 -0
- package/dist/telemetry/argument-shape.js +1 -0
- package/dist/telemetry/tool-refused.d.ts +6 -3
- package/dist/telemetry/tool-refused.js +8 -3
- package/docs/actions.mdx +3 -3
- package/docs/agent-cheatsheet.md +1 -1
- package/docs/capabilities.mdx +61 -1
- package/docs/cli/affected.mdx +9 -7
- package/docs/cli/cloud.mdx +3 -3
- package/docs/cli/compare.mdx +94 -0
- package/docs/cli/doctor.mdx +1 -1
- package/docs/cli/grade.mdx +97 -0
- package/docs/cli/init.mdx +2 -2
- package/docs/cli/license.mdx +9 -7
- package/docs/cli/restart.mdx +7 -5
- package/docs/cli/rollback.mdx +5 -5
- package/docs/cli/stop.mdx +3 -3
- package/docs/cli/update.mdx +2 -2
- package/docs/cli/verify.mdx +23 -1
- package/docs/cli.mdx +33 -23
- package/docs/deploy-checks.md +1 -1
- package/docs/desktop.mdx +1 -1
- package/docs/enterprise.md +2 -2
- package/docs/environment.mdx +1 -1
- package/docs/flows.md +11 -1
- package/docs/frameworks.mdx +9 -9
- package/docs/getting-started.md +1 -1
- package/docs/install-manual.mdx +1 -1
- package/docs/license-activation.mdx +3 -3
- package/docs/packages/tauri.mdx +1 -1
- package/docs/packages.mdx +1 -1
- package/docs/predicates.mdx +1 -1
- package/docs/quickstart.mdx +68 -49
- package/docs/skill-setup.md +1 -1
- package/docs/skill.md +14 -13
- package/docs/telemetry.md +2 -2
- package/docs/tools/navigate.mdx +1 -1
- package/docs/tools/verify.mdx +4 -1
- package/docs/troubleshooting.mdx +30 -1
- package/docs/usage.md +17 -14
- package/package.json +11 -14
- package/dist/features/exhaust/exhaust-run.d.ts +0 -33
- package/dist/features/exhaust/exhaust-run.js +0 -82
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The action plan `reticle compare` drives on each build. Pure: it reads trees and returns steps.
|
|
3
|
+
*
|
|
4
|
+
* The plan is made on the reference, breadth first, with no model and no settings:
|
|
5
|
+
* - the start page, and a wait on the start page;
|
|
6
|
+
* - the start page with each value of each url parameter the app's scripts read
|
|
7
|
+
* (`?scenario=deferred`), up to `MAX_URL_ROOTS`. Each is a start page of its own, with its own
|
|
8
|
+
* wait, and opening it does not count toward the depth;
|
|
9
|
+
* - every single action on every control: click each button, tab, checkbox and option; fill each
|
|
10
|
+
* text field with two values chosen by the shape of its value, one edge value of that shape, and
|
|
11
|
+
* the values the app's scripts compare the field's name with (`value-dictionary.ts`); select each
|
|
12
|
+
* option of a combobox;
|
|
13
|
+
* - a click on each distinct visible text that has no control role (`- text`, `- generic`), up to
|
|
14
|
+
* `MAX_TEXT_PROBES`. A text probe stays in the plan only when it changes the screen on the
|
|
15
|
+
* reference: much of the web is a clickable element with no role;
|
|
16
|
+
* - a press of each common key (`DEFAULT_KEYS`) and of each key the app's scripts compare a key
|
|
17
|
+
* event with or name in a shortcut, up to `MAX_KEYS`, each on the start page. A key goes to the
|
|
18
|
+
* first element of the page and bubbles to the document. Escape again after each sequence that
|
|
19
|
+
* made controls appear: what opened must close;
|
|
20
|
+
* - each request the app makes fails (`FaultKind`), up to `MAX_FAULT_REQUESTS` requests. A request
|
|
21
|
+
* the start page makes on load fails three ways before the page loads. A request an action made
|
|
22
|
+
* fails two ways (server, network), and the action's sequence is done again under the fault.
|
|
23
|
+
* Files the page is built from are not requests the app makes;
|
|
24
|
+
* - each click again, followed by a wait, so behaviour on a timer can show. Also each path of two or
|
|
25
|
+
* more steps that reached a new screen, followed by a wait. The wait stays in the plan only when
|
|
26
|
+
* the reference showed delayed activity there: a call still pending, a page that kept changing
|
|
27
|
+
* while it settled, or a change during the wait;
|
|
28
|
+
* - each single action again under each option of each combobox (a mode, a variant);
|
|
29
|
+
* - after each sequence, one level deeper into the controls its last action made appear (a menu, a
|
|
30
|
+
* dialog): controls that were not on the page before that action;
|
|
31
|
+
* - on a page with two or more fields: fill every field with its own value, then press each button,
|
|
32
|
+
* also a button that is disabled until the fields are filled. When such a press brings new
|
|
33
|
+
* fields (the next step of a form, a dialog), fill those and press again, past the depth limit
|
|
34
|
+
* up to `MAX_CHAIN` steps. A form step and its own buttons (new with its fields, or disabled
|
|
35
|
+
* until they are filled) are planned first, so the cap does not starve them.
|
|
36
|
+
* Only this shows a build that takes the start time for the end time.
|
|
37
|
+
*
|
|
38
|
+
* The plan explores screens, not paths. Many paths reach one screen, and each path costs a load on
|
|
39
|
+
* every build. A path of two or more steps that ends on a screen already reached is kept for at most
|
|
40
|
+
* `MAX_PATHS_PER_SCREEN` paths, and nothing is planned after it. A single action is always kept.
|
|
41
|
+
*
|
|
42
|
+
* A control is targeted by its test id when it has one, else by its role and name. Controls with the
|
|
43
|
+
* same target are numbered in page order, so "fill every field" fills eight fields, not one field
|
|
44
|
+
* eight times. Every sequence starts from a fresh load of the start page.
|
|
45
|
+
*/
|
|
46
|
+
import { magicValues } from './outside/value-dictionary.js';
|
|
47
|
+
import { structureLines } from './visible-content.js';
|
|
48
|
+
/**
|
|
49
|
+
* How a request fails. A failed answer is an input too: the error state, the retry button and the
|
|
50
|
+
* empty list exist only when a request fails.
|
|
51
|
+
*/
|
|
52
|
+
export const FaultKind = {
|
|
53
|
+
/** The server failed. */
|
|
54
|
+
SERVER: '500',
|
|
55
|
+
/** The network failed. */
|
|
56
|
+
ABORT: 'abort',
|
|
57
|
+
/** The server answered with nothing. */
|
|
58
|
+
EMPTY: 'empty',
|
|
59
|
+
};
|
|
60
|
+
export const ControlKind = { CLICK: 'click', FILL: 'fill', SELECT: 'select' };
|
|
61
|
+
/** The most sequences in one plan. Each costs one fresh load on every build. */
|
|
62
|
+
export const MAX_SEQUENCES = 220;
|
|
63
|
+
/** A sequence this long is not probed for controls that appeared. */
|
|
64
|
+
const MAX_DEPTH = 3;
|
|
65
|
+
/**
|
|
66
|
+
* A form chain goes on past `MAX_DEPTH` up to this many steps: fill every field, press a button, and
|
|
67
|
+
* when the press brought new fields, fill those and press again. A wizard's later steps and a
|
|
68
|
+
* dialog's submit sit there.
|
|
69
|
+
*/
|
|
70
|
+
export const MAX_CHAIN = 8;
|
|
71
|
+
/**
|
|
72
|
+
* The most text probes in one plan. Much of the web is a clickable element with no role (an
|
|
73
|
+
* accordion header, a table row), so visible text is clicked too.
|
|
74
|
+
*/
|
|
75
|
+
export const MAX_TEXT_PROBES = 40;
|
|
76
|
+
/** The most start pages opened by a url parameter value, in one plan. */
|
|
77
|
+
export const MAX_URL_ROOTS = 12;
|
|
78
|
+
/** The most values of one url parameter opened as start pages. */
|
|
79
|
+
export const MAX_VALUES_PER_PARAM = 8;
|
|
80
|
+
/**
|
|
81
|
+
* The most paths of two or more steps kept to one screen. A path to a screen already reached is not
|
|
82
|
+
* explored further. A single action is always kept: it is a check on the candidate even when it
|
|
83
|
+
* changes nothing on the reference.
|
|
84
|
+
*/
|
|
85
|
+
export const MAX_PATHS_PER_SCREEN = 2;
|
|
86
|
+
/**
|
|
87
|
+
* The keys a person presses on any page: close, confirm, move focus, move in a list. Each is a check
|
|
88
|
+
* also where it changes nothing on the reference: a build that answers to the wrong key shows it.
|
|
89
|
+
*/
|
|
90
|
+
export const DEFAULT_KEYS = [
|
|
91
|
+
'Escape',
|
|
92
|
+
'Enter',
|
|
93
|
+
'Tab',
|
|
94
|
+
'ArrowDown',
|
|
95
|
+
'ArrowUp',
|
|
96
|
+
'ArrowLeft',
|
|
97
|
+
'ArrowRight',
|
|
98
|
+
'Home',
|
|
99
|
+
'End',
|
|
100
|
+
];
|
|
101
|
+
const ESCAPE = 'Escape';
|
|
102
|
+
/** The most keys pressed on the start page, common keys first. */
|
|
103
|
+
export const MAX_KEYS = 24;
|
|
104
|
+
/** The most requests made to fail, in the order the plan first sees them. */
|
|
105
|
+
export const MAX_FAULT_REQUESTS = 8;
|
|
106
|
+
/** A request made on load fails before the page starts, in each of the three ways. */
|
|
107
|
+
const LOAD_FAULTS = [FaultKind.SERVER, FaultKind.ABORT, FaultKind.EMPTY];
|
|
108
|
+
/** A request an action made fails under that action, as a server or a network failure. */
|
|
109
|
+
const ACTION_FAULTS = [FaultKind.SERVER, FaultKind.ABORT];
|
|
110
|
+
/** A request in an effect line (`effect-lines.ts`): `net: GET /api/items 200`. */
|
|
111
|
+
const NET_LINE = /^net: ([A-Z]+) (\S+)/;
|
|
112
|
+
const QUERY_START = '?';
|
|
113
|
+
/** A file the page is built from: not a request the app makes. */
|
|
114
|
+
const STATIC_PATH = /\.(m?js|jsx|tsx?|css|map|svg|png|jpe?g|gif|webp|avif|ico|woff2?|ttf|otf|html?)$|^\/(@|node_modules\/|src\/|assets\/)|^\/?$/;
|
|
115
|
+
/** The wait after a click: a slideshow advances, a countdown ends, a delivery settles. */
|
|
116
|
+
export const WAIT_AFTER_CLICK_MS = 4000;
|
|
117
|
+
const MS_PER_S = 1000;
|
|
118
|
+
const CONTROL_LINE = /^(\s*)- (button|checkbox|switch|radio|combobox|textbox|spinbutton|menuitem|menuitemcheckbox|tab|option|slider)\b(?: "([^"]*)")?(.*)$/;
|
|
119
|
+
const TESTID = /\[testid=([^\]]+)\]/;
|
|
120
|
+
const REF = /\(ref=(e\d+)\)/;
|
|
121
|
+
const VALUE = /\[value="([^"]*)"\]/;
|
|
122
|
+
const DISABLED = /\[(?:[a-z]+,)*disabled(?:,[a-z]+)*\]/;
|
|
123
|
+
/** Visible text with no control role. */
|
|
124
|
+
const TEXT_LINE = /^\s*- (?:text|generic) "([^"]{1,80})"/;
|
|
125
|
+
const TEXT_ROLE = 'text';
|
|
126
|
+
const COMBOBOX = 'combobox';
|
|
127
|
+
const OPTION = 'option';
|
|
128
|
+
const FIELD_ROLES = new Set(['textbox', 'spinbutton']);
|
|
129
|
+
const TIME = /^\d{1,2}:\d{2}$/;
|
|
130
|
+
const DATE = /^\d{4}-\d{2}-\d{2}|^\d{1,2}\/\d{1,2}\/\d{4}$/;
|
|
131
|
+
const NUMBER = /^-?\d+(\.\d+)?$/;
|
|
132
|
+
/** Long text with markup characters and a non-ASCII mark: a build that breaks only on such input. */
|
|
133
|
+
export const EDGE_TEXT = `${'W'.repeat(120)} <b>&</b> "q" ✓`;
|
|
134
|
+
/** Two ordinary values of a shape, then one edge value: the last minute, a leap day, a negative. */
|
|
135
|
+
const TIME_VALUES = ['09:15', '17:45', '23:59'];
|
|
136
|
+
const DATE_VALUES = ['2024-02-03', '2024-11-28', '2024-02-29'];
|
|
137
|
+
const NUMBER_VALUES = ['0', '7', '-1'];
|
|
138
|
+
const TEXT_VALUES = ['', 'Reticle 42', EDGE_TEXT];
|
|
139
|
+
const FIELD_TEXT = 'Reticle';
|
|
140
|
+
const HOURS_PER_DAY = 24;
|
|
141
|
+
/** The identity of a target, without its number. */
|
|
142
|
+
function identityOf(t) {
|
|
143
|
+
if (t.text !== undefined) {
|
|
144
|
+
return `${TEXT_ROLE} "${t.text}"`;
|
|
145
|
+
}
|
|
146
|
+
return t.testid === undefined ? `${t.role ?? ''} "${t.name ?? ''}"` : `[testid=${t.testid}]`;
|
|
147
|
+
}
|
|
148
|
+
/** The identity of a target, with its number. */
|
|
149
|
+
export const targetKey = (t) => `${identityOf(t)}#${String(t.nth)}`;
|
|
150
|
+
/** The target as a person reads it: `button "Save"`, `[testid=save]`, `button "Open" #2`. */
|
|
151
|
+
export const targetLabel = (t) => 0 === t.nth ? identityOf(t) : `${identityOf(t)} #${String(t.nth + 1)}`;
|
|
152
|
+
/** The controls of a tree, numbered by page order. Options are kept on their combobox. */
|
|
153
|
+
export function planControls(tree) {
|
|
154
|
+
const out = [];
|
|
155
|
+
const seen = new Map();
|
|
156
|
+
let combo;
|
|
157
|
+
for (const line of tree.split('\n')) {
|
|
158
|
+
const m = CONTROL_LINE.exec(line);
|
|
159
|
+
if (null === m) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const indent = (m[1] ?? '').length;
|
|
163
|
+
const role = m[2] ?? '';
|
|
164
|
+
const name = m[3] ?? '';
|
|
165
|
+
const rest = m[4] ?? '';
|
|
166
|
+
if (OPTION === role && combo !== undefined && indent > combo.indent) {
|
|
167
|
+
combo.control.options.push(name);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
combo = undefined;
|
|
171
|
+
if (OPTION === role) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const testid = TESTID.exec(rest)?.[1];
|
|
175
|
+
const base = testid === undefined ? { role, name } : { testid };
|
|
176
|
+
const identity = identityOf({ ...base, nth: 0 });
|
|
177
|
+
const nth = seen.get(identity) ?? 0;
|
|
178
|
+
seen.set(identity, nth + 1);
|
|
179
|
+
const ref = REF.exec(rest)?.[1];
|
|
180
|
+
let kind = ControlKind.CLICK;
|
|
181
|
+
if (COMBOBOX === role) {
|
|
182
|
+
kind = ControlKind.SELECT;
|
|
183
|
+
}
|
|
184
|
+
else if (FIELD_ROLES.has(role)) {
|
|
185
|
+
kind = ControlKind.FILL;
|
|
186
|
+
}
|
|
187
|
+
const control = {
|
|
188
|
+
kind,
|
|
189
|
+
target: { ...base, nth },
|
|
190
|
+
...(ref === undefined ? {} : { ref }),
|
|
191
|
+
value: VALUE.exec(rest)?.[1] ?? '',
|
|
192
|
+
options: [],
|
|
193
|
+
disabled: DISABLED.test(rest),
|
|
194
|
+
};
|
|
195
|
+
out.push(control);
|
|
196
|
+
if (ControlKind.SELECT === kind) {
|
|
197
|
+
combo = { indent, control };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* A click on each distinct visible text that has no control role, in page order, up to
|
|
204
|
+
* `MAX_TEXT_PROBES`. `dropped` counts the texts past the cap.
|
|
205
|
+
*/
|
|
206
|
+
export function textProbes(tree) {
|
|
207
|
+
const texts = new Set();
|
|
208
|
+
for (const line of tree.split('\n')) {
|
|
209
|
+
const text = TEXT_LINE.exec(line)?.[1];
|
|
210
|
+
if (text !== undefined) {
|
|
211
|
+
texts.add(text);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const steps = [...texts]
|
|
215
|
+
.slice(0, MAX_TEXT_PROBES)
|
|
216
|
+
.map((text) => ({ action: ControlKind.CLICK, target: { text, nth: 0 } }));
|
|
217
|
+
return { steps, dropped: Math.max(0, texts.size - MAX_TEXT_PROBES) };
|
|
218
|
+
}
|
|
219
|
+
/** The ref of the first element of the page, where a key press goes. */
|
|
220
|
+
export const firstRef = (tree) => REF.exec(tree)?.[1];
|
|
221
|
+
/** The ref of `target` on this tree, or undefined when the tree has no such control. */
|
|
222
|
+
export function refFor(tree, target) {
|
|
223
|
+
const key = targetKey(target);
|
|
224
|
+
return planControls(tree).find((c) => targetKey(c.target) === key)?.ref;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Values for a field, by the shape of its value: two that differ from it and from each other, so a
|
|
228
|
+
* build that mixes up two fields shows a difference, and one edge value of that shape.
|
|
229
|
+
*/
|
|
230
|
+
export function valuesFor(value) {
|
|
231
|
+
if (TIME.test(value)) {
|
|
232
|
+
return TIME_VALUES;
|
|
233
|
+
}
|
|
234
|
+
if (DATE.test(value)) {
|
|
235
|
+
return DATE_VALUES;
|
|
236
|
+
}
|
|
237
|
+
if (NUMBER.test(value)) {
|
|
238
|
+
return NUMBER_VALUES;
|
|
239
|
+
}
|
|
240
|
+
return TEXT_VALUES;
|
|
241
|
+
}
|
|
242
|
+
const EMPTY_DICTIONARY = new Map();
|
|
243
|
+
/** The name the app's scripts would compare a field with: its accessible name, else its test id. */
|
|
244
|
+
const fieldName = (c) => c.target.name ?? c.target.testid ?? '';
|
|
245
|
+
/** The single actions on one control. None on a disabled control. */
|
|
246
|
+
export function actionsOf(control, dictionary = EMPTY_DICTIONARY) {
|
|
247
|
+
if (control.disabled) {
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
const { target } = control;
|
|
251
|
+
if (ControlKind.SELECT === control.kind) {
|
|
252
|
+
return control.options.map((value) => ({ action: ControlKind.SELECT, target, value }));
|
|
253
|
+
}
|
|
254
|
+
if (ControlKind.FILL === control.kind) {
|
|
255
|
+
const values = new Set([
|
|
256
|
+
...valuesFor(control.value),
|
|
257
|
+
...magicValues(dictionary, fieldName(control)),
|
|
258
|
+
]);
|
|
259
|
+
return [...values].map((value) => ({ action: ControlKind.FILL, target, value }));
|
|
260
|
+
}
|
|
261
|
+
return [{ action: ControlKind.CLICK, target }];
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Field `index`'s own value in "fill every field": the first value the app compares it with, else an
|
|
265
|
+
* ordinary value of its shape, where a time moves by one hour for each field.
|
|
266
|
+
*/
|
|
267
|
+
function ownValue(field, index, dictionary) {
|
|
268
|
+
const magic = magicValues(dictionary, fieldName(field))[0];
|
|
269
|
+
if (magic !== undefined) {
|
|
270
|
+
return magic;
|
|
271
|
+
}
|
|
272
|
+
const value = valuesFor(field.value)[index % 2] ?? '';
|
|
273
|
+
if ('' === value) {
|
|
274
|
+
return `${FIELD_TEXT} ${String(index + 1)}`;
|
|
275
|
+
}
|
|
276
|
+
const time = /^(\d{1,2}):(\d{2})$/.exec(value);
|
|
277
|
+
if (null === time) {
|
|
278
|
+
return value;
|
|
279
|
+
}
|
|
280
|
+
const hour = (Number(time[1]) + index) % HOURS_PER_DAY;
|
|
281
|
+
return `${String(hour).padStart(2, '0')}:${time[2] ?? ''}`;
|
|
282
|
+
}
|
|
283
|
+
export function stepLabel(step) {
|
|
284
|
+
switch (step.action) {
|
|
285
|
+
case 'wait':
|
|
286
|
+
return `wait ${String(step.ms / MS_PER_S)} s`;
|
|
287
|
+
case 'goto':
|
|
288
|
+
return `open "${step.query}"`;
|
|
289
|
+
case 'press':
|
|
290
|
+
return `press ${step.key}`;
|
|
291
|
+
case 'fault':
|
|
292
|
+
return `fail ${step.method} ${step.path} (${step.fault})`;
|
|
293
|
+
case 'fillall':
|
|
294
|
+
return `fill every field (${step.fields.map((f) => `${targetLabel(f.target)}="${f.value}"`).join(', ')})`;
|
|
295
|
+
case 'fill':
|
|
296
|
+
return `fill ${targetLabel(step.target)} with "${step.value}"`;
|
|
297
|
+
case 'select':
|
|
298
|
+
return `select "${step.value}" in ${targetLabel(step.target)}`;
|
|
299
|
+
case 'click':
|
|
300
|
+
return `click ${targetLabel(step.target)}`;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/** One key for one sequence, the same on every build. */
|
|
304
|
+
export const sequenceKey = (seq) => JSON.stringify(seq);
|
|
305
|
+
const targetOf = (step) => 'target' in step ? targetKey(step.target) : undefined;
|
|
306
|
+
const endsInWait = (seq) => 'wait' === seq.at(-1)?.action;
|
|
307
|
+
/** The screen a tree shows, as one string: two trees with the same screen give the same string. */
|
|
308
|
+
const screenOf = (tree) => structureLines(tree).join('\n');
|
|
309
|
+
export const NOTHING_DROPPED = {
|
|
310
|
+
sequences: 0,
|
|
311
|
+
textProbes: 0,
|
|
312
|
+
urlRoots: 0,
|
|
313
|
+
sameScreenPaths: 0,
|
|
314
|
+
keys: 0,
|
|
315
|
+
faultRequests: 0,
|
|
316
|
+
};
|
|
317
|
+
/** The requests the app made, from effect lines, without the files the page is built from. */
|
|
318
|
+
export function requestsIn(lines) {
|
|
319
|
+
const out = [];
|
|
320
|
+
for (const line of lines) {
|
|
321
|
+
const m = NET_LINE.exec(line);
|
|
322
|
+
const path = (m?.[2] ?? '').split(QUERY_START)[0] ?? '';
|
|
323
|
+
if (null !== m && !STATIC_PATH.test(path)) {
|
|
324
|
+
out.push({ method: m[1] ?? '', path });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* A start page for each value of each url parameter, up to `MAX_VALUES_PER_PARAM` for one
|
|
331
|
+
* parameter and `MAX_URL_ROOTS` in all. `dropped` counts the values past the caps.
|
|
332
|
+
*/
|
|
333
|
+
export function urlRoots(params) {
|
|
334
|
+
const all = [];
|
|
335
|
+
let dropped = 0;
|
|
336
|
+
for (const [param, values] of params) {
|
|
337
|
+
dropped += Math.max(0, values.length - MAX_VALUES_PER_PARAM);
|
|
338
|
+
for (const value of values.slice(0, MAX_VALUES_PER_PARAM)) {
|
|
339
|
+
all.push({
|
|
340
|
+
action: 'goto',
|
|
341
|
+
query: `?${encodeURIComponent(param)}=${encodeURIComponent(value)}`,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
dropped += Math.max(0, all.length - MAX_URL_ROOTS);
|
|
346
|
+
return { steps: all.slice(0, MAX_URL_ROOTS), dropped };
|
|
347
|
+
}
|
|
348
|
+
const keyStep = (key) => ({ action: 'press', key });
|
|
349
|
+
/**
|
|
350
|
+
* A press of each common key, then of each key the app listens for, up to `MAX_KEYS`. `dropped`
|
|
351
|
+
* counts the keys past the cap.
|
|
352
|
+
*/
|
|
353
|
+
export function keyPresses(appKeys) {
|
|
354
|
+
const all = [...new Set([...DEFAULT_KEYS, ...appKeys])];
|
|
355
|
+
return {
|
|
356
|
+
steps: all.slice(0, MAX_KEYS).map(keyStep),
|
|
357
|
+
dropped: Math.max(0, all.length - MAX_KEYS),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* The steps that count toward `MAX_DEPTH`. Opening a start page by its url and making a request
|
|
362
|
+
* fail do not: they set up the page the actions start on.
|
|
363
|
+
*/
|
|
364
|
+
const depthOf = (seq) => seq.filter((s) => 'goto' !== s.action && 'fault' !== s.action).length;
|
|
365
|
+
/**
|
|
366
|
+
* Make the plan on the reference. `probe` drives one sequence from a fresh load, or answers
|
|
367
|
+
* undefined when a step could not be done.
|
|
368
|
+
*/
|
|
369
|
+
export async function buildPlan(startTree, probe, hints = {}) {
|
|
370
|
+
const dictionary = hints.dictionary ?? EMPTY_DICTIONARY;
|
|
371
|
+
const start = planControls(startTree);
|
|
372
|
+
const startScreen = screenOf(startTree);
|
|
373
|
+
const probes = textProbes(startTree);
|
|
374
|
+
const roots = urlRoots(hints.urlParams ?? new Map());
|
|
375
|
+
const keys = keyPresses(hints.keys ?? []);
|
|
376
|
+
/** Requests made to fail, and requests past `MAX_FAULT_REQUESTS`, as "METHOD path". */
|
|
377
|
+
const faulted = new Set();
|
|
378
|
+
const overFaultCap = new Set();
|
|
379
|
+
const faultsFor = (lines, kinds) => requestsIn(lines).flatMap(({ method, path }) => {
|
|
380
|
+
const id = `${method} ${path}`;
|
|
381
|
+
if (faulted.has(id)) {
|
|
382
|
+
return [];
|
|
383
|
+
}
|
|
384
|
+
if (faulted.size >= MAX_FAULT_REQUESTS) {
|
|
385
|
+
overFaultCap.add(id);
|
|
386
|
+
return [];
|
|
387
|
+
}
|
|
388
|
+
faulted.add(id);
|
|
389
|
+
return kinds.map((fault) => ({ action: 'fault', method, path, fault }));
|
|
390
|
+
});
|
|
391
|
+
/** A text probe stays in the plan only when it changes the screen on the reference. */
|
|
392
|
+
const probeKeys = new Set(probes.steps.map((s) => sequenceKey([s])));
|
|
393
|
+
const keysOf = (tree) => new Set(planControls(tree).map((c) => targetKey(c.target)));
|
|
394
|
+
/** The controls on the page after each probed sequence, to tell what its next action added. */
|
|
395
|
+
const before = new Map([[sequenceKey([]), keysOf(startTree)]]);
|
|
396
|
+
const steps = (c) => actionsOf(c, dictionary);
|
|
397
|
+
const singles = start.flatMap(steps);
|
|
398
|
+
const modes = start.filter((c) => ControlKind.SELECT === c.kind).flatMap(steps);
|
|
399
|
+
const wait = { action: 'wait', ms: WAIT_AFTER_CLICK_MS };
|
|
400
|
+
const queue = [
|
|
401
|
+
[wait],
|
|
402
|
+
...roots.steps.map((r) => [r]),
|
|
403
|
+
...roots.steps.map((r) => [r, wait]),
|
|
404
|
+
...faultsFor(hints.loadEffects ?? [], LOAD_FAULTS).map((f) => [f]),
|
|
405
|
+
...singles.map((s) => [s]),
|
|
406
|
+
...probes.steps.map((s) => [s]),
|
|
407
|
+
...keys.steps.map((s) => [s]),
|
|
408
|
+
...singles.filter((s) => ControlKind.CLICK === s.action).map((s) => [s, wait]),
|
|
409
|
+
...modes.flatMap((m) => singles.filter((s) => targetOf(s) !== targetOf(m)).map((s) => [m, s])),
|
|
410
|
+
];
|
|
411
|
+
const plan = [[]];
|
|
412
|
+
const seen = new Set([sequenceKey([])]);
|
|
413
|
+
/** How many probed sequences reached each screen. */
|
|
414
|
+
const screens = new Map([[startScreen, 1]]);
|
|
415
|
+
let sameScreenPaths = 0;
|
|
416
|
+
/** Sequences that end in a press after "fill every field": they may go on past `MAX_DEPTH`. */
|
|
417
|
+
const chains = new Set();
|
|
418
|
+
while (queue.length > 0 && plan.length < MAX_SEQUENCES) {
|
|
419
|
+
const seq = queue.shift() ?? [];
|
|
420
|
+
const key = sequenceKey(seq);
|
|
421
|
+
if (seen.has(key)) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
seen.add(key);
|
|
425
|
+
const depth = depthOf(seq);
|
|
426
|
+
const deeper = depth < MAX_DEPTH || (chains.has(key) && depth < MAX_CHAIN) || endsInWait(seq);
|
|
427
|
+
// A wait is probed before it is planned: it stays only where the reference showed activity.
|
|
428
|
+
const probed = deeper ? await probe(seq) : undefined;
|
|
429
|
+
// A request this sequence made can fail: replay the sequence with that request failing.
|
|
430
|
+
if (probed !== undefined && 'fault' !== seq[0]?.action) {
|
|
431
|
+
for (const fault of faultsFor(probed.effects ?? [], ACTION_FAULTS)) {
|
|
432
|
+
queue.push([fault, ...seq]);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (endsInWait(seq) && true !== probed?.active) {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (probeKeys.has(key) && (probed === undefined || screenOf(probed.tree) === startScreen)) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
// Explore screens, not paths: many paths reach one screen, and each costs a load on every build.
|
|
442
|
+
// A wait is a check of time on its path, so it is kept whatever screen it ends on.
|
|
443
|
+
if (probed !== undefined && !endsInWait(seq)) {
|
|
444
|
+
const screen = screenOf(probed.tree);
|
|
445
|
+
const visits = screens.get(screen) ?? 0;
|
|
446
|
+
screens.set(screen, visits + 1);
|
|
447
|
+
if (visits > 0 && seq.length > 1) {
|
|
448
|
+
if (visits >= MAX_PATHS_PER_SCREEN) {
|
|
449
|
+
sameScreenPaths += 1;
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
plan.push(seq);
|
|
453
|
+
}
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
plan.push(seq);
|
|
458
|
+
if (probed === undefined || (depth >= MAX_DEPTH && !chains.has(key))) {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
before.set(key, keysOf(probed.tree));
|
|
462
|
+
// A new screen reached by two or more steps can change by itself: a toast goes, a countdown
|
|
463
|
+
// ends. The wait is probed like any wait, and it stays only where the screen did not hold still.
|
|
464
|
+
if (seq.length > 1 && !endsInWait(seq)) {
|
|
465
|
+
queue.push([...seq, wait]);
|
|
466
|
+
}
|
|
467
|
+
// The page before the last action: the nearest shorter sequence that was probed. A
|
|
468
|
+
// "fill every field" step on its own is not probed, and it adds no control anyway. After a
|
|
469
|
+
// start page opened by its url, every control is new.
|
|
470
|
+
let parent = 'goto' === seq.at(-1)?.action ? new Set() : undefined;
|
|
471
|
+
for (let n = seq.length - 1; n >= 0 && parent === undefined; n -= 1) {
|
|
472
|
+
parent = before.get(sequenceKey(seq.slice(0, n)));
|
|
473
|
+
}
|
|
474
|
+
const all = planControls(probed.tree);
|
|
475
|
+
const now = all.filter((c) => !c.disabled);
|
|
476
|
+
const fields = now.filter((c) => ControlKind.FILL === c.kind);
|
|
477
|
+
const filled = seq.some((s) => 'fillall' === s.action);
|
|
478
|
+
const firstForm = fields.length >= 2 && !filled;
|
|
479
|
+
// The press after a "fill every field" brought fields that were not there before it.
|
|
480
|
+
const nextForm = fields.length >= 1 &&
|
|
481
|
+
filled &&
|
|
482
|
+
ControlKind.CLICK === seq.at(-1)?.action &&
|
|
483
|
+
fields.some((f) => parent?.has(targetKey(f.target)) !== true);
|
|
484
|
+
// A form step adds two steps: the fill and the press.
|
|
485
|
+
if ((firstForm || nextForm) && depth + 2 <= MAX_CHAIN) {
|
|
486
|
+
const fillall = {
|
|
487
|
+
action: 'fillall',
|
|
488
|
+
fields: fields.map((f, i) => ({ target: f.target, value: ownValue(f, i, dictionary) })),
|
|
489
|
+
};
|
|
490
|
+
// Also a disabled button: a submit is often disabled until the fields are filled.
|
|
491
|
+
const buttons = all.filter((c) => ControlKind.CLICK === c.kind);
|
|
492
|
+
const pressOf = (b) => [
|
|
493
|
+
...seq,
|
|
494
|
+
fillall,
|
|
495
|
+
{ action: ControlKind.CLICK, target: b.target },
|
|
496
|
+
];
|
|
497
|
+
for (const b of buttons) {
|
|
498
|
+
chains.add(sequenceKey(pressOf(b)));
|
|
499
|
+
}
|
|
500
|
+
// The form's own buttons: new with its fields, or disabled until they are filled.
|
|
501
|
+
const own = (b) => b.disabled || parent?.has(targetKey(b.target)) !== true;
|
|
502
|
+
// The form step and its own buttons go first, so the cap does not starve them. A button that
|
|
503
|
+
// was already there (a nav bar, the page's other controls) is pressed after the rest.
|
|
504
|
+
queue.unshift([...seq, fillall], ...buttons.filter(own).map(pressOf));
|
|
505
|
+
queue.push(...buttons.filter((b) => !own(b)).map(pressOf));
|
|
506
|
+
}
|
|
507
|
+
const appeared = now.filter((c) => parent?.has(targetKey(c.target)) !== true);
|
|
508
|
+
for (const control of appeared) {
|
|
509
|
+
for (const step of steps(control)) {
|
|
510
|
+
queue.push([...seq, step]);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// What opened (a dialog, a menu) must close on Escape.
|
|
514
|
+
if (appeared.length > 0 && 'press' !== seq.at(-1)?.action) {
|
|
515
|
+
queue.push([...seq, keyStep(ESCAPE)]);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const untried = new Set(queue.map(sequenceKey).filter((k) => !seen.has(k)));
|
|
519
|
+
return {
|
|
520
|
+
sequences: plan,
|
|
521
|
+
dropped: {
|
|
522
|
+
sequences: untried.size,
|
|
523
|
+
textProbes: probes.dropped,
|
|
524
|
+
urlRoots: roots.dropped,
|
|
525
|
+
sameScreenPaths,
|
|
526
|
+
keys: keys.dropped,
|
|
527
|
+
faultRequests: overFaultCap.size,
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* The sequences to drive: those that are not the start of another planned sequence. The page is
|
|
533
|
+
* read after every step of a run, so a run also reads each planned sequence it starts with.
|
|
534
|
+
*/
|
|
535
|
+
export function longestRuns(plan) {
|
|
536
|
+
const starts = new Set();
|
|
537
|
+
for (const seq of plan) {
|
|
538
|
+
for (let i = 0; i < seq.length; i += 1) {
|
|
539
|
+
starts.add(sequenceKey(seq.slice(0, i)));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return plan.filter((seq) => !starts.has(sequenceKey(seq)));
|
|
543
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type BehaviourComparison } from './behaviour-diff.js';
|
|
2
|
+
import type { CompareTimings } from './behaviour-report.js';
|
|
3
|
+
import { type PageDriver } from './build-readings.js';
|
|
4
|
+
/** A new browser tab on one build. */
|
|
5
|
+
export interface OpenedPage extends PageDriver {
|
|
6
|
+
close(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export interface CompareInput {
|
|
9
|
+
reference: string;
|
|
10
|
+
candidate: string;
|
|
11
|
+
base?: string;
|
|
12
|
+
/** Open a new tab on `url`. Throws when the tab cannot be opened. */
|
|
13
|
+
open(url: string): Promise<OpenedPage>;
|
|
14
|
+
sleep: (ms: number) => Promise<void>;
|
|
15
|
+
now: () => number;
|
|
16
|
+
/** Fetch a file the way a browser does. Undefined when it could not be fetched. */
|
|
17
|
+
fetchText: (url: string) => Promise<string | undefined>;
|
|
18
|
+
/** Read the app's declared state sources, after each sequence on every build. */
|
|
19
|
+
readState?: () => Promise<string[]>;
|
|
20
|
+
}
|
|
21
|
+
export declare function runCompare(input: CompareInput): Promise<{
|
|
22
|
+
result: BehaviourComparison;
|
|
23
|
+
timings?: CompareTimings;
|
|
24
|
+
}>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reticle compare`: drive two running builds of one app the same way, and report each difference
|
|
3
|
+
* in what a person sees. The recipe has no settings:
|
|
4
|
+
*
|
|
5
|
+
* 1. Read the values the reference's scripts compare fields and url parameters with
|
|
6
|
+
* (`value-dictionary.ts`), then make the action plan on the reference (`action-plan.ts`).
|
|
7
|
+
* 2. Drive the plan on the reference, the candidate, the base when given, and the reference again,
|
|
8
|
+
* in that order, each in a new browser tab (`build-readings.ts`). The two reference runs are on
|
|
9
|
+
* each side of the candidate, so a line that changes with time is seen as noise.
|
|
10
|
+
* With declared state sources, the app's stored data is read after each sequence too
|
|
11
|
+
* (`outside/state-sources.ts`), so a change to a record that no page shows is a difference.
|
|
12
|
+
* 3. Compare (`behaviour-diff.ts`).
|
|
13
|
+
*
|
|
14
|
+
* A lost browser session makes the run invalid, never a difference.
|
|
15
|
+
*/
|
|
16
|
+
import { NOTHING_DROPPED, buildPlan, sequenceKey } from './action-plan.js';
|
|
17
|
+
import { harvestScripts } from './outside/value-dictionary.js';
|
|
18
|
+
import { compareBehaviour, invalidComparison } from './behaviour-diff.js';
|
|
19
|
+
import { DriverLostError, probeSequence, readBuild, settledRead, } from './build-readings.js';
|
|
20
|
+
/** The event cursor before a tab's first event. */
|
|
21
|
+
const FIRST_EVENT = 0;
|
|
22
|
+
const MS_PER_TENTH = 100;
|
|
23
|
+
const TENTHS = 10;
|
|
24
|
+
const LOST_SESSION = 'The browser session was lost, so this run proves nothing. Run it again:';
|
|
25
|
+
async function withPage(input, url, use) {
|
|
26
|
+
const page = await input.open(url);
|
|
27
|
+
try {
|
|
28
|
+
return await use(page);
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await page.close();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Make the plan on the reference. A start page that does not load gives the start page only. */
|
|
35
|
+
async function planOn(input, page) {
|
|
36
|
+
if (!(await page.open())) {
|
|
37
|
+
return { sequences: [[]], dropped: NOTHING_DROPPED };
|
|
38
|
+
}
|
|
39
|
+
const hints = await harvestScripts(input.reference, input.fetchText);
|
|
40
|
+
const start = (await settledRead(page, input.sleep)).tree;
|
|
41
|
+
// The tab is new, so everything from its first event on is the start page's load.
|
|
42
|
+
const loadEffects = await page.effects(FIRST_EVENT);
|
|
43
|
+
return buildPlan(start, (seq) => probeSequence(page, seq, input.sleep), {
|
|
44
|
+
...hints,
|
|
45
|
+
loadEffects,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export async function runCompare(input) {
|
|
49
|
+
const timed = async (work) => {
|
|
50
|
+
const start = input.now();
|
|
51
|
+
const value = await work();
|
|
52
|
+
return [value, Math.round((input.now() - start) / MS_PER_TENTH) / TENTHS];
|
|
53
|
+
};
|
|
54
|
+
let sequences = 0;
|
|
55
|
+
try {
|
|
56
|
+
const [{ sequences: plan, dropped }, planS] = await timed(() => withPage(input, input.reference, (page) => planOn(input, page)));
|
|
57
|
+
sequences = plan.length;
|
|
58
|
+
const drive = (url) => timed(() => withPage(input, url, (page) => readBuild(page, plan, input.sleep, input.readState)));
|
|
59
|
+
const [reference, referenceS] = await drive(input.reference);
|
|
60
|
+
const [candidate, candidateS] = await drive(input.candidate);
|
|
61
|
+
const based = input.base === undefined ? undefined : await drive(input.base);
|
|
62
|
+
const [again, againS] = await drive(input.reference);
|
|
63
|
+
const result = compareBehaviour({
|
|
64
|
+
order: plan.map(sequenceKey),
|
|
65
|
+
reference,
|
|
66
|
+
again,
|
|
67
|
+
candidate,
|
|
68
|
+
...(based === undefined ? {} : { base: based[0] }),
|
|
69
|
+
});
|
|
70
|
+
const timings = {
|
|
71
|
+
plan: planS,
|
|
72
|
+
reference: referenceS,
|
|
73
|
+
candidate: candidateS,
|
|
74
|
+
...(based === undefined ? {} : { base: based[1] }),
|
|
75
|
+
again: againS,
|
|
76
|
+
};
|
|
77
|
+
return { result: { ...result, planDropped: dropped }, timings };
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof DriverLostError) {
|
|
81
|
+
return { result: invalidComparison(`${LOST_SESSION} ${error.message}`, sequences) };
|
|
82
|
+
}
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|