@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
|
@@ -28,6 +28,16 @@ export const AMBIENT_RECORDING = '__ambient__';
|
|
|
28
28
|
export const ambientRecordingOf = (session) => session === undefined ? AMBIENT_RECORDING : `${AMBIENT_RECORDING}:${session}`;
|
|
29
29
|
/** Whether a recording is an ambient tape: the daemon's own, never one somebody opened. */
|
|
30
30
|
export const isAmbient = (name) => AMBIENT_RECORDING === name || name.startsWith(`${AMBIENT_RECORDING}:`);
|
|
31
|
+
/**
|
|
32
|
+
* Whether a step, or one of its sub-steps, declared a consequence.
|
|
33
|
+
* ponytail: the same test as `carriesAnAssertion` in memory/journal/drive-flow.ts, which this
|
|
34
|
+
* directory may not import. Move both into core if a third caller needs it.
|
|
35
|
+
*/
|
|
36
|
+
const isChecked = (step) => {
|
|
37
|
+
const subs = Array.isArray(step.args['steps']) ? step.args['steps'] : [];
|
|
38
|
+
return (step.expect !== undefined ||
|
|
39
|
+
subs.some((sub) => 'object' === typeof sub && null !== sub && 'expect' in sub));
|
|
40
|
+
};
|
|
31
41
|
/**
|
|
32
42
|
* How long a journey the ambient tape will hold.
|
|
33
43
|
*
|
|
@@ -36,17 +46,43 @@ export const isAmbient = (name) => AMBIENT_RECORDING === name || name.startsWith
|
|
|
36
46
|
* 400-step regression test: the longest saved flow in the corpus is a small fraction of this.
|
|
37
47
|
*/
|
|
38
48
|
const AMBIENT_STEP_CAP = 400;
|
|
49
|
+
/** How many ended pages keep their unchecked steps for a successor. See takeAmbient. */
|
|
50
|
+
const CARRY_CAP = 32;
|
|
39
51
|
export class RecordingStore {
|
|
40
52
|
byHarness;
|
|
53
|
+
successorOf;
|
|
41
54
|
#active = new Map();
|
|
42
55
|
#compiled = new Map();
|
|
43
56
|
/** A navigation came after the last captured step — see markNavigated. */
|
|
44
57
|
#navigatedSinceStep = false;
|
|
45
58
|
/** Tabs with network mocks installed now, keyed by session ('' for none named). See markMocked. */
|
|
46
59
|
#mocked = new Set();
|
|
47
|
-
/**
|
|
48
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Steps a tab took after its last check, kept when the APP loaded a new page, by the session that
|
|
62
|
+
* ended. The page that loaded takes them when its own tape opens. See takeAmbient.
|
|
63
|
+
*/
|
|
64
|
+
#carried = new Map();
|
|
65
|
+
/**
|
|
66
|
+
* `byHarness`: whether the step being captured was driven by the Harness, which tapes its own.
|
|
67
|
+
* `successorOf`: the session that the same tab connected as after a page load, if any.
|
|
68
|
+
*/
|
|
69
|
+
constructor(byHarness = () => false, successorOf = () => undefined) {
|
|
49
70
|
this.byHarness = byHarness;
|
|
71
|
+
this.successorOf = successorOf;
|
|
72
|
+
}
|
|
73
|
+
/** The carried steps that this session continues, taken. */
|
|
74
|
+
#takeCarried(session) {
|
|
75
|
+
if (session === undefined) {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
for (const [ended, steps] of this.#carried) {
|
|
79
|
+
// A leased tab comes back from a page load with its own id; any other tab with a new one.
|
|
80
|
+
if (ended === session || this.successorOf(ended) === session) {
|
|
81
|
+
this.#carried.delete(ended);
|
|
82
|
+
return steps;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return [];
|
|
50
86
|
}
|
|
51
87
|
start(name, cursor, startPath, session, document) {
|
|
52
88
|
const openedOver = new Map();
|
|
@@ -114,7 +150,7 @@ export class RecordingStore {
|
|
|
114
150
|
if (!this.#active.has(ambient)) {
|
|
115
151
|
this.#active.set(ambient, {
|
|
116
152
|
cursor: 0,
|
|
117
|
-
steps:
|
|
153
|
+
steps: this.#takeCarried(session),
|
|
118
154
|
openedOver: new Map(),
|
|
119
155
|
...(session === undefined ? {} : { session }),
|
|
120
156
|
});
|
|
@@ -265,9 +301,39 @@ export class RecordingStore {
|
|
|
265
301
|
// One tab's tape is that tab's: the list names what somebody opened, and the plain tape as before.
|
|
266
302
|
return [...this.#active.keys()].filter((name) => !name.startsWith(`${AMBIENT_RECORDING}:`));
|
|
267
303
|
}
|
|
268
|
-
/**
|
|
304
|
+
/**
|
|
305
|
+
* The ambient tape of one tab (or of steps no tab named), taken: what its session end saves.
|
|
306
|
+
*
|
|
307
|
+
* A session also ends when the app itself loads a page (a form post on a server-rendered app), and
|
|
308
|
+
* the journey goes on in the page that loaded. A tape with no check is not saved, so the next
|
|
309
|
+
* page's tape starts with a copy of it: a fill and the submit that proves it are then one flow, not
|
|
310
|
+
* a fill that is dropped and a submit on an empty form. When Reticle navigated away, the agent
|
|
311
|
+
* started a new journey, and nothing is carried.
|
|
312
|
+
* ponytail: only a tape with NO check is carried; unchecked steps after a check stay behind.
|
|
313
|
+
* ponytail: kept for the last CARRY_CAP ended pages; a tab that never loads a successor drops them.
|
|
314
|
+
*/
|
|
269
315
|
takeAmbient(session) {
|
|
270
|
-
|
|
316
|
+
const tape = this.stop(ambientRecordingOf(session));
|
|
317
|
+
const unchecked = tape !== undefined && !tape.steps.some(isChecked);
|
|
318
|
+
if (tape === undefined || session === undefined || this.#navigatedSinceStep || !unchecked) {
|
|
319
|
+
return tape;
|
|
320
|
+
}
|
|
321
|
+
// The next page may already have a tape: its step was recorded before this session ended.
|
|
322
|
+
const next = this.successorOf(session);
|
|
323
|
+
const nextTape = next === undefined ? undefined : this.#active.get(ambientRecordingOf(next));
|
|
324
|
+
if (nextTape !== undefined) {
|
|
325
|
+
nextTape.steps.unshift(...tape.steps);
|
|
326
|
+
return tape;
|
|
327
|
+
}
|
|
328
|
+
this.#carried.set(session, [...tape.steps]);
|
|
329
|
+
while (this.#carried.size > CARRY_CAP) {
|
|
330
|
+
const oldest = this.#carried.keys().next().value;
|
|
331
|
+
if (oldest === undefined) {
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
this.#carried.delete(oldest);
|
|
335
|
+
}
|
|
336
|
+
return tape;
|
|
271
337
|
}
|
|
272
338
|
/**
|
|
273
339
|
* Names of recordings that have been STOPPED and compiled.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type Predicate, type ReticleEvent } from '@reticlehq/core';
|
|
2
2
|
import { type CommandResult } from '@reticlehq/core';
|
|
3
3
|
import type { RecordedStep, CompiledProgram } from './recording/tape/recordings.js';
|
|
4
4
|
import type { Session } from '../../portal/session/session.js';
|
|
@@ -13,6 +13,15 @@ export declare function ambiguousTestidNote(value: string): string;
|
|
|
13
13
|
* replay engines (replayProgram + flow-replay's step runner) so element extraction — the core of anchor
|
|
14
14
|
* resolution — can't drift between them.
|
|
15
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* The refs of a role query's matches that have NO accessible name, in page order.
|
|
18
|
+
*
|
|
19
|
+
* A role query without a name returns every control of the role, named ones included. A nameless
|
|
20
|
+
* anchor's recorded position (`nth` of `of`) counts only the nameless ones — an icon-only edit
|
|
21
|
+
* button is the 2nd nameless button of its row, not the 5th button — so the named ones are dropped
|
|
22
|
+
* before the position is read.
|
|
23
|
+
*/
|
|
24
|
+
export declare function namelessRefs(result: CommandResult): string[];
|
|
16
25
|
export declare function queryRefs(result: CommandResult): string[];
|
|
17
26
|
/**
|
|
18
27
|
* A destructive-action confirmation is one-shot and must never persist into a recording.
|
|
@@ -29,12 +38,16 @@ export declare function queryRefs(result: CommandResult): string[];
|
|
|
29
38
|
*/
|
|
30
39
|
export declare function replayActionArgs(value: unknown, confirmDangerous?: boolean, field?: string): Record<string, unknown>;
|
|
31
40
|
/**
|
|
32
|
-
* The
|
|
41
|
+
* The page a session's live url is on, as a NAVIGABLE path: pathname + query + hash, with
|
|
42
|
+
* Reticle's own params dropped. Undefined when the session has no url yet.
|
|
33
43
|
*
|
|
34
|
-
*
|
|
35
|
-
* a
|
|
44
|
+
* Every recorded page comes from here: a step's page, the route a tape is cut on, and the
|
|
45
|
+
* `startPath` and route `requires` of a saved flow. The pathname alone dropped the query, so a
|
|
46
|
+
* journey leased on `/?showModal=login` saved `startPath: "/"`, and its replay asserted "the modal
|
|
47
|
+
* is absent" on a page where the modal never opens. Under a hash router it dropped the hash too,
|
|
48
|
+
* which made every page `/` (#1411).
|
|
36
49
|
*/
|
|
37
|
-
export
|
|
50
|
+
export declare function pageOf(url: string | undefined): string | undefined;
|
|
38
51
|
/**
|
|
39
52
|
* Did the app send a write request in this window? A step that did is saved `writes: true`, so a
|
|
40
53
|
* replay does not repeat it unasked. The dev server's own traffic is not the app's.
|
|
@@ -134,3 +147,4 @@ via?: readonly {
|
|
|
134
147
|
flow: string;
|
|
135
148
|
step: number;
|
|
136
149
|
}[]): Promise<ReplayStepResult[]>;
|
|
150
|
+
export {};
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { SECRET_FIELD_ARG, SECRET_NAME_ARG, secretKeyForFill, secretValue, } from './fields/flow-secret-field.js';
|
|
2
|
-
import { EventType, FlowPredicateSchema, MUTATING_METHODS, isDevToolingUrl,
|
|
2
|
+
import { EventType, FlowPredicateSchema, MUTATING_METHODS, isDevToolingUrl, } from '@reticlehq/core';
|
|
3
3
|
import { DANGEROUS_ACTION_CONFIRM_ARG, DOCUMENT_ANCHOR_ROLE, ReticleCommand, QueryBy, isGlobalPressCall, } from '@reticlehq/core';
|
|
4
4
|
import { ReticleTool, invokedFlowMissing } from '@reticlehq/core';
|
|
5
5
|
import { formatStepAddress } from 'open-verification';
|
|
6
6
|
import { refFor } from './flow-anchor.js';
|
|
7
|
+
import { navigablePath, routeOfUrl } from '@reticlehq/engine/question/predicate/predicate-route.js';
|
|
7
8
|
import { asRecord, asString } from '@reticlehq/core';
|
|
8
9
|
/**
|
|
9
10
|
* The note attached when a testid resolves to multiple live elements (the first match is used).
|
|
@@ -21,6 +22,32 @@ function ambiguousRoleNote(role, name) {
|
|
|
21
22
|
* replay engines (replayProgram + flow-replay's step runner) so element extraction — the core of anchor
|
|
22
23
|
* resolution — can't drift between them.
|
|
23
24
|
*/
|
|
25
|
+
/**
|
|
26
|
+
* The refs of a role query's matches that have NO accessible name, in page order.
|
|
27
|
+
*
|
|
28
|
+
* A role query without a name returns every control of the role, named ones included. A nameless
|
|
29
|
+
* anchor's recorded position (`nth` of `of`) counts only the nameless ones — an icon-only edit
|
|
30
|
+
* button is the 2nd nameless button of its row, not the 5th button — so the named ones are dropped
|
|
31
|
+
* before the position is read.
|
|
32
|
+
*/
|
|
33
|
+
export function namelessRefs(result) {
|
|
34
|
+
if (!result.ok) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const elements = asRecord(result.result)['elements'];
|
|
38
|
+
if (!Array.isArray(elements)) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
const refs = [];
|
|
42
|
+
for (const element of elements) {
|
|
43
|
+
const e = asRecord(element);
|
|
44
|
+
const ref = asString(e['ref']);
|
|
45
|
+
if (0 === (asString(e['name']) ?? '').trim().length && ref !== undefined) {
|
|
46
|
+
refs.push(ref);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return refs;
|
|
50
|
+
}
|
|
24
51
|
export function queryRefs(result) {
|
|
25
52
|
if (!result.ok) {
|
|
26
53
|
return [];
|
|
@@ -86,12 +113,22 @@ function sourceFromResult(res) {
|
|
|
86
113
|
return out;
|
|
87
114
|
}
|
|
88
115
|
/**
|
|
89
|
-
* The
|
|
116
|
+
* The page a session's live url is on, as a NAVIGABLE path: pathname + query + hash, with
|
|
117
|
+
* Reticle's own params dropped. Undefined when the session has no url yet.
|
|
90
118
|
*
|
|
91
|
-
*
|
|
92
|
-
* a
|
|
119
|
+
* Every recorded page comes from here: a step's page, the route a tape is cut on, and the
|
|
120
|
+
* `startPath` and route `requires` of a saved flow. The pathname alone dropped the query, so a
|
|
121
|
+
* journey leased on `/?showModal=login` saved `startPath: "/"`, and its replay asserted "the modal
|
|
122
|
+
* is absent" on a page where the modal never opens. Under a hash router it dropped the hash too,
|
|
123
|
+
* which made every page `/` (#1411).
|
|
93
124
|
*/
|
|
94
|
-
export
|
|
125
|
+
export function pageOf(url) {
|
|
126
|
+
if (url === undefined) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const parts = routeOfUrl(url);
|
|
130
|
+
return parts === undefined ? undefined : navigablePath(parts);
|
|
131
|
+
}
|
|
95
132
|
/**
|
|
96
133
|
* Did the app send a write request in this window? A step that did is saved `writes: true`, so a
|
|
97
134
|
* replay does not repeat it unasked. The dev server's own traffic is not the app's.
|
|
@@ -300,6 +337,9 @@ function anchorArgs(r, ref, action, actArgs) {
|
|
|
300
337
|
}
|
|
301
338
|
if (testidOf !== undefined) {
|
|
302
339
|
testidArgs['of'] = testidOf;
|
|
340
|
+
if ('number' === typeof r['testidNth'] && 0 <= r['testidNth']) {
|
|
341
|
+
testidArgs['nth'] = r['testidNth'];
|
|
342
|
+
}
|
|
303
343
|
}
|
|
304
344
|
return { args: testidArgs, stable: true };
|
|
305
345
|
}
|
|
@@ -324,8 +364,33 @@ function anchorArgs(r, ref, action, actArgs) {
|
|
|
324
364
|
roleArgs['nth'] = r['nth'];
|
|
325
365
|
roleArgs['of'] = r['of'];
|
|
326
366
|
}
|
|
367
|
+
if (Array.isArray(r['within'])) {
|
|
368
|
+
roleArgs['within'] = r['within'];
|
|
369
|
+
}
|
|
327
370
|
return { args: roleArgs, stable: true };
|
|
328
371
|
}
|
|
372
|
+
// A role and NO accessible name (an icon-only button): the role plus the control's position among
|
|
373
|
+
// the nameless controls of that role. Without this branch the step fell to a component anchor or
|
|
374
|
+
// to the stale ref, and a replay on a fresh page could not find the control again.
|
|
375
|
+
if (role !== undefined &&
|
|
376
|
+
role !== 'generic' &&
|
|
377
|
+
name === undefined &&
|
|
378
|
+
asString(r['component']) === undefined) {
|
|
379
|
+
const namelessArgs = {
|
|
380
|
+
by: QueryBy.ROLE,
|
|
381
|
+
value: role,
|
|
382
|
+
action,
|
|
383
|
+
args: actArgs,
|
|
384
|
+
};
|
|
385
|
+
if (source !== undefined) {
|
|
386
|
+
namelessArgs['source'] = source;
|
|
387
|
+
}
|
|
388
|
+
if ('number' === typeof r['nth'] && 'number' === typeof r['of']) {
|
|
389
|
+
namelessArgs['nth'] = r['nth'];
|
|
390
|
+
namelessArgs['of'] = r['of'];
|
|
391
|
+
}
|
|
392
|
+
return { args: namelessArgs, stable: true };
|
|
393
|
+
}
|
|
329
394
|
const component = asString(r['component']);
|
|
330
395
|
if (component !== undefined || source !== undefined) {
|
|
331
396
|
const componentArgs = { by: QueryBy.COMPONENT, action, args: actArgs };
|
|
@@ -335,6 +400,21 @@ function anchorArgs(r, ref, action, actArgs) {
|
|
|
335
400
|
if (source !== undefined) {
|
|
336
401
|
componentArgs['source'] = source;
|
|
337
402
|
}
|
|
403
|
+
// Which of the elements this anchor matches was acted on: a list renders every item from one site.
|
|
404
|
+
// The element's own words, when it has no accessible name: they survive a change of layout.
|
|
405
|
+
const ownText = asString(r['ownText']);
|
|
406
|
+
if (ownText !== undefined && ownText.length > 0) {
|
|
407
|
+
componentArgs['text'] = ownText;
|
|
408
|
+
}
|
|
409
|
+
// The role, too: a nameless control with no own words has no other identity a layout keeps.
|
|
410
|
+
const role = asString(r['role']);
|
|
411
|
+
if (role !== undefined && role.length > 0) {
|
|
412
|
+
componentArgs['role'] = role;
|
|
413
|
+
}
|
|
414
|
+
if ('number' === typeof r['componentNth'] && 'number' === typeof r['componentOf']) {
|
|
415
|
+
componentArgs['nth'] = r['componentNth'];
|
|
416
|
+
componentArgs['of'] = r['componentOf'];
|
|
417
|
+
}
|
|
338
418
|
return { args: componentArgs, stable: true };
|
|
339
419
|
}
|
|
340
420
|
return { args: { ref, action, args: actArgs }, stable: false };
|
|
@@ -350,6 +430,8 @@ export function compileSequenceStep(args, res) {
|
|
|
350
430
|
? asRecord(res)['steps']
|
|
351
431
|
: [];
|
|
352
432
|
let stable = inputSteps.length > 0;
|
|
433
|
+
// A declared `expect` that a flow cannot keep (a session ref), said on save as for a single act (#2188).
|
|
434
|
+
let unkept = false;
|
|
353
435
|
const subSteps = inputSteps.flatMap((raw, i) => {
|
|
354
436
|
// A step on Reticle's own panel is not a step of the app's journey, as for a single act.
|
|
355
437
|
if (true === asRecord(resolved[i])['reticleUi']) {
|
|
@@ -364,14 +446,22 @@ export function compileSequenceStep(args, res) {
|
|
|
364
446
|
// one batch, but what each step CLAIMED is per step -- folding them together would let one
|
|
365
447
|
// signal answer for every click in the journey.
|
|
366
448
|
const expect = enforceableExpect(step['expect']);
|
|
449
|
+
if (expect === undefined && step['expect'] !== undefined) {
|
|
450
|
+
unkept = true;
|
|
451
|
+
}
|
|
367
452
|
return [expect === undefined ? compiled.args : { ...compiled.args, expect }];
|
|
368
453
|
});
|
|
369
454
|
const timeoutMs = explicitTimeoutMs(args);
|
|
455
|
+
// The pace is part of what the sequence did: replay at the default settle answered differently (#2130).
|
|
456
|
+
const burst = args['burst'];
|
|
457
|
+
const burstMs = 'number' === typeof burst && Number.isFinite(burst) && 0 <= burst ? burst : undefined;
|
|
370
458
|
return {
|
|
371
459
|
tool: ReticleTool.ACT_SEQUENCE,
|
|
372
460
|
stable,
|
|
373
461
|
args: { steps: subSteps },
|
|
374
462
|
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
463
|
+
...(burstMs === undefined ? {} : { burstMs }),
|
|
464
|
+
...(unkept ? { unkeptExpect: UNKEPT_EXPECT } : {}),
|
|
375
465
|
};
|
|
376
466
|
}
|
|
377
467
|
/**
|
|
@@ -412,11 +502,13 @@ async function resolveRef(session, step) {
|
|
|
412
502
|
if (!result.ok) {
|
|
413
503
|
throw new Error(result.error ?? 'query failed');
|
|
414
504
|
}
|
|
415
|
-
const refs = queryRefs(result);
|
|
505
|
+
const refs = name === undefined ? namelessRefs(result) : queryRefs(result);
|
|
416
506
|
const meant = refFor(step, refs);
|
|
417
507
|
const ref = meant ?? refs[0];
|
|
418
508
|
if (ref === undefined) {
|
|
419
|
-
throw new Error(
|
|
509
|
+
throw new Error(name === undefined
|
|
510
|
+
? `no ${value} with no accessible name resolved in current page`
|
|
511
|
+
: `${value} named '${String(name)}' did not resolve in current page`);
|
|
420
512
|
}
|
|
421
513
|
return meant === undefined ? { ref, note: ambiguousRoleNote(value, String(name)) } : { ref };
|
|
422
514
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Steps that reach past the page the journey is on: one opens another page (`navigate`), one sends a
|
|
3
|
+
* request to the app (`request`). Neither acts on an element, and both depend on a caller that owns
|
|
4
|
+
* what the replay cannot reach by itself: the sessions, the origin, the cookies.
|
|
5
|
+
*/
|
|
6
|
+
import { type Drift, type FlowRequest, type FlowStep, type FlowStepResult } from '@reticlehq/core';
|
|
7
|
+
/**
|
|
8
|
+
* Opens the page a `navigate` step names and returns the session of the page that loaded, or why no
|
|
9
|
+
* page of the app answered. A full page load ends the session that asked for it, so only the caller
|
|
10
|
+
* that owns the sessions can do this.
|
|
11
|
+
*/
|
|
12
|
+
export type OpenPage<S> = (url: string) => Promise<{
|
|
13
|
+
session: S;
|
|
14
|
+
} | {
|
|
15
|
+
error: string;
|
|
16
|
+
}>;
|
|
17
|
+
/**
|
|
18
|
+
* Sends the request a `request` step names and returns the answer, or why nothing could be sent.
|
|
19
|
+
* Supplied by the caller that knows the app's origin and the page's cookies.
|
|
20
|
+
*/
|
|
21
|
+
export type SendRequest = (request: FlowRequest) => Promise<{
|
|
22
|
+
status: number;
|
|
23
|
+
body: string;
|
|
24
|
+
} | {
|
|
25
|
+
error: string;
|
|
26
|
+
}>;
|
|
27
|
+
/** Where the page's cookies come from: the driven browser's storage state. */
|
|
28
|
+
interface CookieSource {
|
|
29
|
+
storageState?(sessionUrl: string): Promise<unknown>;
|
|
30
|
+
}
|
|
31
|
+
/** The step's result before its `expect` is judged, and the session to go on with. */
|
|
32
|
+
export declare function openStepPage<S>(step: FlowStep, index: number, open: OpenPage<S> | undefined): Promise<{
|
|
33
|
+
result: FlowStepResult;
|
|
34
|
+
session?: S;
|
|
35
|
+
}>;
|
|
36
|
+
export declare function runRequestStep(step: FlowStep, index: number, send: SendRequest | undefined, judged: boolean,
|
|
37
|
+
/**
|
|
38
|
+
* The run's own yes to steps that change data, never the flow file's: flow files are shared, and a
|
|
39
|
+
* saved `DELETE` would otherwise run with the user's cookies on any app the flow is pointed at.
|
|
40
|
+
*/
|
|
41
|
+
confirmDangerous: boolean): Promise<FlowStepResult>;
|
|
42
|
+
/** True for a step that reaches past the page: `navigate` or `request`. */
|
|
43
|
+
export declare function isPageStep(step: FlowStep): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Run a `navigate` or `request` step. `judge` evaluates an `expect` on a page; it comes from the
|
|
46
|
+
* replay so this file does not import the replay back. `checked` is false for a setup step, whose
|
|
47
|
+
* consequence is not checked. A navigate step returns the session to go on with.
|
|
48
|
+
*/
|
|
49
|
+
export declare function runPageStep<S>(step: FlowStep, index: number, ctx: {
|
|
50
|
+
open?: OpenPage<S> | undefined;
|
|
51
|
+
send?: SendRequest | undefined;
|
|
52
|
+
checked: boolean;
|
|
53
|
+
confirmDangerous: boolean;
|
|
54
|
+
judge: (session: S, expect: NonNullable<FlowStep['expect']>) => Promise<Drift | undefined>;
|
|
55
|
+
route: (session: S) => string | undefined;
|
|
56
|
+
}): Promise<{
|
|
57
|
+
result: FlowStepResult;
|
|
58
|
+
session?: S;
|
|
59
|
+
}>;
|
|
60
|
+
/**
|
|
61
|
+
* The sender a `request` step uses: same origin as the page only, the page's cookies unless the step
|
|
62
|
+
* is anonymous, no redirects followed (a 302 is the answer to judge). `pageUrl` is read at send time,
|
|
63
|
+
* because a `navigate` step changes the page.
|
|
64
|
+
*/
|
|
65
|
+
export declare function requestSender(pageUrl: () => string | undefined, provider: CookieSource | undefined,
|
|
66
|
+
/** The `Cookie` header of the account a step names in `as`, or undefined when not supplied. */
|
|
67
|
+
accountCookie?: (name: string) => string | undefined): SendRequest;
|
|
68
|
+
export {};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Steps that reach past the page the journey is on: one opens another page (`navigate`), one sends a
|
|
3
|
+
* request to the app (`request`). Neither acts on an element, and both depend on a caller that owns
|
|
4
|
+
* what the replay cannot reach by itself: the sessions, the origin, the cookies.
|
|
5
|
+
*/
|
|
6
|
+
import { FlowStepTool, } from '@reticlehq/core';
|
|
7
|
+
/*
|
|
8
|
+
* A step that opens a page in the middle of a journey. The replay goes on with the page that loaded,
|
|
9
|
+
* and the step's `expect` is judged there (see `OpenPage`).
|
|
10
|
+
*/
|
|
11
|
+
/** The step's result before its `expect` is judged, and the session to go on with. */
|
|
12
|
+
export async function openStepPage(step, index, open) {
|
|
13
|
+
const url = step.navigate ?? '';
|
|
14
|
+
const base = { step: index, tool: FlowStepTool.NAVIGATE, anchor: url };
|
|
15
|
+
// Never skipped: a journey that reports green without the page it was meant to check is a false
|
|
16
|
+
// green arriving through the step meant to make the check stronger.
|
|
17
|
+
if (open === undefined) {
|
|
18
|
+
return {
|
|
19
|
+
result: {
|
|
20
|
+
...base,
|
|
21
|
+
ok: false,
|
|
22
|
+
error: `this replay cannot open a page, so the step that opens ${url} did not run`,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const opened = await open(url);
|
|
27
|
+
if ('error' in opened) {
|
|
28
|
+
return { result: { ...base, ok: false, error: opened.error } };
|
|
29
|
+
}
|
|
30
|
+
return { result: { ...base, ok: true }, session: opened.session };
|
|
31
|
+
}
|
|
32
|
+
/** Why an answer does not match the step's `response`, or undefined when it does. */
|
|
33
|
+
function responseMismatch(response, answer) {
|
|
34
|
+
const statuses = response.status === undefined ? [] : [response.status].flat();
|
|
35
|
+
if (statuses.length > 0 && !statuses.includes(answer.status)) {
|
|
36
|
+
return `the answer was ${String(answer.status)}, expected ${statuses.join(' or ')}`;
|
|
37
|
+
}
|
|
38
|
+
if (response.contains !== undefined && !answer.body.includes(response.contains)) {
|
|
39
|
+
return `the answer (${String(answer.status)}) does not contain "${response.contains}"`;
|
|
40
|
+
}
|
|
41
|
+
if (response.absent !== undefined && answer.body.includes(response.absent)) {
|
|
42
|
+
return `the answer (${String(answer.status)}) contains "${response.absent}", which it must not`;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
/*
|
|
47
|
+
* A step that sends one request and judges the answer. It never skips: a rule the flow says it
|
|
48
|
+
* checks and did not is a false green.
|
|
49
|
+
*/
|
|
50
|
+
/** Methods that only read. Any other method changes data and needs the run's confirmation. */
|
|
51
|
+
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
52
|
+
export async function runRequestStep(step, index, send, judged,
|
|
53
|
+
/**
|
|
54
|
+
* The run's own yes to steps that change data, never the flow file's: flow files are shared, and a
|
|
55
|
+
* saved `DELETE` would otherwise run with the user's cookies on any app the flow is pointed at.
|
|
56
|
+
*/
|
|
57
|
+
confirmDangerous) {
|
|
58
|
+
const request = step.request;
|
|
59
|
+
const label = request === undefined ? '' : `${request.method} ${request.url}`;
|
|
60
|
+
const base = { step: index, tool: FlowStepTool.REQUEST, anchor: label };
|
|
61
|
+
if (request === undefined || send === undefined) {
|
|
62
|
+
return {
|
|
63
|
+
...base,
|
|
64
|
+
ok: false,
|
|
65
|
+
error: `this replay cannot send a request, so ${label} did not run`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (!READ_METHODS.has(request.method.toUpperCase()) && !confirmDangerous) {
|
|
69
|
+
return {
|
|
70
|
+
...base,
|
|
71
|
+
ok: false,
|
|
72
|
+
error: `potentially destructive request blocked: ${label} changes data, so it was not sent. ` +
|
|
73
|
+
'Confirm it for this run: reticle_verify{action:"flows", confirmDangerous: true}, or ' +
|
|
74
|
+
'`reticle verify <url> --confirm-dangerous`. It cannot be confirmed in the flow file.',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const answer = await send(request);
|
|
78
|
+
if ('error' in answer) {
|
|
79
|
+
return { ...base, ok: false, error: answer.error };
|
|
80
|
+
}
|
|
81
|
+
const mismatch = !judged || step.response === undefined ? undefined : responseMismatch(step.response, answer);
|
|
82
|
+
return mismatch === undefined ? { ...base, ok: true } : { ...base, ok: false, error: mismatch };
|
|
83
|
+
}
|
|
84
|
+
/** True for a step that reaches past the page: `navigate` or `request`. */
|
|
85
|
+
export function isPageStep(step) {
|
|
86
|
+
return step.navigate !== undefined || step.request !== undefined;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Run a `navigate` or `request` step. `judge` evaluates an `expect` on a page; it comes from the
|
|
90
|
+
* replay so this file does not import the replay back. `checked` is false for a setup step, whose
|
|
91
|
+
* consequence is not checked. A navigate step returns the session to go on with.
|
|
92
|
+
*/
|
|
93
|
+
export async function runPageStep(step, index, ctx) {
|
|
94
|
+
if (step.request !== undefined) {
|
|
95
|
+
return {
|
|
96
|
+
result: await runRequestStep(step, index, ctx.send, ctx.checked, ctx.confirmDangerous),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const opened = await openStepPage(step, index, ctx.open);
|
|
100
|
+
if (opened.session === undefined) {
|
|
101
|
+
return opened;
|
|
102
|
+
}
|
|
103
|
+
const result = opened.result;
|
|
104
|
+
const page = ctx.route(opened.session);
|
|
105
|
+
if (page !== undefined) {
|
|
106
|
+
result.page = page;
|
|
107
|
+
}
|
|
108
|
+
const drift = step.expect === undefined || !ctx.checked
|
|
109
|
+
? undefined
|
|
110
|
+
: await ctx.judge(opened.session, step.expect);
|
|
111
|
+
if (drift !== undefined) {
|
|
112
|
+
result.ok = false;
|
|
113
|
+
result.drift = drift;
|
|
114
|
+
}
|
|
115
|
+
return { result, session: opened.session };
|
|
116
|
+
}
|
|
117
|
+
/** How long a `request` step waits for the answer. */
|
|
118
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
119
|
+
/** The `Cookie` header the page would send to `host`, from the driven browser's storage state. */
|
|
120
|
+
function cookieHeader(state, host) {
|
|
121
|
+
const cookies = state?.cookies;
|
|
122
|
+
if (!Array.isArray(cookies)) {
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
return cookies
|
|
126
|
+
.filter((c) => {
|
|
127
|
+
const domain = c.domain;
|
|
128
|
+
if (typeof domain !== 'string') {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
const bare = domain.replace(/^\./, '');
|
|
132
|
+
return host === bare || host.endsWith(`.${bare}`);
|
|
133
|
+
})
|
|
134
|
+
.map((c) => `${c.name}=${c.value}`)
|
|
135
|
+
.join('; ');
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The sender a `request` step uses: same origin as the page only, the page's cookies unless the step
|
|
139
|
+
* is anonymous, no redirects followed (a 302 is the answer to judge). `pageUrl` is read at send time,
|
|
140
|
+
* because a `navigate` step changes the page.
|
|
141
|
+
*/
|
|
142
|
+
export function requestSender(pageUrl, provider,
|
|
143
|
+
/** The `Cookie` header of the account a step names in `as`, or undefined when not supplied. */
|
|
144
|
+
accountCookie = () => undefined) {
|
|
145
|
+
return async (request) => {
|
|
146
|
+
const url = pageUrl();
|
|
147
|
+
if (url === undefined) {
|
|
148
|
+
return { error: 'the page has no known address, so the request has nowhere to go' };
|
|
149
|
+
}
|
|
150
|
+
const origin = new URL(url).origin;
|
|
151
|
+
const target = new URL(request.url, origin);
|
|
152
|
+
if (target.origin !== origin) {
|
|
153
|
+
return {
|
|
154
|
+
error: `${request.url} is not on the app's origin ${origin}; a flow sends requests only to its app`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const headers = {};
|
|
158
|
+
if (request.body !== undefined) {
|
|
159
|
+
headers['content-type'] = request.contentType ?? 'application/json';
|
|
160
|
+
}
|
|
161
|
+
if (request.as !== undefined) {
|
|
162
|
+
const cookie = accountCookie(request.as);
|
|
163
|
+
if (cookie === undefined || '' === cookie) {
|
|
164
|
+
return {
|
|
165
|
+
error: `the session of the account "${request.as}" was not supplied, so the request was not ` +
|
|
166
|
+
'sent. Supply its Cookie header as a secret for this run.',
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
headers['cookie'] = cookie;
|
|
170
|
+
}
|
|
171
|
+
else if (true !== request.anonymous) {
|
|
172
|
+
const state = await provider?.storageState?.(url);
|
|
173
|
+
if (state === undefined) {
|
|
174
|
+
return {
|
|
175
|
+
error: 'the page cookies cannot be read here (this is not a driven browser), so the request ' +
|
|
176
|
+
'cannot go as the signed-in user. Mark it `anonymous`, or replay with `reticle verify`.',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const cookie = cookieHeader(state, target.hostname);
|
|
180
|
+
if (cookie !== '') {
|
|
181
|
+
headers['cookie'] = cookie;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const answer = await fetch(target, {
|
|
186
|
+
method: request.method,
|
|
187
|
+
headers,
|
|
188
|
+
...(request.body === undefined ? {} : { body: request.body }),
|
|
189
|
+
redirect: 'manual',
|
|
190
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
191
|
+
});
|
|
192
|
+
return { status: answer.status, body: await answer.text() };
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
return { error: `the request ${request.method} ${target.pathname} failed: ${String(error)}` };
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|