@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
|
@@ -146,11 +146,13 @@ export class SessionJournal {
|
|
|
146
146
|
#droppedBytes = 0;
|
|
147
147
|
/** Parsed records evicted from the retained set. Exact — these were counted, not estimated. */
|
|
148
148
|
#droppedEvents = 0;
|
|
149
|
-
/**
|
|
150
|
-
* Latest parsed eviction timestamp, or +Infinity after unparsed bytes were skipped.
|
|
151
|
-
* -Infinity means no loss. Later intact reads cannot narrow an unknown discarded time range.
|
|
152
|
-
*/
|
|
149
|
+
/** Latest `t` that a cut or an eviction can reach, inclusive. -Infinity means no loss. */
|
|
153
150
|
#lostThroughT = Number.NEGATIVE_INFINITY;
|
|
151
|
+
/** A cut skipped bytes and no record after it has been parsed yet, so its bound is unknown. */
|
|
152
|
+
#cutPending = false;
|
|
153
|
+
/** The last record parsed, so a cut can tell whether the clock started again across it. */
|
|
154
|
+
#lastParsed;
|
|
155
|
+
#now;
|
|
154
156
|
constructor(fs, root, sessionId, options = {}) {
|
|
155
157
|
if (!isValidSessionId(sessionId)) {
|
|
156
158
|
throw new Error(`refusing to journal an unsafe session id: ${sessionId}`);
|
|
@@ -171,6 +173,7 @@ export class SessionJournal {
|
|
|
171
173
|
this.#maxRetainedBytes = boundedOption(options.maxRetainedBytes, JOURNAL_READ_LIMITS.MAX_RETAINED_BYTES, 'maxRetainedBytes');
|
|
172
174
|
this.#maxRetainedEvents = boundedOption(options.maxRetainedEvents, JOURNAL_READ_LIMITS.MAX_RETAINED_EVENTS, 'maxRetainedEvents');
|
|
173
175
|
this.#eventBytesCap = cap;
|
|
176
|
+
this.#now = options.now;
|
|
174
177
|
}
|
|
175
178
|
/**
|
|
176
179
|
* Append a batch, or refuse it at the byte cap and say so.
|
|
@@ -416,10 +419,15 @@ export class SessionJournal {
|
|
|
416
419
|
// never skips, so its start IS the offset we asked for.
|
|
417
420
|
const from = chunk.from ?? this.#eventBytesConsumed;
|
|
418
421
|
const skipped = Math.max(0, from - this.#eventBytesConsumed);
|
|
419
|
-
|
|
422
|
+
// Before the ingest, so the first record parsed after the cut bounds it.
|
|
420
423
|
if (skipped > 0) {
|
|
421
424
|
this.#noteCut(skipped);
|
|
422
425
|
}
|
|
426
|
+
this.#ingestTail(chunk.text);
|
|
427
|
+
const now = this.#now;
|
|
428
|
+
if (this.#cutPending && now !== undefined) {
|
|
429
|
+
this.#boundCut({ t: now() });
|
|
430
|
+
}
|
|
423
431
|
// Resume at the end of the last COMPLETE line, counted forward over the bytes that actually
|
|
424
432
|
// ARRIVED. Not from `chunk.size`: that is the file's end as `fstat` reported it, and a read
|
|
425
433
|
// truncated or cut short returns less — a cursor there would skip every unread byte, silently
|
|
@@ -494,6 +502,10 @@ export class SessionJournal {
|
|
|
494
502
|
}
|
|
495
503
|
const result = ReticleEventSchema.safeParse(parsed);
|
|
496
504
|
if (result.success) {
|
|
505
|
+
if (this.#cutPending) {
|
|
506
|
+
this.#boundCut(result.data);
|
|
507
|
+
}
|
|
508
|
+
this.#lastParsed = result.data;
|
|
497
509
|
this.#eventCache.push(result.data);
|
|
498
510
|
// Its cost in the file, newline included — so the retained bound is in the same unit the
|
|
499
511
|
// read ceiling and the loss report are.
|
|
@@ -543,13 +555,37 @@ export class SessionJournal {
|
|
|
543
555
|
}
|
|
544
556
|
}
|
|
545
557
|
/**
|
|
546
|
-
* Skipped bytes
|
|
547
|
-
*
|
|
548
|
-
*
|
|
558
|
+
* Skipped bytes are not parsed, so their `t` is not known at the cut. The first record parsed
|
|
559
|
+
* after the cut, or the time of the read, can bound them (see `#boundCut`). Until then, the cut
|
|
560
|
+
* reaches every window.
|
|
549
561
|
*/
|
|
550
562
|
#noteCut(skippedBytes) {
|
|
551
563
|
this.#droppedBytes += skippedBytes;
|
|
552
|
-
this.#
|
|
564
|
+
this.#cutPending = true;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Bound the pending cut at `next`: the first record parsed after it, or the time of the read.
|
|
568
|
+
*
|
|
569
|
+
* The file is append-only, and one connection stamps `t` from one monotonic clock
|
|
570
|
+
* (`Session.elapsed`). So when the clock did not start again across the cut, each skipped record
|
|
571
|
+
* has a `t` at or before `next.t`.
|
|
572
|
+
*
|
|
573
|
+
* A reload or a reconnect keeps the session id and starts the clock again, and one window can
|
|
574
|
+
* span it: a window opened at t=150 on the old connection can hold skipped old records at t=200
|
|
575
|
+
* while the new connection's records start at t=10. A bound from the new clock then clears a
|
|
576
|
+
* window that lost evidence. So the cut stays unbounded, for good, when the reset cannot be
|
|
577
|
+
* ruled out: no record was parsed before the cut, or `t` or `seq` went down, or the document
|
|
578
|
+
* changed. This check is conservative but not complete. A reconnect in the same document whose
|
|
579
|
+
* new clock already passed the old `t` shows none of the three signs.
|
|
580
|
+
*/
|
|
581
|
+
#boundCut(next) {
|
|
582
|
+
const last = this.#lastParsed;
|
|
583
|
+
this.#cutPending = false;
|
|
584
|
+
if (last === undefined || clockStartedAgain(last, next)) {
|
|
585
|
+
this.#lostThroughT = Number.POSITIVE_INFINITY;
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
this.#lostThroughT = Math.max(this.#lostThroughT, next.t);
|
|
553
589
|
}
|
|
554
590
|
/**
|
|
555
591
|
* What these reads could not produce, or `undefined` when nothing was lost.
|
|
@@ -562,7 +598,7 @@ export class SessionJournal {
|
|
|
562
598
|
if (0 === this.#droppedBytes && 0 === this.#droppedEvents) {
|
|
563
599
|
return undefined;
|
|
564
600
|
}
|
|
565
|
-
const bounded = Number.isFinite(this.#lostThroughT);
|
|
601
|
+
const bounded = !this.#cutPending && Number.isFinite(this.#lostThroughT);
|
|
566
602
|
return {
|
|
567
603
|
droppedBytes: this.#droppedBytes,
|
|
568
604
|
droppedEvents: this.#droppedEvents,
|
|
@@ -621,6 +657,18 @@ export class SessionJournal {
|
|
|
621
657
|
return out;
|
|
622
658
|
}
|
|
623
659
|
}
|
|
660
|
+
/** Did the clock start again between `last` and `next`? A field one record lacks proves nothing. */
|
|
661
|
+
function clockStartedAgain(last, next) {
|
|
662
|
+
if (next.t < last.t) {
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
if (last.seq !== undefined && next.seq !== undefined && next.seq < last.seq) {
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
return (last.documentId !== undefined &&
|
|
669
|
+
next.documentId !== undefined &&
|
|
670
|
+
next.documentId !== last.documentId);
|
|
671
|
+
}
|
|
624
672
|
/**
|
|
625
673
|
* The closure report a session's sidecar holds — details when it can be read, the bare fact when not.
|
|
626
674
|
*
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `.reticle` folder a caller meant by `root`. Agents pass the project folder, the one they work
|
|
3
|
+
* in, so a folder that holds a `.reticle` stands for it — and so does any existing directory, since
|
|
4
|
+
* a fresh worktree has none until the first run writes one, the same way a `.reticle` path is
|
|
5
|
+
* accepted before it exists. A path nowhere on disk is the caller's to fix, and the refusals name
|
|
6
|
+
* the path to pass. Openings exported so error-recovery classifies both as the caller's mistake.
|
|
7
|
+
*/
|
|
8
|
+
export declare const ARTIFACT_ROOT_ABSOLUTE_REFUSAL: string;
|
|
9
|
+
export declare const ARTIFACT_ROOT_MISSING_REFUSAL: string;
|
|
1
10
|
/** Claim `root` for the session a lease registers. Takes a `.reticle` folder or the project folder holding one. */
|
|
2
11
|
export declare function claimArtifactRoot(leaseId: string, root: string): void;
|
|
3
12
|
/** Every root a lease claimed in this process, released or not. Sync reads it to find the work. */
|
|
@@ -17,20 +17,25 @@ const claims = new Map();
|
|
|
17
17
|
const everClaimed = new Set();
|
|
18
18
|
/**
|
|
19
19
|
* The `.reticle` folder a caller meant by `root`. Agents pass the project folder, the one they work
|
|
20
|
-
* in, so a folder that holds a `.reticle` stands for it
|
|
20
|
+
* in, so a folder that holds a `.reticle` stands for it — and so does any existing directory, since
|
|
21
|
+
* a fresh worktree has none until the first run writes one, the same way a `.reticle` path is
|
|
22
|
+
* accepted before it exists. A path nowhere on disk is the caller's to fix, and the refusals name
|
|
23
|
+
* the path to pass. Openings exported so error-recovery classifies both as the caller's mistake.
|
|
21
24
|
*/
|
|
25
|
+
export const ARTIFACT_ROOT_ABSOLUTE_REFUSAL = `root must be an absolute path to a ${ReticleDir.ROOT} folder`;
|
|
26
|
+
export const ARTIFACT_ROOT_MISSING_REFUSAL = `root must be an existing directory, or the ${ReticleDir.ROOT} folder inside one`;
|
|
22
27
|
function resolveArtifactRoot(root) {
|
|
23
28
|
if (!isAbsolute(root)) {
|
|
24
|
-
throw new Error(
|
|
29
|
+
throw new Error(`${ARTIFACT_ROOT_ABSOLUTE_REFUSAL}, got ${root}`);
|
|
25
30
|
}
|
|
26
31
|
if (ReticleDir.ROOT === basename(root)) {
|
|
27
32
|
return root;
|
|
28
33
|
}
|
|
29
34
|
const inside = join(root, ReticleDir.ROOT);
|
|
30
|
-
if (isDirectory(inside)) {
|
|
35
|
+
if (isDirectory(inside) || isDirectory(root)) {
|
|
31
36
|
return inside;
|
|
32
37
|
}
|
|
33
|
-
throw new Error(
|
|
38
|
+
throw new Error(`${ARTIFACT_ROOT_MISSING_REFUSAL}, got ${root}. Pass ${inside}`);
|
|
34
39
|
}
|
|
35
40
|
function isDirectory(path) {
|
|
36
41
|
try {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type NetworkDetail } from './network-detail.js';
|
|
2
|
+
/** The part of a CDP session this needs, kept structural so it can be tested with a fake. */
|
|
3
|
+
export interface CdpLike {
|
|
4
|
+
send(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
5
|
+
on(event: string, handler: (event: unknown) => void): void;
|
|
6
|
+
}
|
|
7
|
+
export declare function attachDownloadCapture(cdp: CdpLike, emit: (detail: NetworkDetail) => void, pageUrl: () => string): Promise<void>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { buildNetworkDetail } from './network-detail.js';
|
|
2
|
+
import { drivenRedactionPolicy } from './driven-redaction.js';
|
|
3
|
+
/**
|
|
4
|
+
* Capture a file the server sends as a download, on a page Reticle drives.
|
|
5
|
+
*
|
|
6
|
+
* A click that becomes a download leaves the page as a navigation the browser turns into a file.
|
|
7
|
+
* Measured on Chromium, nothing the page can see records it: no page `request`/`response` event, no
|
|
8
|
+
* CDP `Network.responseReceived` on the page target, and a Playwright route does not intercept it.
|
|
9
|
+
* So a `net` check on a working export missed. CDP request interception at the response stage does
|
|
10
|
+
* see it, with the status and headers, so this listens there.
|
|
11
|
+
*
|
|
12
|
+
* Document responses only, and every paused response is let through at once: the capture must never
|
|
13
|
+
* change or delay what the page receives. Chromium only; a browser without the Fetch domain gets no
|
|
14
|
+
* capture rather than an error.
|
|
15
|
+
*/
|
|
16
|
+
const DOCUMENT_RESPONSES = { patterns: [{ resourceType: 'Document', requestStage: 'Response' }] };
|
|
17
|
+
/**
|
|
18
|
+
* The body is read only for a text attachment of known, small size. Reading it holds the whole file
|
|
19
|
+
* in memory, so a large or binary download keeps its status and headers and gets no body: a body
|
|
20
|
+
* check on it stays undecidable rather than costing the run its memory.
|
|
21
|
+
*/
|
|
22
|
+
const MAX_BODY_READ_BYTES = 1024 * 1024;
|
|
23
|
+
const TEXTUAL = /text\/|json|csv|xml/i;
|
|
24
|
+
function readableBodySize(headers) {
|
|
25
|
+
const length = Number(headers['content-length']);
|
|
26
|
+
return (Number.isFinite(length) &&
|
|
27
|
+
length <= MAX_BODY_READ_BYTES &&
|
|
28
|
+
TEXTUAL.test(headers['content-type'] ?? ''));
|
|
29
|
+
}
|
|
30
|
+
async function readBody(cdp, requestId) {
|
|
31
|
+
try {
|
|
32
|
+
const got = (await cdp.send('Fetch.getResponseBody', { requestId }));
|
|
33
|
+
if ('string' !== typeof got.body) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return true === got.base64Encoded ? Buffer.from(got.body, 'base64').toString('utf8') : got.body;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function headersOf(raw) {
|
|
43
|
+
const headers = {};
|
|
44
|
+
if (!Array.isArray(raw)) {
|
|
45
|
+
return headers;
|
|
46
|
+
}
|
|
47
|
+
for (const entry of raw) {
|
|
48
|
+
const { name, value } = (entry ?? {});
|
|
49
|
+
if ('string' === typeof name && 'string' === typeof value) {
|
|
50
|
+
headers[name.toLowerCase()] = value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return headers;
|
|
54
|
+
}
|
|
55
|
+
function isAttachment(headers) {
|
|
56
|
+
return /^\s*attachment\b/i.test(headers['content-disposition'] ?? '');
|
|
57
|
+
}
|
|
58
|
+
async function onPaused(cdp, paused, emit, pageUrl) {
|
|
59
|
+
const requestId = 'string' === typeof paused.requestId ? paused.requestId : undefined;
|
|
60
|
+
try {
|
|
61
|
+
const headers = headersOf(paused.responseHeaders);
|
|
62
|
+
const url = paused.request?.url;
|
|
63
|
+
const status = paused.responseStatusCode;
|
|
64
|
+
if (isAttachment(headers) && 'string' === typeof url && 'number' === typeof status) {
|
|
65
|
+
const method = paused.request?.method;
|
|
66
|
+
const responseBody = requestId !== undefined && readableBodySize(headers)
|
|
67
|
+
? await readBody(cdp, requestId)
|
|
68
|
+
: undefined;
|
|
69
|
+
emit(buildNetworkDetail({
|
|
70
|
+
url,
|
|
71
|
+
...('string' === typeof method ? { method } : {}),
|
|
72
|
+
status,
|
|
73
|
+
headers,
|
|
74
|
+
resourceType: 'document',
|
|
75
|
+
...(responseBody === undefined ? {} : { responseBody }),
|
|
76
|
+
pageUrl: pageUrl(),
|
|
77
|
+
}, drivenRedactionPolicy()));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Recording is best-effort; letting the response through below is not.
|
|
82
|
+
}
|
|
83
|
+
if (requestId !== undefined) {
|
|
84
|
+
await cdp.send('Fetch.continueRequest', { requestId }).catch(() => undefined);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export async function attachDownloadCapture(cdp, emit, pageUrl) {
|
|
88
|
+
cdp.on('Fetch.requestPaused', (event) => {
|
|
89
|
+
void onPaused(cdp, event ?? {}, emit, pageUrl);
|
|
90
|
+
});
|
|
91
|
+
await cdp.send('Fetch.enable', DOCUMENT_RESPONSES);
|
|
92
|
+
}
|
|
@@ -49,6 +49,10 @@ export interface NetworkDetail {
|
|
|
49
49
|
* Omitted when the whole body fits, so the caveat means something when it appears.
|
|
50
50
|
*/
|
|
51
51
|
requestBodyTruncated?: boolean;
|
|
52
|
+
/** The response body, captured only where the page cannot (a server-sent download); capped. */
|
|
53
|
+
responseBody?: string;
|
|
54
|
+
/** True when `responseBody` was cut to the cap: a body check on the rest cannot be decided. */
|
|
55
|
+
responseBodyTruncated?: boolean;
|
|
52
56
|
/**
|
|
53
57
|
* The document that ISSUED the request, which is what identifies the session it belongs to.
|
|
54
58
|
*
|
|
@@ -73,6 +77,8 @@ export declare function buildNetworkDetail(raw: {
|
|
|
73
77
|
headers: Record<string, string>;
|
|
74
78
|
resourceType?: string;
|
|
75
79
|
requestBody?: string;
|
|
80
|
+
/** The response body, when the capture could read it (a server-sent download). */
|
|
81
|
+
responseBody?: string;
|
|
76
82
|
pageUrl?: string;
|
|
77
83
|
}, policy?: RedactionPolicy): NetworkDetail;
|
|
78
84
|
/** The minimal Playwright surfaces this attachment reads — kept structural so it's fake-testable. */
|
|
@@ -19,8 +19,9 @@ const DEFAULT_POLICY = { isSensitiveKey: defaultIsSensitiveKey };
|
|
|
19
19
|
* which is key-based over a parsed object — not string-shaped.
|
|
20
20
|
*/
|
|
21
21
|
function redactByKey(value, policy) {
|
|
22
|
-
if (Array.isArray(value))
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
23
|
return value.map((v) => redactByKey(v, policy));
|
|
24
|
+
}
|
|
24
25
|
if (value !== null && 'object' === typeof value) {
|
|
25
26
|
const out = {};
|
|
26
27
|
for (const [k, v] of Object.entries(value)) {
|
|
@@ -86,6 +87,10 @@ function projectHeaders(headers, policy) {
|
|
|
86
87
|
* about the function's inputs.
|
|
87
88
|
*/
|
|
88
89
|
export function buildNetworkDetail(raw, policy = DEFAULT_POLICY) {
|
|
90
|
+
// Capped and redacted exactly like the request body, and the cut is declared the same way.
|
|
91
|
+
const responseBody = raw.responseBody === undefined || 0 === raw.responseBody.length
|
|
92
|
+
? undefined
|
|
93
|
+
: projectWireBody(raw.responseBody, policy);
|
|
89
94
|
const wireBody = raw.requestBody === undefined || 0 === raw.requestBody.length
|
|
90
95
|
? undefined
|
|
91
96
|
: projectWireBody(raw.requestBody, policy);
|
|
@@ -101,6 +106,12 @@ export function buildNetworkDetail(raw, policy = DEFAULT_POLICY) {
|
|
|
101
106
|
requestBody: wireBody.body,
|
|
102
107
|
...(wireBody.truncated ? { requestBodyTruncated: true } : {}),
|
|
103
108
|
}),
|
|
109
|
+
...(responseBody === undefined
|
|
110
|
+
? {}
|
|
111
|
+
: {
|
|
112
|
+
responseBody: responseBody.body,
|
|
113
|
+
...(responseBody.truncated ? { responseBodyTruncated: true } : {}),
|
|
114
|
+
}),
|
|
104
115
|
...(raw.pageUrl === undefined || 0 === raw.pageUrl.length ? {} : { pageUrl: raw.pageUrl }),
|
|
105
116
|
};
|
|
106
117
|
}
|
|
@@ -8,6 +8,17 @@ function mockProvider(deps) {
|
|
|
8
8
|
const p = deps.realInput;
|
|
9
9
|
return p !== undefined && 'function' === typeof p.setMocks ? p : undefined;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Whether this call can mock the session: a lease of this caller, or a page the driven browser has.
|
|
13
|
+
* Having a driven browser is not enough: it mocks its own page, whatever session was named.
|
|
14
|
+
*/
|
|
15
|
+
async function canMock(deps, session) {
|
|
16
|
+
if (true === deps.pool?.heldBy(session.id, deps.attachId)) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
const driven = mockProvider(deps);
|
|
20
|
+
return driven !== undefined && driven.isAvailableFor(session.url);
|
|
21
|
+
}
|
|
11
22
|
const ruleShape = z.object({
|
|
12
23
|
urlContains: z
|
|
13
24
|
.string()
|
|
@@ -22,6 +33,11 @@ const ruleShape = z.object({
|
|
|
22
33
|
.boolean()
|
|
23
34
|
.optional()
|
|
24
35
|
.describe('Simulate a network failure (offline) instead of a response.'),
|
|
36
|
+
forwardTo: z
|
|
37
|
+
.string()
|
|
38
|
+
.url()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe('Send the matching request on to this origin, e.g. "http://127.0.0.1:15500"; the path and query stay and the real server answers.'),
|
|
25
41
|
});
|
|
26
42
|
/**
|
|
27
43
|
* Narrow the validated tool args into MockRule[], omitting undefined keys (exactOptionalPropertyTypes).
|
|
@@ -41,8 +57,9 @@ const ruleShape = z.object({
|
|
|
41
57
|
* to parse.
|
|
42
58
|
*/
|
|
43
59
|
export function toRules(value) {
|
|
44
|
-
if (value === undefined)
|
|
60
|
+
if (value === undefined) {
|
|
45
61
|
return [];
|
|
62
|
+
}
|
|
46
63
|
const parsed = z.array(ruleShape).safeParse(value);
|
|
47
64
|
if (!parsed.success) {
|
|
48
65
|
const problem = parsed.error.issues
|
|
@@ -50,23 +67,32 @@ export function toRules(value) {
|
|
|
50
67
|
.join('; ');
|
|
51
68
|
throw new Error(`reticle_network_mock could not read \`mocks\` (${problem}). Nothing was applied and mocking ` +
|
|
52
69
|
'was left unchanged — an unreadable rule is NOT treated as a request to clear. A rule is ' +
|
|
53
|
-
'{ urlContains, method?, status?, body?, contentType?, delayMs?, abort? }; pass `clear: true` ' +
|
|
70
|
+
'{ urlContains, method?, status?, body?, contentType?, delayMs?, abort?, forwardTo? }; pass `clear: true` ' +
|
|
54
71
|
'or an empty array if you meant to turn mocking off.');
|
|
55
72
|
}
|
|
56
73
|
return parsed.data.map((r) => {
|
|
57
74
|
const rule = { urlContains: r.urlContains };
|
|
58
|
-
if (r.method !== undefined)
|
|
75
|
+
if (r.method !== undefined) {
|
|
59
76
|
rule.method = r.method;
|
|
60
|
-
|
|
77
|
+
}
|
|
78
|
+
if (r.status !== undefined) {
|
|
61
79
|
rule.status = r.status;
|
|
62
|
-
|
|
80
|
+
}
|
|
81
|
+
if (r.body !== undefined) {
|
|
63
82
|
rule.body = r.body;
|
|
64
|
-
|
|
83
|
+
}
|
|
84
|
+
if (r.contentType !== undefined) {
|
|
65
85
|
rule.contentType = r.contentType;
|
|
66
|
-
|
|
86
|
+
}
|
|
87
|
+
if (r.delayMs !== undefined) {
|
|
67
88
|
rule.delayMs = r.delayMs;
|
|
68
|
-
|
|
89
|
+
}
|
|
90
|
+
if (r.abort !== undefined) {
|
|
69
91
|
rule.abort = r.abort;
|
|
92
|
+
}
|
|
93
|
+
if (r.forwardTo !== undefined) {
|
|
94
|
+
rule.forwardTo = r.forwardTo;
|
|
95
|
+
}
|
|
70
96
|
return rule;
|
|
71
97
|
});
|
|
72
98
|
}
|
|
@@ -95,7 +121,7 @@ export const NETWORK_MOCK_TOOLS = [
|
|
|
95
121
|
mocks: z
|
|
96
122
|
.array(ruleShape)
|
|
97
123
|
.optional()
|
|
98
|
-
.describe('Interception rules, first match wins: { urlContains, method?, status?, body?, contentType?, delayMs?, abort? }. Omit or pass [] to clear.'),
|
|
124
|
+
.describe('Interception rules, first match wins: { urlContains, method?, status?, body?, contentType?, delayMs?, abort?, forwardTo? }. Omit or pass [] to clear.'),
|
|
99
125
|
clear: z.boolean().optional().describe('Clear all active mocks (same as mocks: []).'),
|
|
100
126
|
...sessionIdShape,
|
|
101
127
|
},
|
|
@@ -107,20 +133,30 @@ export const NETWORK_MOCK_TOOLS = [
|
|
|
107
133
|
recommendation: z.string().optional(),
|
|
108
134
|
},
|
|
109
135
|
handler: async (deps, args) => {
|
|
110
|
-
const session = deps.sessions.
|
|
136
|
+
const session = await deps.sessions.resolveServing(asString(args['sessionId']), (s) => canMock(deps, s));
|
|
111
137
|
const rules = true === args['clear'] ? [] : toRules(args['mocks']);
|
|
112
|
-
|
|
138
|
+
// A lease is mocked as the lease, and the driven browser mocks only a page it has. The driven
|
|
139
|
+
// provider mocks its own page whatever session was named: taken first, it mocked another tab,
|
|
140
|
+
// answered `applied: true`, and the lease's real write reached the backend.
|
|
141
|
+
const driven = mockProvider(deps);
|
|
142
|
+
const provider = driven !== undefined &&
|
|
143
|
+
deps.pool?.leaseUrl(session.id) === undefined &&
|
|
144
|
+
(await driven.isAvailableFor(session.url))
|
|
145
|
+
? driven
|
|
146
|
+
: undefined;
|
|
113
147
|
if (provider !== undefined) {
|
|
114
148
|
const applied = await provider.setMocks(session.url, rules);
|
|
115
|
-
if (applied)
|
|
149
|
+
if (applied) {
|
|
116
150
|
return mocksApplied(deps, session.id, rules.length);
|
|
151
|
+
}
|
|
117
152
|
}
|
|
118
153
|
// A lease is a Playwright-owned page — CDP intercept was always there, this tool just had no
|
|
119
154
|
// route to it. Tried after the driven provider: when both exist, drive is the page the caller
|
|
120
155
|
// means, and a lease is the fallback rather than a competitor.
|
|
121
156
|
const leased = await deps.pool?.setMocksLease(session.id, rules);
|
|
122
|
-
if (true === leased)
|
|
157
|
+
if (true === leased) {
|
|
123
158
|
return mocksApplied(deps, session.id, rules.length);
|
|
159
|
+
}
|
|
124
160
|
return {
|
|
125
161
|
applied: false,
|
|
126
162
|
count: 0,
|
|
@@ -26,6 +26,12 @@ export interface MockRule {
|
|
|
26
26
|
delayMs?: number;
|
|
27
27
|
/** Simulate a network failure (offline / connection refused) instead of a response. */
|
|
28
28
|
abort?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Send the request on to this origin (protocol, host and port; the path and query stay). An app that
|
|
31
|
+
* calls a fixed address (`http://localhost:5000`) can then reach its API on any port, for example when
|
|
32
|
+
* the host already uses that port. The real server answers.
|
|
33
|
+
*/
|
|
34
|
+
forwardTo?: string;
|
|
29
35
|
}
|
|
30
36
|
/** What to do with a request after matching: fulfill a canned response, abort it, or let it through. */
|
|
31
37
|
interface MockOutcome {
|
|
@@ -34,6 +40,8 @@ interface MockOutcome {
|
|
|
34
40
|
body?: string;
|
|
35
41
|
contentType?: string;
|
|
36
42
|
delayMs?: number;
|
|
43
|
+
/** For `continue`: the url the request goes on with (a `forwardTo` rule). */
|
|
44
|
+
url?: string;
|
|
37
45
|
}
|
|
38
46
|
/**
|
|
39
47
|
* Decide the outcome for a request from the active rules. Pure: no IO, no clock. The first rule
|
|
@@ -6,12 +6,26 @@ const DEFAULT_CONTENT_TYPE = 'application/json';
|
|
|
6
6
|
*/
|
|
7
7
|
export function matchMock(rules, req) {
|
|
8
8
|
for (const rule of rules) {
|
|
9
|
-
if (!req.url.includes(rule.urlContains))
|
|
9
|
+
if (!req.url.includes(rule.urlContains)) {
|
|
10
10
|
continue;
|
|
11
|
-
|
|
11
|
+
}
|
|
12
|
+
if (rule.method !== undefined && rule.method.toUpperCase() !== req.method.toUpperCase()) {
|
|
12
13
|
continue;
|
|
13
|
-
|
|
14
|
+
}
|
|
15
|
+
if (true === rule.abort) {
|
|
14
16
|
return { kind: 'abort' };
|
|
17
|
+
}
|
|
18
|
+
if (rule.forwardTo !== undefined) {
|
|
19
|
+
const to = new URL(rule.forwardTo);
|
|
20
|
+
const next = new URL(req.url);
|
|
21
|
+
next.protocol = to.protocol;
|
|
22
|
+
next.host = to.host;
|
|
23
|
+
const outcome = { kind: 'continue', url: next.toString() };
|
|
24
|
+
if (rule.delayMs !== undefined) {
|
|
25
|
+
outcome.delayMs = rule.delayMs;
|
|
26
|
+
}
|
|
27
|
+
return outcome;
|
|
28
|
+
}
|
|
15
29
|
/*
|
|
16
30
|
* A rule that says only WHEN says nothing about WHAT.
|
|
17
31
|
*
|
|
@@ -35,10 +49,12 @@ export function matchMock(rules, req) {
|
|
|
35
49
|
status: rule.status ?? DEFAULT_STATUS,
|
|
36
50
|
contentType: rule.contentType ?? DEFAULT_CONTENT_TYPE,
|
|
37
51
|
};
|
|
38
|
-
if (rule.body !== undefined)
|
|
52
|
+
if (rule.body !== undefined) {
|
|
39
53
|
outcome.body = rule.body;
|
|
40
|
-
|
|
54
|
+
}
|
|
55
|
+
if (rule.delayMs !== undefined) {
|
|
41
56
|
outcome.delayMs = rule.delayMs;
|
|
57
|
+
}
|
|
42
58
|
return outcome;
|
|
43
59
|
}
|
|
44
60
|
return { kind: 'continue' };
|
|
@@ -49,17 +65,19 @@ export async function applyOutcome(route, outcome, sleep) {
|
|
|
49
65
|
// Sleep FIRST, then let it through: the delay is the whole perturbation, and the response that
|
|
50
66
|
// comes back is the server's own. Fallback, not continue: a lease's context route (the CSP
|
|
51
67
|
// widening, #2016) must still see the request.
|
|
52
|
-
if (outcome.delayMs !== undefined && outcome.delayMs > 0)
|
|
68
|
+
if (outcome.delayMs !== undefined && outcome.delayMs > 0) {
|
|
53
69
|
await sleep(outcome.delayMs);
|
|
54
|
-
|
|
70
|
+
}
|
|
71
|
+
await (outcome.url === undefined ? route.fallback() : route.fallback({ url: outcome.url }));
|
|
55
72
|
return;
|
|
56
73
|
}
|
|
57
74
|
if ('abort' === outcome.kind) {
|
|
58
75
|
await route.abort('failed');
|
|
59
76
|
return;
|
|
60
77
|
}
|
|
61
|
-
if (outcome.delayMs !== undefined && outcome.delayMs > 0)
|
|
78
|
+
if (outcome.delayMs !== undefined && outcome.delayMs > 0) {
|
|
62
79
|
await sleep(outcome.delayMs);
|
|
80
|
+
}
|
|
63
81
|
await route.fulfill({
|
|
64
82
|
status: outcome.status ?? DEFAULT_STATUS,
|
|
65
83
|
contentType: outcome.contentType ?? DEFAULT_CONTENT_TYPE,
|
|
@@ -74,8 +92,9 @@ const realSleep = (ms) => new Promise((res) => setTimeout(res, ms));
|
|
|
74
92
|
*/
|
|
75
93
|
export async function installNetworkMocks(page, rules, sleep = realSleep) {
|
|
76
94
|
await page.unroute('**/*').catch(() => undefined);
|
|
77
|
-
if (0 === rules.length)
|
|
95
|
+
if (0 === rules.length) {
|
|
78
96
|
return;
|
|
97
|
+
}
|
|
79
98
|
await page.route('**/*', (route) => {
|
|
80
99
|
const req = route.request();
|
|
81
100
|
const outcome = matchMock(rules, { url: req.url(), method: req.method() });
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A screenshot of a Playwright page, shared by the CDP and launched providers so the capture path
|
|
3
|
+
* lives in one place.
|
|
4
|
+
*/
|
|
5
|
+
import type { Page } from 'playwright';
|
|
6
|
+
import type { ElementBox } from './real-input.js';
|
|
7
|
+
/** Options for a page screenshot — full-page scroll capture and/or a clip box. */
|
|
8
|
+
export interface ScreenshotOpts {
|
|
9
|
+
fullPage?: boolean;
|
|
10
|
+
/** Restrict the capture to one element/region (viewport CSS px). */
|
|
11
|
+
clip?: ElementBox;
|
|
12
|
+
/**
|
|
13
|
+
* A live picture, as a JPEG at this quality: what a person is watching, taken as it is. The
|
|
14
|
+
* baseline tweaks (hiding the HUD, stopping animations) would make their window flicker each second.
|
|
15
|
+
*/
|
|
16
|
+
jpegQuality?: number;
|
|
17
|
+
/** One image pixel per CSS pixel, so a high-density display does not double a step screenshot. */
|
|
18
|
+
cssScale?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Capture a PNG from a Playwright page. Shared by the CDP + launched providers so the
|
|
22
|
+
* screenshot path lives in one place (mirrors performGesture). Returns the raw PNG bytes.
|
|
23
|
+
*/
|
|
24
|
+
export declare function capturePage(page: Page, opts: ScreenshotOpts): Promise<Uint8Array>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { SCREENSHOT_DETERMINISM, captureWithChromeHidden, } from '../pool/playwright-launcher.js';
|
|
2
|
+
/**
|
|
3
|
+
* Capture a PNG from a Playwright page. Shared by the CDP + launched providers so the
|
|
4
|
+
* screenshot path lives in one place (mirrors performGesture). Returns the raw PNG bytes.
|
|
5
|
+
*/
|
|
6
|
+
export async function capturePage(page, opts) {
|
|
7
|
+
// A step capture injects nothing into the page, so Reticle's own hosts are hidden another way.
|
|
8
|
+
if (true === opts.cssScale) {
|
|
9
|
+
return captureWithChromeHidden({ evaluate: (script) => page.evaluate(script) }, () => shoot(page, opts));
|
|
10
|
+
}
|
|
11
|
+
return shoot(page, opts);
|
|
12
|
+
}
|
|
13
|
+
/** Playwright screenshot options for one capture: a JPEG, a clip, the full page, or the viewport. */
|
|
14
|
+
function screenshotOptions(opts) {
|
|
15
|
+
if (opts.jpegQuality !== undefined) {
|
|
16
|
+
return {
|
|
17
|
+
type: 'jpeg',
|
|
18
|
+
quality: opts.jpegQuality,
|
|
19
|
+
...(true === opts.cssScale ? { scale: 'css' } : {}),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (opts.clip !== undefined) {
|
|
23
|
+
return { ...SCREENSHOT_DETERMINISM, clip: opts.clip };
|
|
24
|
+
}
|
|
25
|
+
if (true === opts.fullPage) {
|
|
26
|
+
return { ...SCREENSHOT_DETERMINISM, fullPage: true };
|
|
27
|
+
}
|
|
28
|
+
return { ...SCREENSHOT_DETERMINISM };
|
|
29
|
+
}
|
|
30
|
+
async function shoot(page, opts) {
|
|
31
|
+
const buf = await page.screenshot(screenshotOptions(opts));
|
|
32
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
33
|
+
}
|