@reticlehq/server 3.9.0 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +6 -0
- 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/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 +1 -1
- package/docs/usage.md +17 -14
- package/package.json +13 -13
- package/dist/features/exhaust/exhaust-run.d.ts +0 -33
- package/dist/features/exhaust/exhaust-run.js +0 -82
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `reticle verify <candidate> --against <reference>` prints, writes and exits with.
|
|
3
|
+
*
|
|
4
|
+
* Exit 0 only when steps were compared and no difference was found. A run that compared nothing
|
|
5
|
+
* proved nothing, so it exits 1, the same as a difference.
|
|
6
|
+
*/
|
|
7
|
+
import { DifferenceKind } from './compare-diff.js';
|
|
8
|
+
const EXIT_SAME = 0;
|
|
9
|
+
const EXIT_DIFFERENT = 1;
|
|
10
|
+
/** The version of the `--results-json` file this writes. */
|
|
11
|
+
export const COMPARE_SCHEMA_VERSION = 1;
|
|
12
|
+
const INDENT = ' ';
|
|
13
|
+
const REMOVED_MARK = '- ';
|
|
14
|
+
const ADDED_MARK = '+ ';
|
|
15
|
+
const MSG_SAME = 'No difference: the candidate shows what the reference shows after every step.';
|
|
16
|
+
const MSG_NOTHING_COMPARED = 'Nothing was compared, so nothing was proved. The reference did no step two times the same way. ' +
|
|
17
|
+
'Make sure the reference url opens, and that its page does not change on each load.';
|
|
18
|
+
const MSG_UNREACHED = 'The candidate could not do the last action. The reference could.';
|
|
19
|
+
export function compareExit(result) {
|
|
20
|
+
if (result.differences.length > 0 || 0 === result.compared) {
|
|
21
|
+
return EXIT_DIFFERENT;
|
|
22
|
+
}
|
|
23
|
+
return EXIT_SAME;
|
|
24
|
+
}
|
|
25
|
+
function differenceLines(difference) {
|
|
26
|
+
const where = 1 === difference.seen ? '1 step' : `${String(difference.seen)} steps`;
|
|
27
|
+
const lines = [
|
|
28
|
+
`difference ${difference.id} (seen at ${where})`,
|
|
29
|
+
`${INDENT}after:`,
|
|
30
|
+
...difference.actions.map((action, i) => `${INDENT}${INDENT}${String(i + 1)}. ${action}`),
|
|
31
|
+
];
|
|
32
|
+
if (DifferenceKind.UNREACHED === difference.kind) {
|
|
33
|
+
lines.push(`${INDENT}${MSG_UNREACHED}`);
|
|
34
|
+
}
|
|
35
|
+
lines.push(...difference.removed.map((line) => `${INDENT}${REMOVED_MARK}${line}`));
|
|
36
|
+
lines.push(...difference.added.map((line) => `${INDENT}${ADDED_MARK}${line}`));
|
|
37
|
+
return lines;
|
|
38
|
+
}
|
|
39
|
+
export function compareLines(urls, result) {
|
|
40
|
+
const lines = [
|
|
41
|
+
`Compared ${urls.candidate} against ${urls.reference}.`,
|
|
42
|
+
`${String(result.journeys)} journeys, ${String(result.compared)} steps compared. ` +
|
|
43
|
+
`${String(result.ignored)} lines ignored, because they also changed between two runs of the reference.`,
|
|
44
|
+
];
|
|
45
|
+
if (result.unstable > 0) {
|
|
46
|
+
lines.push(`${String(result.unstable)} steps were not compared: the reference did them on one of its two runs only.`);
|
|
47
|
+
}
|
|
48
|
+
if (result.explored.budgetExhausted) {
|
|
49
|
+
lines.push(`The explorer used its action budget. ${String(result.explored.frontier)} states it found were not explored.`);
|
|
50
|
+
}
|
|
51
|
+
if (0 === result.compared && 0 === result.differences.length) {
|
|
52
|
+
lines.push(MSG_NOTHING_COMPARED);
|
|
53
|
+
return lines;
|
|
54
|
+
}
|
|
55
|
+
if (0 === result.differences.length) {
|
|
56
|
+
lines.push(MSG_SAME);
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
lines.push(`${String(result.differences.length)} differences:`, '');
|
|
60
|
+
for (const difference of result.differences) {
|
|
61
|
+
lines.push(...differenceLines(difference), '');
|
|
62
|
+
}
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
/** The `--results-json` file. Every count the text gives is here too, so nothing is dropped silently. */
|
|
66
|
+
export function compareJson(urls, result) {
|
|
67
|
+
return {
|
|
68
|
+
schemaVersion: COMPARE_SCHEMA_VERSION,
|
|
69
|
+
...urls,
|
|
70
|
+
identical: EXIT_SAME === compareExit(result),
|
|
71
|
+
...result,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive two builds of one app with the same actions, and read what each one shows after every step.
|
|
3
|
+
*
|
|
4
|
+
* The actions come from two places. The project's saved flows are replayed one step at a time, and
|
|
5
|
+
* the page is read after each step, not only where the flow has a check. The explorer the exhaustive
|
|
6
|
+
* crawl uses (`explore`) walks the reference's states breadth first, and every click it tried
|
|
7
|
+
* becomes a journey. Only the reference is explored: both builds then get the same list of journeys,
|
|
8
|
+
* so a control the candidate lost shows as a step it could not do.
|
|
9
|
+
*
|
|
10
|
+
* Four browsers, one after the other: one to explore, two runs of the reference (the second sets
|
|
11
|
+
* what is noise), and one run of the candidate. Each run starts in a new browser, so state one run
|
|
12
|
+
* leaves (a cookie, local storage) cannot reach the next one.
|
|
13
|
+
*/
|
|
14
|
+
import { type ExplorePort } from '../exhaust/explorer.js';
|
|
15
|
+
import { type Comparison } from './compare-diff.js';
|
|
16
|
+
/** A browser on one build: what the explorer needs, plus a full read and one step of a saved flow. */
|
|
17
|
+
export interface CompareDriver extends ExplorePort {
|
|
18
|
+
/** The whole page as an accessibility tree. */
|
|
19
|
+
read(): Promise<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Do step `index` of the saved flow `flow`. Step 0 opens the flow's start page first. Every later
|
|
22
|
+
* step continues on the page as the step before it left it. `label` says what the step did.
|
|
23
|
+
*/
|
|
24
|
+
replayStep(flow: string, index: number): Promise<{
|
|
25
|
+
done: boolean;
|
|
26
|
+
label: string;
|
|
27
|
+
}>;
|
|
28
|
+
close(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export interface CompareInput {
|
|
31
|
+
reference: string;
|
|
32
|
+
candidate: string;
|
|
33
|
+
/** Open a new browser on a build. Throws when the build cannot be opened. */
|
|
34
|
+
open(url: string): Promise<CompareDriver>;
|
|
35
|
+
/** The saved flows to replay, and how many steps each has. */
|
|
36
|
+
flows: readonly {
|
|
37
|
+
name: string;
|
|
38
|
+
steps: number;
|
|
39
|
+
}[];
|
|
40
|
+
/** The explorer's action budget. */
|
|
41
|
+
maxActions: number;
|
|
42
|
+
/** Called after each action, before the page is read, so behaviour driven by a timer can show. */
|
|
43
|
+
wait: () => Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
export interface CompareResult extends Comparison {
|
|
46
|
+
/** How many journeys each build was driven through. */
|
|
47
|
+
journeys: number;
|
|
48
|
+
/** What the explorer found on the reference. A non-zero frontier means it stopped at its budget. */
|
|
49
|
+
explored: {
|
|
50
|
+
states: number;
|
|
51
|
+
actions: number;
|
|
52
|
+
frontier: number;
|
|
53
|
+
budgetExhausted: boolean;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** The action that reaches the start page, for the journey that only reads it. */
|
|
57
|
+
export declare const START_ACTION = "open the start page";
|
|
58
|
+
/** What the explorer types into a text field: this, then the field's position on its state. */
|
|
59
|
+
export declare const FILL_TEXT = "text";
|
|
60
|
+
export declare function compareBuilds(input: CompareInput): Promise<CompareResult>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive two builds of one app with the same actions, and read what each one shows after every step.
|
|
3
|
+
*
|
|
4
|
+
* The actions come from two places. The project's saved flows are replayed one step at a time, and
|
|
5
|
+
* the page is read after each step, not only where the flow has a check. The explorer the exhaustive
|
|
6
|
+
* crawl uses (`explore`) walks the reference's states breadth first, and every click it tried
|
|
7
|
+
* becomes a journey. Only the reference is explored: both builds then get the same list of journeys,
|
|
8
|
+
* so a control the candidate lost shows as a step it could not do.
|
|
9
|
+
*
|
|
10
|
+
* Four browsers, one after the other: one to explore, two runs of the reference (the second sets
|
|
11
|
+
* what is noise), and one run of the candidate. Each run starts in a new browser, so state one run
|
|
12
|
+
* leaves (a cookie, local storage) cannot reach the next one.
|
|
13
|
+
*/
|
|
14
|
+
import { controlsOf, explore } from '../exhaust/explorer.js';
|
|
15
|
+
import { compareReadings } from './compare-diff.js';
|
|
16
|
+
import { visibleLines } from './visible-content.js';
|
|
17
|
+
const JourneyKind = { FLOW: 'flow', PATH: 'path' };
|
|
18
|
+
const StepAction = { CLICK: 'click', FILL: 'fill' };
|
|
19
|
+
/** The action that reaches the start page, for the journey that only reads it. */
|
|
20
|
+
export const START_ACTION = 'open the start page';
|
|
21
|
+
/** What the explorer types into a text field: this, then the field's position on its state. */
|
|
22
|
+
export const FILL_TEXT = 'text';
|
|
23
|
+
const flowStepLabel = (flow, index, label) => `flow "${flow}" step ${String(index + 1)}: ${label}`;
|
|
24
|
+
const pathStepLabel = (step) => StepAction.FILL === step.action
|
|
25
|
+
? `${StepAction.FILL} ${step.key} with "${step.value ?? ''}"`
|
|
26
|
+
: `${StepAction.CLICK} ${step.key}`;
|
|
27
|
+
/** The paths that are not the start of a longer path: reading a long path reads its starts too. */
|
|
28
|
+
function longestPaths(paths) {
|
|
29
|
+
const text = paths.map((path) => path.map((step) => JSON.stringify(step)));
|
|
30
|
+
return paths.filter((path, i) => text.every((other, j) => i === j || other.length <= path.length || path.some((_, k) => other[k] !== text[i]?.[k])));
|
|
31
|
+
}
|
|
32
|
+
async function readLines(driver, wait) {
|
|
33
|
+
await wait();
|
|
34
|
+
return visibleLines(await driver.read());
|
|
35
|
+
}
|
|
36
|
+
async function readFlow(driver, journey, flow, wait) {
|
|
37
|
+
const out = [];
|
|
38
|
+
const actions = [];
|
|
39
|
+
for (let step = 0; step < flow.steps; step += 1) {
|
|
40
|
+
const done = await driver.replayStep(flow.name, step);
|
|
41
|
+
actions.push(flowStepLabel(flow.name, step, done.label));
|
|
42
|
+
if (!done.done) {
|
|
43
|
+
out.push({ journey, step, actions: [...actions] });
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
out.push({ journey, step, actions: [...actions], lines: await readLines(driver, wait) });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
async function readPath(driver, journey, steps, wait) {
|
|
51
|
+
if (!(await driver.reset())) {
|
|
52
|
+
return [{ journey, step: 0, actions: [START_ACTION] }];
|
|
53
|
+
}
|
|
54
|
+
if (0 === steps.length) {
|
|
55
|
+
return [{ journey, step: 0, actions: [START_ACTION], lines: await readLines(driver, wait) }];
|
|
56
|
+
}
|
|
57
|
+
const out = [];
|
|
58
|
+
const actions = [];
|
|
59
|
+
for (const [index, step] of steps.entries()) {
|
|
60
|
+
actions.push(pathStepLabel(step));
|
|
61
|
+
// The first control with this identity, on every build, so both builds act on the same one.
|
|
62
|
+
const control = controlsOf((await driver.look()).tree).find((c) => c.key === step.key);
|
|
63
|
+
const done = control !== undefined && (await driver.act(control.ref, step.action, step.value)).ok;
|
|
64
|
+
if (!done) {
|
|
65
|
+
out.push({ journey, step: index, actions: [...actions] });
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
out.push({ journey, step: index, actions: [...actions], lines: await readLines(driver, wait) });
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/** Drive one build through every journey, in a new browser, and read it after every step. */
|
|
73
|
+
async function readBuild(input, url, journeys) {
|
|
74
|
+
const driver = await input.open(url);
|
|
75
|
+
try {
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const [index, journey] of journeys.entries()) {
|
|
78
|
+
if (JourneyKind.FLOW === journey.kind) {
|
|
79
|
+
out.push(...(await readFlow(driver, index, journey, input.wait)));
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
out.push(...(await readPath(driver, index, journey.steps, input.wait)));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await driver.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export async function compareBuilds(input) {
|
|
92
|
+
const explorer = await input.open(input.reference);
|
|
93
|
+
let report;
|
|
94
|
+
try {
|
|
95
|
+
report = await explore(explorer, {
|
|
96
|
+
maxActions: input.maxActions,
|
|
97
|
+
// Each field its own value, so a build that swaps two fields does not read the same.
|
|
98
|
+
fillValue: (_label, index) => `${FILL_TEXT} ${String(index + 1)}`,
|
|
99
|
+
wait: input.wait,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
await explorer.close();
|
|
104
|
+
}
|
|
105
|
+
const journeys = [
|
|
106
|
+
...input.flows.map((flow) => ({ kind: JourneyKind.FLOW, ...flow })),
|
|
107
|
+
{ kind: JourneyKind.PATH, steps: [] },
|
|
108
|
+
...longestPaths(report.paths).map((steps) => ({ kind: JourneyKind.PATH, steps })),
|
|
109
|
+
];
|
|
110
|
+
const reference = await readBuild(input, input.reference, journeys);
|
|
111
|
+
const again = await readBuild(input, input.reference, journeys);
|
|
112
|
+
const candidate = await readBuild(input, input.candidate, journeys);
|
|
113
|
+
return {
|
|
114
|
+
...compareReadings(reference, again, candidate),
|
|
115
|
+
journeys: journeys.length,
|
|
116
|
+
explored: {
|
|
117
|
+
states: report.states,
|
|
118
|
+
actions: report.actions,
|
|
119
|
+
frontier: report.frontier,
|
|
120
|
+
budgetExhausted: report.budgetExhausted,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the app DID, as lines that compare like page text. Pure.
|
|
3
|
+
*
|
|
4
|
+
* A wrong payload, a swallowed failure, a duplicate submit or a thrown error can leave the page the
|
|
5
|
+
* same; it shows here. Each network call is its method, its path (the host removed, the query values
|
|
6
|
+
* blanked, the keys kept), its status, and the start of its request body. Each console error or
|
|
7
|
+
* warning is its level and text.
|
|
8
|
+
*
|
|
9
|
+
* A repeated identical GET is kept once. A list that loads one more page on one run is not a change
|
|
10
|
+
* in behaviour, and a read changes nothing on the server. A repeated write is kept: a double submit
|
|
11
|
+
* is a defect.
|
|
12
|
+
*
|
|
13
|
+
* A read that failed is written as an `effects failed` line, never dropped: an empty list must mean
|
|
14
|
+
* that nothing happened.
|
|
15
|
+
*/
|
|
16
|
+
/** The lines of a `reticle_observe { action: "network" }` result. */
|
|
17
|
+
export declare function networkLines(result: unknown): string[];
|
|
18
|
+
/** The error and warning lines of a `reticle_observe { action: "console" }` result. */
|
|
19
|
+
export declare function consoleLines(result: unknown): string[];
|
|
20
|
+
/**
|
|
21
|
+
* A read that loaded. Compare does not count it as a difference: what it loaded shows on the page, and when a
|
|
22
|
+
* read runs (a font, a refetch) changes from run to run. A read that failed, and every write, still count.
|
|
23
|
+
*/
|
|
24
|
+
export declare const isLoadedRead: (line: string) => boolean;
|
|
25
|
+
/** A network line for a call that was still in flight when it was read. */
|
|
26
|
+
export declare const isPendingLine: (line: string) => boolean;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the app DID, as lines that compare like page text. Pure.
|
|
3
|
+
*
|
|
4
|
+
* A wrong payload, a swallowed failure, a duplicate submit or a thrown error can leave the page the
|
|
5
|
+
* same; it shows here. Each network call is its method, its path (the host removed, the query values
|
|
6
|
+
* blanked, the keys kept), its status, and the start of its request body. Each console error or
|
|
7
|
+
* warning is its level and text.
|
|
8
|
+
*
|
|
9
|
+
* A repeated identical GET is kept once. A list that loads one more page on one run is not a change
|
|
10
|
+
* in behaviour, and a read changes nothing on the server. A repeated write is kept: a double submit
|
|
11
|
+
* is a defect.
|
|
12
|
+
*
|
|
13
|
+
* A read that failed is written as an `effects failed` line, never dropped: an empty list must mean
|
|
14
|
+
* that nothing happened.
|
|
15
|
+
*/
|
|
16
|
+
const NET = 'net:';
|
|
17
|
+
const CONSOLE = 'console';
|
|
18
|
+
const EFFECTS_FAILED = 'effects failed:';
|
|
19
|
+
const NETWORK_SOURCE = 'network';
|
|
20
|
+
const CONSOLE_SOURCE = 'console';
|
|
21
|
+
const NO_ANSWER = 'no answer';
|
|
22
|
+
/** The status a call still in flight carries. */
|
|
23
|
+
const PENDING = 'pending';
|
|
24
|
+
const KEPT_LEVELS = new Set(['error', 'warn', 'warning']);
|
|
25
|
+
const BODY_CHARS = 120;
|
|
26
|
+
const TEXT_CHARS = 200;
|
|
27
|
+
const FAILURE_CHARS = 160;
|
|
28
|
+
const HOST = /^[a-z][a-z0-9+.-]*:\/\/[^/]+/i;
|
|
29
|
+
const QUERY_VALUE = /=[^&#]*/g;
|
|
30
|
+
const asText = (value) => 'string' === typeof value ? value : undefined;
|
|
31
|
+
const record = (value) => 'object' === typeof value && null !== value ? value : undefined;
|
|
32
|
+
const oneLine = (text, max) => text.replace(/\s+/g, ' ').trim().slice(0, max);
|
|
33
|
+
/** The list under `key`, or the reason it is not there. */
|
|
34
|
+
function listOf(result, key) {
|
|
35
|
+
const r = record(result);
|
|
36
|
+
const items = r?.[key];
|
|
37
|
+
if (Array.isArray(items)) {
|
|
38
|
+
return { items };
|
|
39
|
+
}
|
|
40
|
+
const why = asText(r?.['error']) ?? (r === undefined ? NO_ANSWER : JSON.stringify(r));
|
|
41
|
+
return { failed: oneLine(why, FAILURE_CHARS) };
|
|
42
|
+
}
|
|
43
|
+
/** The path of a url, with the host removed and the query values blanked. */
|
|
44
|
+
const pathOf = (url) => url.replace(HOST, '').replace(QUERY_VALUE, '=');
|
|
45
|
+
const READ_METHOD = 'GET';
|
|
46
|
+
/** The lines of a `reticle_observe { action: "network" }` result. */
|
|
47
|
+
export function networkLines(result) {
|
|
48
|
+
const list = listOf(result, 'calls');
|
|
49
|
+
if ('failed' in list) {
|
|
50
|
+
return [`${EFFECTS_FAILED} ${NETWORK_SOURCE}: ${list.failed}`];
|
|
51
|
+
}
|
|
52
|
+
const reads = new Set();
|
|
53
|
+
return list.items.flatMap((item) => {
|
|
54
|
+
const line = callLine(item);
|
|
55
|
+
if (line.startsWith(`${NET} ${READ_METHOD} `)) {
|
|
56
|
+
if (reads.has(line)) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
reads.add(line);
|
|
60
|
+
}
|
|
61
|
+
return [line];
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** One call as a line. */
|
|
65
|
+
function callLine(item) {
|
|
66
|
+
const call = record(item) ?? {};
|
|
67
|
+
const status = call['status'];
|
|
68
|
+
const parts = [
|
|
69
|
+
NET,
|
|
70
|
+
asText(call['method']) ?? READ_METHOD,
|
|
71
|
+
pathOf(asText(call['url']) ?? ''),
|
|
72
|
+
'number' === typeof status || 'string' === typeof status ? String(status) : '',
|
|
73
|
+
];
|
|
74
|
+
const body = asText(call['requestBody']);
|
|
75
|
+
if (body !== undefined && body.length > 0) {
|
|
76
|
+
parts.push(oneLine(body, BODY_CHARS));
|
|
77
|
+
}
|
|
78
|
+
return parts.filter((p) => p.length > 0).join(' ');
|
|
79
|
+
}
|
|
80
|
+
/** The error and warning lines of a `reticle_observe { action: "console" }` result. */
|
|
81
|
+
export function consoleLines(result) {
|
|
82
|
+
const list = listOf(result, 'logs');
|
|
83
|
+
if ('failed' in list) {
|
|
84
|
+
return [`${EFFECTS_FAILED} ${CONSOLE_SOURCE}: ${list.failed}`];
|
|
85
|
+
}
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const item of list.items) {
|
|
88
|
+
const log = record(item) ?? {};
|
|
89
|
+
const level = asText(log['level']) ?? '';
|
|
90
|
+
if (KEPT_LEVELS.has(level)) {
|
|
91
|
+
out.push(`${CONSOLE} ${level}: ${oneLine(asText(log['text']) ?? '', TEXT_CHARS)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
const LOADED_READ = /^net: GET \S+ [1-3]\d\d$/;
|
|
97
|
+
/**
|
|
98
|
+
* A read that loaded. Compare does not count it as a difference: what it loaded shows on the page, and when a
|
|
99
|
+
* read runs (a font, a refetch) changes from run to run. A read that failed, and every write, still count.
|
|
100
|
+
*/
|
|
101
|
+
export const isLoadedRead = (line) => LOADED_READ.test(line);
|
|
102
|
+
/** A network line for a call that was still in flight when it was read. */
|
|
103
|
+
export const isPendingLine = (line) => line.startsWith(NET) && line.split(' ').includes(PENDING);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const StateSourceKind: {
|
|
3
|
+
readonly COMMAND: "command";
|
|
4
|
+
readonly HTTP: "http";
|
|
5
|
+
readonly FILE: "file";
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Rows kept for each source. More are cut, and a line declares the cut and a digest of the cut rows.
|
|
9
|
+
*
|
|
10
|
+
* ponytail: the line diff is an O(n*m) table, so a source of thousands of rows makes each compared
|
|
11
|
+
* sequence slow. Raise this only with a faster diff.
|
|
12
|
+
*/
|
|
13
|
+
export declare const STATE_ROW_CAP = 1000;
|
|
14
|
+
export declare const STATE_PREFIX = "state";
|
|
15
|
+
export declare const MSG_READ_FAILED = "state read failed:";
|
|
16
|
+
export declare const MSG_ROWS_CUT = "state rows cut:";
|
|
17
|
+
export declare const StateSourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
18
|
+
kind: z.ZodLiteral<"command">;
|
|
19
|
+
label: z.ZodString;
|
|
20
|
+
command: z.ZodString;
|
|
21
|
+
container: z.ZodOptional<z.ZodString>;
|
|
22
|
+
ignore: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
23
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
24
|
+
kind: z.ZodLiteral<"http">;
|
|
25
|
+
label: z.ZodString;
|
|
26
|
+
url: z.ZodString;
|
|
27
|
+
path: z.ZodOptional<z.ZodString>;
|
|
28
|
+
ignore: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
29
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
30
|
+
kind: z.ZodLiteral<"file">;
|
|
31
|
+
label: z.ZodString;
|
|
32
|
+
path: z.ZodString;
|
|
33
|
+
at: z.ZodOptional<z.ZodString>;
|
|
34
|
+
container: z.ZodOptional<z.ZodString>;
|
|
35
|
+
ignore: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
36
|
+
}, z.core.$strict>], "kind">;
|
|
37
|
+
export type StateSource = z.infer<typeof StateSourceSchema>;
|
|
38
|
+
/** The `--state` file. */
|
|
39
|
+
export declare const StateDeclarationSchema: z.ZodObject<{
|
|
40
|
+
sources: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
41
|
+
kind: z.ZodLiteral<"command">;
|
|
42
|
+
label: z.ZodString;
|
|
43
|
+
command: z.ZodString;
|
|
44
|
+
container: z.ZodOptional<z.ZodString>;
|
|
45
|
+
ignore: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
46
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
47
|
+
kind: z.ZodLiteral<"http">;
|
|
48
|
+
label: z.ZodString;
|
|
49
|
+
url: z.ZodString;
|
|
50
|
+
path: z.ZodOptional<z.ZodString>;
|
|
51
|
+
ignore: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
52
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
53
|
+
kind: z.ZodLiteral<"file">;
|
|
54
|
+
label: z.ZodString;
|
|
55
|
+
path: z.ZodString;
|
|
56
|
+
at: z.ZodOptional<z.ZodString>;
|
|
57
|
+
container: z.ZodOptional<z.ZodString>;
|
|
58
|
+
ignore: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
59
|
+
}, z.core.$strict>], "kind">>;
|
|
60
|
+
}, z.core.$strict>;
|
|
61
|
+
/** How the sources are read. Each method throws when the read fails. */
|
|
62
|
+
export interface StateIo {
|
|
63
|
+
/** The standard output of `command`, run on the host, or in `container` when one is given. */
|
|
64
|
+
run(command: string, container?: string): Promise<string>;
|
|
65
|
+
fetchJson(url: string): Promise<unknown>;
|
|
66
|
+
readFile(path: string): Promise<string>;
|
|
67
|
+
}
|
|
68
|
+
/** The rows of one source, unsorted and without the `state <label>` prefix. Throws when the read fails. */
|
|
69
|
+
export declare function sourceRows(source: StateSource, io: StateIo): Promise<string[]>;
|
|
70
|
+
/**
|
|
71
|
+
* Every source, read and turned into sorted `state <label> <row>` lines, in declared order. A read
|
|
72
|
+
* that fails is one `state read failed: <label>: <reason>` line. A source with more than
|
|
73
|
+
* `cap` rows (`STATE_ROW_CAP` by default) keeps the first rows in sorted order, then a `state rows cut:` line. That line
|
|
74
|
+
* holds a digest of the rows it cuts, so a change to a cut row still makes the reading differ.
|
|
75
|
+
*/
|
|
76
|
+
export declare function readStateSources(sources: readonly StateSource[], io: StateIo, cap?: number): Promise<string[]>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declared state sources: the data an app keeps, read as lines that `reticle compare` diffs like a
|
|
3
|
+
* page read. A page read sees only the pages a sequence opens. A change to another record (the
|
|
4
|
+
* price of another product) was not seen, and the candidate passed.
|
|
5
|
+
*
|
|
6
|
+
* The user declares each source as data. Nothing here knows one app:
|
|
7
|
+
* - `command`: the output rows of a shell command on the host, or in `container` through
|
|
8
|
+
* `docker exec <container> sh -c`. A database dump goes through the database's own client.
|
|
9
|
+
* - `http`: the records of a JSON endpoint. `path` is a dotted path to the array.
|
|
10
|
+
* - `file`: a file of JSON records (`at` is a dotted path to the array), or of plain lines. With
|
|
11
|
+
* `container`, it is read in that container.
|
|
12
|
+
*
|
|
13
|
+
* `ignore` holds field names to remove from JSON records, or regular expressions to remove from text
|
|
14
|
+
* rows (a time, an id that changes on each run). Each row becomes `state <label> <row>`, sorted. A
|
|
15
|
+
* row that identifies a record starts with the record id, so a changed row names its record.
|
|
16
|
+
*
|
|
17
|
+
* I/O is injected (`StateIo`), so this file is pure.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
export const StateSourceKind = {
|
|
22
|
+
COMMAND: 'command',
|
|
23
|
+
HTTP: 'http',
|
|
24
|
+
FILE: 'file',
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Rows kept for each source. More are cut, and a line declares the cut and a digest of the cut rows.
|
|
28
|
+
*
|
|
29
|
+
* ponytail: the line diff is an O(n*m) table, so a source of thousands of rows makes each compared
|
|
30
|
+
* sequence slow. Raise this only with a faster diff.
|
|
31
|
+
*/
|
|
32
|
+
export const STATE_ROW_CAP = 1000;
|
|
33
|
+
export const STATE_PREFIX = 'state';
|
|
34
|
+
export const MSG_READ_FAILED = 'state read failed:';
|
|
35
|
+
export const MSG_ROWS_CUT = 'state rows cut:';
|
|
36
|
+
const DIGEST_ALGORITHM = 'sha256';
|
|
37
|
+
const DIGEST_LENGTH = 16;
|
|
38
|
+
/** A short digest of the rows past the cap, so a change to one of them changes the cut line. */
|
|
39
|
+
const digestOf = (rows) => createHash(DIGEST_ALGORITHM).update(rows.join('\n')).digest('hex').slice(0, DIGEST_LENGTH);
|
|
40
|
+
const PATH_SEPARATOR = '.';
|
|
41
|
+
const LINE_BREAK = /\r?\n/;
|
|
42
|
+
const ANY_LINE_BREAK = /\s*\r?\n\s*/g;
|
|
43
|
+
const label = z.string().min(1).describe('The name each row of this source starts with.');
|
|
44
|
+
const ignore = z.array(z.string()).optional();
|
|
45
|
+
const container = z.string().min(1).optional();
|
|
46
|
+
export const StateSourceSchema = z.discriminatedUnion('kind', [
|
|
47
|
+
z.strictObject({
|
|
48
|
+
kind: z.literal(StateSourceKind.COMMAND),
|
|
49
|
+
label,
|
|
50
|
+
command: z.string().min(1),
|
|
51
|
+
container,
|
|
52
|
+
ignore,
|
|
53
|
+
}),
|
|
54
|
+
z.strictObject({
|
|
55
|
+
kind: z.literal(StateSourceKind.HTTP),
|
|
56
|
+
label,
|
|
57
|
+
url: z.string().min(1),
|
|
58
|
+
path: z.string().optional(),
|
|
59
|
+
ignore,
|
|
60
|
+
}),
|
|
61
|
+
z.strictObject({
|
|
62
|
+
kind: z.literal(StateSourceKind.FILE),
|
|
63
|
+
label,
|
|
64
|
+
path: z.string().min(1),
|
|
65
|
+
at: z.string().optional(),
|
|
66
|
+
container,
|
|
67
|
+
ignore,
|
|
68
|
+
}),
|
|
69
|
+
]);
|
|
70
|
+
/** The `--state` file. */
|
|
71
|
+
export const StateDeclarationSchema = z.strictObject({
|
|
72
|
+
sources: z.array(StateSourceSchema).min(1),
|
|
73
|
+
});
|
|
74
|
+
const isRecord = (value) => value !== null && 'object' === typeof value && !Array.isArray(value);
|
|
75
|
+
const sortKeys = (value) => {
|
|
76
|
+
if (Array.isArray(value)) {
|
|
77
|
+
return value.map(sortKeys);
|
|
78
|
+
}
|
|
79
|
+
if (isRecord(value)) {
|
|
80
|
+
return Object.fromEntries(Object.keys(value)
|
|
81
|
+
.sort()
|
|
82
|
+
.map((k) => [k, sortKeys(value[k])]));
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
};
|
|
86
|
+
const at = (value, path) => path === undefined
|
|
87
|
+
? value
|
|
88
|
+
: path
|
|
89
|
+
.split(PATH_SEPARATOR)
|
|
90
|
+
.reduce((v, key) => (isRecord(v) ? v[key] : undefined), value);
|
|
91
|
+
function recordRows(value, dropped = []) {
|
|
92
|
+
const records = Array.isArray(value) ? value : [value];
|
|
93
|
+
return records.map((record) => {
|
|
94
|
+
if (!isRecord(record)) {
|
|
95
|
+
return JSON.stringify(record);
|
|
96
|
+
}
|
|
97
|
+
const kept = Object.fromEntries(Object.entries(record).filter(([k]) => !dropped.includes(k)));
|
|
98
|
+
return JSON.stringify(sortKeys(kept));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function textRows(text, patterns = []) {
|
|
102
|
+
const res = patterns.map((p) => new RegExp(p, 'g'));
|
|
103
|
+
return (text
|
|
104
|
+
.split(LINE_BREAK)
|
|
105
|
+
.filter((line) => '' !== line.trim())
|
|
106
|
+
.map((line) => res.reduce((row, re) => row.replace(re, ''), line))
|
|
107
|
+
// A pattern that removes all of a row removes the row, so a row of no meaning can be ignored.
|
|
108
|
+
.filter((row) => '' !== row.trim()));
|
|
109
|
+
}
|
|
110
|
+
/** A shell word that holds `text` as it is. */
|
|
111
|
+
const shellQuote = (text) => `'${text.replaceAll("'", "'\\''")}'`;
|
|
112
|
+
/** JSON records when the text is JSON, else plain lines. */
|
|
113
|
+
function fileRows(text, path, patterns) {
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(text);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return textRows(text, patterns);
|
|
120
|
+
}
|
|
121
|
+
return recordRows(at(parsed, path), patterns);
|
|
122
|
+
}
|
|
123
|
+
/** The rows of one source, unsorted and without the `state <label>` prefix. Throws when the read fails. */
|
|
124
|
+
export async function sourceRows(source, io) {
|
|
125
|
+
switch (source.kind) {
|
|
126
|
+
case StateSourceKind.COMMAND:
|
|
127
|
+
return textRows(await io.run(source.command, source.container), source.ignore);
|
|
128
|
+
case StateSourceKind.HTTP:
|
|
129
|
+
return recordRows(at(await io.fetchJson(source.url), source.path), source.ignore);
|
|
130
|
+
case StateSourceKind.FILE: {
|
|
131
|
+
const text = source.container === undefined
|
|
132
|
+
? await io.readFile(source.path)
|
|
133
|
+
: await io.run(`cat ${shellQuote(source.path)}`, source.container);
|
|
134
|
+
return fileRows(text, source.at, source.ignore);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const reasonOf = (error) => (error instanceof Error ? error.message : String(error)).trim().replace(ANY_LINE_BREAK, ' ');
|
|
139
|
+
/**
|
|
140
|
+
* Every source, read and turned into sorted `state <label> <row>` lines, in declared order. A read
|
|
141
|
+
* that fails is one `state read failed: <label>: <reason>` line. A source with more than
|
|
142
|
+
* `cap` rows (`STATE_ROW_CAP` by default) keeps the first rows in sorted order, then a `state rows cut:` line. That line
|
|
143
|
+
* holds a digest of the rows it cuts, so a change to a cut row still makes the reading differ.
|
|
144
|
+
*/
|
|
145
|
+
export async function readStateSources(sources, io, cap = STATE_ROW_CAP) {
|
|
146
|
+
const out = [];
|
|
147
|
+
for (const source of sources) {
|
|
148
|
+
let rows;
|
|
149
|
+
try {
|
|
150
|
+
rows = (await sourceRows(source, io)).sort();
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
out.push(`${MSG_READ_FAILED} ${source.label}: ${reasonOf(error)}`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
for (const row of rows.slice(0, cap)) {
|
|
157
|
+
out.push(`${STATE_PREFIX} ${source.label} ${row}`);
|
|
158
|
+
}
|
|
159
|
+
if (rows.length > cap) {
|
|
160
|
+
out.push(`${MSG_ROWS_CUT} ${source.label}: kept ${String(cap)} of ${String(rows.length)}, rest ${digestOf(rows.slice(cap))}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|