@reticlehq/server 3.3.0 → 3.4.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/LICENSE-ENTERPRISE +1 -1
- package/README.md +3 -3
- package/SKILL.md +12 -14
- package/dist/chromium-launch-options.d.ts +4 -3
- package/dist/chromium-launch-options.js +6 -2
- package/dist/command/cli/adhoc-suite.d.ts +22 -0
- package/dist/command/cli/adhoc-suite.js +90 -19
- package/dist/command/cli/adhoc-verdict.d.ts +35 -0
- package/dist/command/cli/adhoc-verdict.js +90 -3
- package/dist/command/cli/answers/gate-hook-message.d.ts +6 -0
- package/dist/command/cli/answers/gate-hook-message.js +6 -0
- package/dist/command/cli/auth/cloud-keystore.d.ts +0 -2
- package/dist/command/cli/auth/cloud-keystore.js +0 -3
- package/dist/command/cli/auth/cloud-session.d.ts +4 -0
- package/dist/command/cli/auth/cloud-session.js +2 -0
- package/dist/command/cli/cli-doctor.js +7 -5
- package/dist/command/cli/cli-flow-commands.d.ts +3 -6
- package/dist/command/cli/cli-flow-commands.js +67 -4
- package/dist/command/cli/cli-parse.d.ts +8 -1
- package/dist/command/cli/cli-parse.js +21 -3
- package/dist/command/cli/cli-update-commands.js +8 -1
- package/dist/command/cli/cli-verify.d.ts +24 -3
- package/dist/command/cli/cli-verify.js +54 -11
- package/dist/command/cli/cloud-cli.js +5 -2
- package/dist/command/cli/cloud-kit.js +3 -5
- package/dist/command/cli/cloud-login.js +21 -3
- package/dist/command/cli/demo/demo-page.js +6 -3
- package/dist/command/cli/doctor/browser/chromium-hint.d.ts +5 -0
- package/dist/command/cli/doctor/browser/chromium-hint.js +5 -0
- package/dist/command/cli/launch/cli-launch.d.ts +8 -0
- package/dist/command/cli/launch/cli-launch.js +21 -9
- package/dist/command/cli/launch/sdk-version-match.d.ts +84 -0
- package/dist/command/cli/launch/sdk-version-match.js +206 -0
- package/dist/command/cli/lifecycle/daemon-lifecycle.js +2 -16
- package/dist/command/cli/ports/resolve/cli-port.d.ts +25 -0
- package/dist/command/cli/ports/resolve/cli-port.js +125 -15
- package/dist/command/cli/setup-mcp-cli.js +13 -13
- package/dist/command/cli/tutorial.d.ts +2 -2
- package/dist/command/cli/tutorial.js +21 -7
- package/dist/command/cli.js +62 -10
- package/dist/command/daemon/binding/daemon-status-probe.d.ts +14 -2
- package/dist/command/daemon/binding/daemon-status-probe.js +34 -6
- package/dist/command/daemon/daemon-resolve.d.ts +9 -0
- package/dist/command/daemon/daemon-resolve.js +1 -1
- package/dist/command/daemon/lifetime/daemon-usefulness.d.ts +7 -1
- package/dist/command/daemon/lifetime/daemon-usefulness.js +30 -7
- package/dist/command/dev/stale-issue-guard.d.ts +3 -4
- package/dist/command/dev/stale-issue-guard.js +8 -8
- package/dist/command/setup/bringup/connect-lease.d.ts +17 -0
- package/dist/command/setup/bringup/connect-lease.js +38 -0
- package/dist/command/setup/bringup/dev-server-files.d.ts +68 -0
- package/dist/command/setup/bringup/dev-server-files.js +131 -0
- package/dist/command/setup/bringup/dev-server-log-cap.d.ts +35 -0
- package/dist/command/setup/bringup/dev-server-log-cap.js +121 -0
- package/dist/command/setup/bringup/ensure-daemon.d.ts +65 -4
- package/dist/command/setup/bringup/ensure-daemon.js +135 -16
- package/dist/command/setup/bringup/static-page-server.d.ts +35 -0
- package/dist/command/setup/bringup/static-page-server.js +151 -0
- package/dist/command/setup/desktop-shape.d.ts +4 -0
- package/dist/command/setup/desktop-shape.js +18 -2
- package/dist/command/setup/init/init-port.d.ts +15 -0
- package/dist/command/setup/init/init-port.js +44 -0
- package/dist/command/setup/init/init-runtime.d.ts +1 -1
- package/dist/command/setup/init/init-runtime.js +39 -2
- package/dist/command/setup/node-effects.d.ts +27 -14
- package/dist/command/setup/node-effects.js +80 -44
- package/dist/command/setup/probe/loopback-probe-urls.d.ts +7 -0
- package/dist/command/setup/probe/loopback-probe-urls.js +40 -0
- package/dist/command/setup/probe/page-probe.d.ts +13 -2
- package/dist/command/setup/probe/page-probe.js +18 -3
- package/dist/command/setup/run-setup.d.ts +26 -1
- package/dist/command/setup/run-setup.js +96 -13
- package/dist/command/setup/session-pick.d.ts +13 -0
- package/dist/command/setup/session-pick.js +20 -0
- package/dist/command/setup/setup-command.d.ts +2 -14
- package/dist/command/setup/setup-command.js +105 -31
- package/dist/command/setup/setup-mcp.js +3 -3
- package/dist/command/setup/terminal/wait-progress.d.ts +8 -0
- package/dist/command/setup/terminal/wait-progress.js +17 -0
- package/dist/features/crawl/crawl-empty.js +1 -1
- package/dist/features/crawl/crawl-tools.d.ts +6 -0
- package/dist/features/crawl/crawl-tools.js +57 -0
- package/dist/features/crawl/crawl.js +171 -157
- package/dist/features/exhaust/app-coverage.d.ts +33 -0
- package/dist/features/exhaust/app-coverage.js +31 -0
- package/dist/features/exhaust/code-coverage.d.ts +47 -0
- package/dist/features/exhaust/code-coverage.js +77 -0
- package/dist/features/exhaust/exhaust-run.d.ts +33 -0
- package/dist/features/exhaust/exhaust-run.js +82 -0
- package/dist/features/exhaust/explorer.d.ts +103 -0
- package/dist/features/exhaust/explorer.js +195 -0
- package/dist/features/exhaust/failure-branches.d.ts +33 -0
- package/dist/features/exhaust/failure-branches.js +92 -0
- package/dist/features/exhaust/ledger.d.ts +202 -0
- package/dist/features/exhaust/ledger.js +252 -0
- package/dist/features/exhaust/session-fold.d.ts +31 -0
- package/dist/features/exhaust/session-fold.js +79 -0
- package/dist/features/exhaust/session-port.d.ts +36 -0
- package/dist/features/exhaust/session-port.js +102 -0
- package/dist/features/harness/drive-report.d.ts +0 -9
- package/dist/features/harness/drive-report.js +18 -0
- package/dist/features/harness/driver.js +4 -7
- package/dist/features/harness/fill-values.d.ts +6 -0
- package/dist/features/harness/fill-values.js +3 -0
- package/dist/features/harness/jev-driver.js +30 -13
- package/dist/features/harness/openai-driver.js +4 -7
- package/dist/features/harness/platform-config.js +5 -6
- package/dist/index.d.ts +9 -0
- package/dist/index.js +40 -11
- package/dist/judgement/domain/domain-model.js +3 -1
- package/dist/judgement/oracles/self-instrument.js +4 -2
- package/dist/judgement/runs/artifact/replay-mapping.d.ts +2 -2
- package/dist/judgement/runs/artifact/replay-mapping.js +44 -9
- package/dist/judgement/runs/verification-sync.d.ts +3 -1
- package/dist/judgement/runs/verification-sync.js +1 -1
- package/dist/language/flows/change/affected.d.ts +9 -0
- package/dist/language/flows/change/affected.js +35 -0
- package/dist/language/flows/change/gate.d.ts +20 -0
- package/dist/language/flows/change/gate.js +12 -1
- package/dist/language/flows/flow-author.d.ts +7 -0
- package/dist/language/flows/flow-author.js +19 -0
- package/dist/language/flows/flow-expect-grammar.js +1 -1
- package/dist/language/flows/flow-journey.d.ts +54 -0
- package/dist/language/flows/flow-journey.js +119 -0
- package/dist/language/flows/flow-replay-run.d.ts +2 -3
- package/dist/language/flows/flow-replay-run.js +13 -47
- package/dist/language/flows/flow-replay.d.ts +22 -8
- package/dist/language/flows/flow-replay.js +55 -9
- package/dist/language/flows/flow-result.d.ts +2 -0
- package/dist/language/flows/flow-result.js +25 -0
- package/dist/language/flows/flow-tools.js +17 -5
- package/dist/language/flows/flows.d.ts +10 -1
- package/dist/language/flows/flows.js +90 -14
- package/dist/language/flows/recording/replay-halt.js +3 -1
- package/dist/language/flows/recording/tape/recordings.d.ts +30 -1
- package/dist/language/flows/recording/tape/recordings.js +55 -1
- package/dist/language/flows/replay.d.ts +19 -0
- package/dist/language/flows/replay.js +42 -6
- package/dist/language/flows/verify-change-tools.js +5 -2
- package/dist/launch-chromium.d.ts +80 -0
- package/dist/launch-chromium.js +218 -0
- package/dist/memory/cloud/cloud-config.d.ts +11 -2
- package/dist/memory/cloud/cloud-config.js +47 -7
- package/dist/memory/cloud/cloud-sync.d.ts +4 -1
- package/dist/memory/cloud/cloud-sync.js +6 -9
- package/dist/memory/cloud/harness-offer.js +5 -6
- package/dist/memory/cloud/harness-switch.js +5 -6
- package/dist/memory/cloud/sync-cycle.d.ts +68 -3
- package/dist/memory/cloud/sync-cycle.js +298 -55
- package/dist/memory/cloud/sync-daemon.js +24 -20
- package/dist/memory/cloud/sync-disk.js +16 -2
- package/dist/memory/intent/intent-shard.d.ts +10 -32
- package/dist/memory/intent/intent-shard.js +0 -10
- package/dist/memory/journal/drive-flow.d.ts +18 -13
- package/dist/memory/journal/drive-flow.js +45 -37
- package/dist/memory/journal/on-disk/workspace-tiers.js +2 -0
- package/dist/memory/journal/session-end.d.ts +9 -1
- package/dist/memory/journal/session-end.js +24 -12
- package/dist/memory/project/dir/reticle-dir.d.ts +2 -0
- package/dist/memory/project/dir/reticle-dir.js +1 -0
- package/dist/memory/project/project-tools.js +3 -1
- package/dist/portal/bridge/auth-failure-reason.d.ts +1 -1
- package/dist/portal/bridge/auth-failure-reason.js +4 -2
- package/dist/portal/bridge/bridge.d.ts +8 -0
- package/dist/portal/bridge/bridge.js +43 -2
- package/dist/portal/input/js-coverage.d.ts +25 -0
- package/dist/portal/input/js-coverage.js +30 -0
- package/dist/portal/input/real-input.d.ts +11 -0
- package/dist/portal/input/real-input.js +29 -4
- package/dist/portal/pool/playwright-launcher.d.ts +6 -0
- package/dist/portal/pool/playwright-launcher.js +20 -13
- package/dist/portal/session/dev-server/dev-command.d.ts +2 -2
- package/dist/portal/session/dev-server/dev-command.js +30 -12
- package/dist/portal/session/dev-server/dev-server-probe.js +17 -1
- package/dist/portal/session/dev-server/route-status-probe.d.ts +24 -0
- package/dist/portal/session/dev-server/route-status-probe.js +57 -0
- package/dist/portal/session/facts/observed-state.d.ts +11 -0
- package/dist/portal/session/facts/observed-state.js +34 -0
- package/dist/portal/session/no-session-diagnosis.d.ts +29 -0
- package/dist/portal/session/no-session-diagnosis.js +62 -5
- package/dist/portal/session/no-session-next-action.d.ts +13 -0
- package/dist/portal/session/no-session-next-action.js +48 -9
- package/dist/portal/session/no-session-watch.d.ts +18 -0
- package/dist/portal/session/no-session-watch.js +88 -2
- package/dist/portal/session/session-health.js +3 -2
- package/dist/portal/session/session-manager.d.ts +20 -6
- package/dist/portal/session/session-manager.js +19 -3
- package/dist/portal/session/session-reaper.d.ts +28 -0
- package/dist/portal/session/session-reaper.js +49 -1
- package/dist/portal/session/session.d.ts +5 -0
- package/dist/portal/session/session.js +15 -19
- package/dist/surface/http-server.d.ts +2 -1
- package/dist/surface/http-server.js +4 -2
- package/dist/surface/mcp/mcp-proxy.d.ts +2 -2
- package/dist/surface/mcp/mcp-proxy.js +2 -2
- package/dist/surface/mcp/mcp.js +4 -2
- package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +13 -1
- package/dist/surface/mcp/proxy/proxy-daemon-probe.js +27 -2
- package/dist/surface/mcp/server-instructions.js +9 -7
- package/dist/surface/tools/act/after-match.d.ts +17 -0
- package/dist/surface/tools/act/after-match.js +30 -0
- package/dist/surface/tools/act/durable.d.ts +69 -0
- package/dist/surface/tools/act/durable.js +139 -0
- package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +13 -6
- package/dist/surface/tools/act/navigation/navigate-arrival.js +47 -10
- package/dist/surface/tools/act/navigation/navigate-result.d.ts +1 -12
- package/dist/surface/tools/act/navigation/navigate-result.js +21 -3
- package/dist/surface/tools/act/proved-controls.d.ts +9 -0
- package/dist/surface/tools/act/proved-controls.js +11 -0
- package/dist/surface/tools/act/sequence-grade.d.ts +5 -0
- package/dist/surface/tools/act/sequence-grade.js +32 -1
- package/dist/surface/tools/act-capsule.d.ts +7 -0
- package/dist/surface/tools/act-capsule.js +46 -1
- package/dist/surface/tools/act-sequence-tool.js +25 -8
- package/dist/surface/tools/act-tools.js +26 -50
- package/dist/surface/tools/assert/assert-grade.js +12 -1
- package/dist/surface/tools/assert/assert-verdict.js +2 -2
- package/dist/surface/tools/browser-tools.js +14 -2
- package/dist/surface/tools/coverage-tools.js +27 -1
- package/dist/surface/tools/error-recovery.js +4 -4
- package/dist/surface/tools/feedback-invite.js +6 -4
- package/dist/surface/tools/harness-explore.d.ts +3 -2
- package/dist/surface/tools/harness-explore.js +7 -6
- package/dist/surface/tools/lease-tools.js +15 -6
- package/dist/surface/tools/live-call-text.js +4 -1
- package/dist/surface/tools/observe-tools.js +9 -1
- package/dist/surface/tools/tools.js +4 -0
- package/dist/telemetry/argument-shape.d.ts +0 -2
- package/dist/telemetry/argument-shape.js +0 -4
- package/dist/telemetry/error-class.d.ts +2 -3
- package/dist/telemetry/error-class.js +2 -3
- package/dist/telemetry/harness-drive.d.ts +0 -2
- package/dist/telemetry/harness-drive.js +0 -4
- package/dist/wire-journal.d.ts +3 -0
- package/dist/wire-journal.js +4 -0
- package/dist/workspace-packages.d.ts +0 -2
- package/dist/workspace-packages.js +1 -9
- package/docs/README.md +4 -2
- package/docs/actions.mdx +3 -3
- package/docs/agent-cheatsheet.md +4 -4
- package/docs/architecture.md +6 -6
- package/docs/best-practices.mdx +1 -1
- package/docs/capabilities.mdx +32 -1
- package/docs/cli/cloud.mdx +14 -4
- package/docs/cli/doctor.mdx +4 -0
- package/docs/cli/feedback.mdx +2 -2
- package/docs/cli/gate.mdx +9 -4
- package/docs/cli/update.mdx +10 -0
- package/docs/cli.mdx +9 -0
- package/docs/desktop.mdx +14 -10
- package/docs/environment.mdx +87 -0
- package/docs/faq.mdx +18 -16
- package/docs/first-drive.md +5 -13
- package/docs/flows.md +21 -2
- package/docs/frameworks.mdx +29 -10
- package/docs/getting-started.md +3 -3
- package/docs/http-transport.md +6 -3
- package/docs/human-control.md +2 -2
- package/docs/index.mdx +16 -6
- package/docs/install-agentic.mdx +1 -1
- package/docs/install-manual.mdx +1 -1
- package/docs/integration-patterns.md +3 -3
- package/docs/multi-agent-testing.md +1 -1
- package/docs/packages/babel-plugin.mdx +1 -1
- package/docs/packages/browser.mdx +2 -2
- package/docs/packages/react.mdx +2 -2
- package/docs/packages/tauri.mdx +1 -1
- package/docs/packages/vite-plugin.mdx +2 -2
- package/docs/packages.mdx +9 -9
- package/docs/predicates.mdx +50 -5
- package/docs/quickstart.mdx +2 -2
- package/docs/recipes.mdx +2 -2
- package/docs/state-management.mdx +2 -2
- package/docs/system-map.md +1 -1
- package/docs/telemetry.md +1 -1
- package/docs/token-efficiency.md +3 -3
- package/docs/tools/act-and-wait.mdx +1 -0
- package/docs/tools/act-sequence.mdx +1 -1
- package/docs/tools/console.mdx +1 -1
- package/docs/tools/inspect.mdx +1 -1
- package/docs/tools/network.mdx +1 -1
- package/docs/tools/query.mdx +1 -1
- package/docs/tools/sessions.mdx +1 -1
- package/docs/tools/snapshot.mdx +1 -1
- package/docs/tools/state.mdx +1 -1
- package/docs/tools/wait-for.mdx +1 -1
- package/docs/usage.md +7 -6
- package/docs/use-cases.mdx +157 -0
- package/docs/vs-screenshots.mdx +3 -3
- package/docs/what-is-recorded.md +2 -2
- package/docs/what-is-reticle.mdx +17 -20
- package/docs/why-reticle.mdx +14 -21
- package/package.json +10 -9
- package/dist/judgement/capsule/minimize.d.ts +0 -12
- package/dist/judgement/capsule/minimize.js +0 -22
- package/dist/judgement/phenomena/phenomena.d.ts +0 -27
- package/dist/judgement/phenomena/phenomena.js +0 -101
- package/dist/language/flows/flow-report.d.ts +0 -22
- package/dist/language/flows/flow-report.js +0 -106
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reticle_crawl { exhaustive: true }` — explore every reachable state, drive every write's failure
|
|
3
|
+
* path, take the code coverage, fold all of it into the ledger, and say what is left.
|
|
4
|
+
*
|
|
5
|
+
* It answers in crawl's own shape, so every caller that reads a crawl keeps working, and adds one
|
|
6
|
+
* `exhaustive` block with what only this mode knows.
|
|
7
|
+
*/
|
|
8
|
+
import { emptyCodeCoverage, foldCodeCoverage } from './code-coverage.js';
|
|
9
|
+
import { explore } from './explorer.js';
|
|
10
|
+
import { driveFailureBranches } from './failure-branches.js';
|
|
11
|
+
import { LedgerStore, levelsOf, regressions } from './ledger.js';
|
|
12
|
+
import { sessionPort } from './session-port.js';
|
|
13
|
+
/** Default action budget: generous, because every edge replays its path from a fresh load. */
|
|
14
|
+
export const EXHAUST_DEFAULT_ACTIONS = 400;
|
|
15
|
+
/** The share of the budget kept back for failure paths, so exploring cannot starve them. */
|
|
16
|
+
const FAILURE_SHARE = 0.25;
|
|
17
|
+
export async function runExhaustive(input) {
|
|
18
|
+
const port = sessionPort({
|
|
19
|
+
sessions: input.sessions,
|
|
20
|
+
start: input.session,
|
|
21
|
+
startUrl: input.startUrl,
|
|
22
|
+
settleMs: input.settleMs,
|
|
23
|
+
now: input.now,
|
|
24
|
+
sleep: input.sleep,
|
|
25
|
+
...(input.setMocks === undefined ? {} : { setMocks: input.setMocks }),
|
|
26
|
+
});
|
|
27
|
+
const failureBudget = Math.floor(input.maxActions * FAILURE_SHARE);
|
|
28
|
+
const report = await explore(port, {
|
|
29
|
+
maxActions: input.maxActions - failureBudget,
|
|
30
|
+
fillValue: (label) => input.fillValue(label, port.current().url),
|
|
31
|
+
});
|
|
32
|
+
const branches = await driveFailureBranches(port, report.writes, { maxActions: failureBudget });
|
|
33
|
+
const take = await input.takeCode?.(port.current().url);
|
|
34
|
+
const ledger = await new LedgerStore(input.fs, input.reticleRoot).merge({
|
|
35
|
+
routes: { reached: report.routes },
|
|
36
|
+
controls: { seen: report.seen, touched: report.touched },
|
|
37
|
+
writes: {
|
|
38
|
+
seen: report.writes.map((w) => w.key),
|
|
39
|
+
branched: branches.branched,
|
|
40
|
+
unhandled: branches.unhandled.map((u) => u.key),
|
|
41
|
+
},
|
|
42
|
+
...(take === undefined
|
|
43
|
+
? {}
|
|
44
|
+
: { code: foldCodeCoverage(emptyCodeCoverage(), take), codeTakenAt: input.now() }),
|
|
45
|
+
});
|
|
46
|
+
const anomalies = [
|
|
47
|
+
...report.anomalies.map((a) => ({ kind: a.kind, ref: '', desc: a.control, detail: a.detail })),
|
|
48
|
+
...branches.unhandled.map((u) => ({
|
|
49
|
+
kind: 'failure-unhandled',
|
|
50
|
+
ref: '',
|
|
51
|
+
desc: u.key,
|
|
52
|
+
detail: u.detail,
|
|
53
|
+
})),
|
|
54
|
+
];
|
|
55
|
+
const counts = {};
|
|
56
|
+
for (const a of anomalies)
|
|
57
|
+
counts[a.kind] = (counts[a.kind] ?? 0) + 1;
|
|
58
|
+
return {
|
|
59
|
+
interactiveFound: report.seen.length,
|
|
60
|
+
stepsRun: report.actions,
|
|
61
|
+
anomalies,
|
|
62
|
+
counts,
|
|
63
|
+
visited: report.touched,
|
|
64
|
+
truncated: report.frontier > 0 || report.budgetExhausted,
|
|
65
|
+
exhaustive: {
|
|
66
|
+
states: report.states,
|
|
67
|
+
routes: report.routes,
|
|
68
|
+
frontier: report.frontier,
|
|
69
|
+
unreachable: report.unreachable,
|
|
70
|
+
budgetExhausted: report.budgetExhausted,
|
|
71
|
+
writes: report.writes.map((w) => w.key),
|
|
72
|
+
failureBranches: branches,
|
|
73
|
+
levels: levelsOf(ledger),
|
|
74
|
+
regressed: regressions(ledger),
|
|
75
|
+
...(take === undefined
|
|
76
|
+
? {
|
|
77
|
+
codeNote: 'code coverage needs a browser Reticle drives (`reticle drive`, or RETICLE_CDP_URL)',
|
|
78
|
+
}
|
|
79
|
+
: {}),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The systematic explorer: every state the app can reach from its start page, breadth first.
|
|
3
|
+
*
|
|
4
|
+
* The crawl clicks what is on one page; the model-driven harness clicks what a model finds
|
|
5
|
+
* interesting. Neither can promise it did not skip something, and "don't skip anything" is the
|
|
6
|
+
* requirement. This walks the app's STATE GRAPH instead:
|
|
7
|
+
*
|
|
8
|
+
* - A state is a route plus the set of control names on it. An edge is one action. Values and
|
|
9
|
+
* counts are left out on purpose (every keystroke, every added row would be a new state), so
|
|
10
|
+
* states that differ only there are one state — see `frontier` for what that costs the report.
|
|
11
|
+
* - Every state is reached by a recorded PATH from a fresh start page, so returning to an
|
|
12
|
+
* unexplored state is a replay, never a hope that "back" works.
|
|
13
|
+
* - Controls that differ only by a number — the rows of a table — are one control. It is covered
|
|
14
|
+
* once, which is what keeps a 500-row page from eating the whole budget.
|
|
15
|
+
* - Text fields on a state are filled before its buttons are tried, so a submit is driven with
|
|
16
|
+
* input rather than dismissed as dead because it refused an empty form.
|
|
17
|
+
*
|
|
18
|
+
* ponytail: breadth-first with a flat action budget, and every edge replays its path from a fresh
|
|
19
|
+
* load. That is O(states × path length), correct and slow; add prefix sharing if it proves too slow.
|
|
20
|
+
* A control that needs a choice (select, combobox) is not driven — it needs a value model.
|
|
21
|
+
*/
|
|
22
|
+
import { type ReticleEvent } from '@reticlehq/core';
|
|
23
|
+
/** What the explorer needs from a driven page. The tool layer adapts a session to it. */
|
|
24
|
+
export interface ExplorePort {
|
|
25
|
+
/** Load the start page fresh. False when it did not come back. */
|
|
26
|
+
reset(): Promise<boolean>;
|
|
27
|
+
/** The interactive tree and route of the page now. */
|
|
28
|
+
look(): Promise<{
|
|
29
|
+
tree: string;
|
|
30
|
+
route?: string;
|
|
31
|
+
}>;
|
|
32
|
+
/** Act on a control; the events are the ones this action caused. */
|
|
33
|
+
act(ref: string, action: 'click' | 'fill', value?: string): Promise<{
|
|
34
|
+
ok: boolean;
|
|
35
|
+
events: readonly ReticleEvent[];
|
|
36
|
+
}>;
|
|
37
|
+
/** Install network mocks ([] clears). Absent, or false, when no driven browser can. */
|
|
38
|
+
mock?: (rules: {
|
|
39
|
+
urlContains: string;
|
|
40
|
+
method?: string;
|
|
41
|
+
abort?: boolean;
|
|
42
|
+
}[]) => Promise<boolean>;
|
|
43
|
+
}
|
|
44
|
+
export interface Step {
|
|
45
|
+
key: string;
|
|
46
|
+
action: 'click' | 'fill';
|
|
47
|
+
value?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface ExploredWrite {
|
|
50
|
+
key: string;
|
|
51
|
+
method: string;
|
|
52
|
+
/** A concrete path this write was seen at, for a mock to match. */
|
|
53
|
+
urlPath: string;
|
|
54
|
+
/** How to trigger it from a fresh start page: the last step is the action that fired it. */
|
|
55
|
+
path: Step[];
|
|
56
|
+
/** The state the write was fired from. */
|
|
57
|
+
beforeState: string;
|
|
58
|
+
/** The state the app reached when this write SUCCEEDED — what a failure must not look like. */
|
|
59
|
+
successState: string;
|
|
60
|
+
}
|
|
61
|
+
export interface ExploreReport {
|
|
62
|
+
states: number;
|
|
63
|
+
actions: number;
|
|
64
|
+
routes: string[];
|
|
65
|
+
seen: string[];
|
|
66
|
+
touched: string[];
|
|
67
|
+
writes: ExploredWrite[];
|
|
68
|
+
anomalies: {
|
|
69
|
+
kind: string;
|
|
70
|
+
control: string;
|
|
71
|
+
detail: string;
|
|
72
|
+
}[];
|
|
73
|
+
/**
|
|
74
|
+
* States found and never explored. Zero means every state the explorer could TELL APART was
|
|
75
|
+
* explored, not that every reachable state was: identity is the route plus the set of control
|
|
76
|
+
* names, so two states that differ only in field values, in how many of a control there are, or
|
|
77
|
+
* in behaviour behind the same controls are one state here and the second is never visited.
|
|
78
|
+
*/
|
|
79
|
+
frontier: number;
|
|
80
|
+
/** States whose recorded path no longer replays — the app is not deterministic from its start. */
|
|
81
|
+
unreachable: number;
|
|
82
|
+
budgetExhausted: boolean;
|
|
83
|
+
}
|
|
84
|
+
interface Control {
|
|
85
|
+
ref: string;
|
|
86
|
+
key: string;
|
|
87
|
+
/** The field already holds a value — pre-filled credentials, a default. Typing over it breaks it. */
|
|
88
|
+
holdsValue: boolean;
|
|
89
|
+
}
|
|
90
|
+
export declare function controlsOf(tree: string): Control[];
|
|
91
|
+
export declare function stateKey(route: string | undefined, controls: readonly Control[]): string;
|
|
92
|
+
export declare function explore(port: ExplorePort, opts: {
|
|
93
|
+
maxActions: number;
|
|
94
|
+
fillValue: (label: string) => string;
|
|
95
|
+
}): Promise<ExploreReport>;
|
|
96
|
+
/**
|
|
97
|
+
* A fresh start page, then the path. False when a step's control is gone or refused. Every state
|
|
98
|
+
* is reached this way, which is what makes returning to one a replay rather than a hope.
|
|
99
|
+
*/
|
|
100
|
+
export declare function replayPath(port: ExplorePort, path: readonly Step[], act: (ref: string, action: 'click' | 'fill', value?: string) => Promise<{
|
|
101
|
+
ok: boolean;
|
|
102
|
+
}>, spent: () => boolean): Promise<boolean>;
|
|
103
|
+
export {};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The systematic explorer: every state the app can reach from its start page, breadth first.
|
|
3
|
+
*
|
|
4
|
+
* The crawl clicks what is on one page; the model-driven harness clicks what a model finds
|
|
5
|
+
* interesting. Neither can promise it did not skip something, and "don't skip anything" is the
|
|
6
|
+
* requirement. This walks the app's STATE GRAPH instead:
|
|
7
|
+
*
|
|
8
|
+
* - A state is a route plus the set of control names on it. An edge is one action. Values and
|
|
9
|
+
* counts are left out on purpose (every keystroke, every added row would be a new state), so
|
|
10
|
+
* states that differ only there are one state — see `frontier` for what that costs the report.
|
|
11
|
+
* - Every state is reached by a recorded PATH from a fresh start page, so returning to an
|
|
12
|
+
* unexplored state is a replay, never a hope that "back" works.
|
|
13
|
+
* - Controls that differ only by a number — the rows of a table — are one control. It is covered
|
|
14
|
+
* once, which is what keeps a 500-row page from eating the whole budget.
|
|
15
|
+
* - Text fields on a state are filled before its buttons are tried, so a submit is driven with
|
|
16
|
+
* input rather than dismissed as dead because it refused an empty form.
|
|
17
|
+
*
|
|
18
|
+
* ponytail: breadth-first with a flat action budget, and every edge replays its path from a fresh
|
|
19
|
+
* load. That is O(states × path length), correct and slow; add prefix sharing if it proves too slow.
|
|
20
|
+
* A control that needs a choice (select, combobox) is not driven — it needs a value model.
|
|
21
|
+
*/
|
|
22
|
+
import { asNumber, asString, isAbsenceDerived, isAdvisory, parseInteractive, } from '@reticlehq/core';
|
|
23
|
+
import { findContradictions } from '@reticlehq/engine/disagreement/contradictions.js';
|
|
24
|
+
import { controlKey, failedRequests, isConsoleError, writeKeyOf } from './session-fold.js';
|
|
25
|
+
/** Roles that take typed input. */
|
|
26
|
+
const TYPED = /^(?:textbox|searchbox|spinbutton)\b/;
|
|
27
|
+
/** Roles that need a value model this explorer does not have, and are left alone. */
|
|
28
|
+
const CHOOSER = /^(?:combobox|listbox|option|slider)\b/;
|
|
29
|
+
const FAILED_STATUS = 400;
|
|
30
|
+
/** A route without its query: `?__reticle_opened=1` and friends are not a different page. */
|
|
31
|
+
const pathOf = (route) => route?.split('?')[0];
|
|
32
|
+
export function controlsOf(tree) {
|
|
33
|
+
return parseInteractive(tree)
|
|
34
|
+
.filter((item) => item.ref.length > 0)
|
|
35
|
+
.map((item) => ({
|
|
36
|
+
ref: item.ref,
|
|
37
|
+
key: controlKey(item.desc),
|
|
38
|
+
holdsValue: /\[value="[^"]+"/.test(item.desc),
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
export function stateKey(route, controls) {
|
|
42
|
+
return `${pathOf(route) ?? ''}|${[...new Set(controls.map((c) => c.key))].sort().join('\n')}`;
|
|
43
|
+
}
|
|
44
|
+
export async function explore(port, opts) {
|
|
45
|
+
let actions = 0;
|
|
46
|
+
let unreachable = 0;
|
|
47
|
+
const routes = new Set();
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
const touched = new Set();
|
|
50
|
+
const writes = new Map();
|
|
51
|
+
const anomalies = [];
|
|
52
|
+
const explored = new Set();
|
|
53
|
+
const queued = new Set();
|
|
54
|
+
const queue = [{ path: [] }];
|
|
55
|
+
const spent = () => actions >= opts.maxActions;
|
|
56
|
+
const act = async (ref, action, value) => {
|
|
57
|
+
actions += 1;
|
|
58
|
+
return port.act(ref, action, value);
|
|
59
|
+
};
|
|
60
|
+
const goTo = (path) => replayPath(port, path, act, spent);
|
|
61
|
+
while (queue.length > 0 && !spent()) {
|
|
62
|
+
const node = queue.shift();
|
|
63
|
+
if (node === undefined)
|
|
64
|
+
break;
|
|
65
|
+
if (!(await goTo(node.path))) {
|
|
66
|
+
if (!spent())
|
|
67
|
+
unreachable += 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const here = await port.look();
|
|
71
|
+
const controls = controlsOf(here.tree);
|
|
72
|
+
const key = stateKey(here.route, controls);
|
|
73
|
+
if (explored.has(key))
|
|
74
|
+
continue;
|
|
75
|
+
explored.add(key);
|
|
76
|
+
const where = pathOf(here.route);
|
|
77
|
+
if (where !== undefined)
|
|
78
|
+
routes.add(where);
|
|
79
|
+
for (const c of controls)
|
|
80
|
+
seen.add(c.key);
|
|
81
|
+
// Only EMPTY fields. The first drive of the fixture typed placeholders over the login form's
|
|
82
|
+
// correct pre-filled credentials, got a 401, and never left the first page.
|
|
83
|
+
const typed = controls.filter((c) => TYPED.test(c.key) && !c.holdsValue);
|
|
84
|
+
const fills = typed.map((c) => ({
|
|
85
|
+
key: c.key,
|
|
86
|
+
action: 'fill',
|
|
87
|
+
value: opts.fillValue(c.key),
|
|
88
|
+
}));
|
|
89
|
+
// Fills first, then the targets: valid input can reveal the button that moves on (a Continue,
|
|
90
|
+
// a Submit), and a list drawn before the fill would never contain it.
|
|
91
|
+
const fillHere = async () => {
|
|
92
|
+
const before = controlsOf((await port.look()).tree);
|
|
93
|
+
for (const fill of fills) {
|
|
94
|
+
const field = before.find((c) => c.key === fill.key);
|
|
95
|
+
if (field !== undefined && !spent()) {
|
|
96
|
+
await act(field.ref, 'fill', fill.value);
|
|
97
|
+
touched.add(fill.key);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return 0 < fills.length ? controlsOf((await port.look()).tree) : before;
|
|
101
|
+
};
|
|
102
|
+
const ready = await fillHere();
|
|
103
|
+
for (const c of ready)
|
|
104
|
+
seen.add(c.key);
|
|
105
|
+
// Every control of THIS state, once. Not filtered by what other states touched: a "Save" on
|
|
106
|
+
// another screen is another control, and skipping it left reachable writes undriven.
|
|
107
|
+
const untried = [
|
|
108
|
+
...new Set(ready.filter((c) => !TYPED.test(c.key) && !CHOOSER.test(c.key)).map((c) => c.key)),
|
|
109
|
+
];
|
|
110
|
+
for (const [index, target] of untried.entries()) {
|
|
111
|
+
if (spent())
|
|
112
|
+
break;
|
|
113
|
+
// The first control is tried on the page already loaded and filled; every other one starts
|
|
114
|
+
// over from the recorded path, because the one before it may have changed or left this state.
|
|
115
|
+
if (index > 0 && !(await goTo(node.path)))
|
|
116
|
+
break;
|
|
117
|
+
const current = 0 === index ? ready : await fillHere();
|
|
118
|
+
const control = current.find((c) => c.key === target);
|
|
119
|
+
if (control === undefined || spent())
|
|
120
|
+
continue;
|
|
121
|
+
const result = await act(control.ref, 'click');
|
|
122
|
+
touched.add(target);
|
|
123
|
+
const path = [...node.path, ...fills, { key: target, action: 'click' }];
|
|
124
|
+
const after = await port.look();
|
|
125
|
+
const next = stateKey(after.route, controlsOf(after.tree));
|
|
126
|
+
noteWrites(result.events, path, key, next, writes);
|
|
127
|
+
noteAnomalies(result.events, target, anomalies);
|
|
128
|
+
if (!explored.has(next) && !queued.has(next)) {
|
|
129
|
+
queued.add(next);
|
|
130
|
+
queue.push({ path, key: next });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
states: explored.size,
|
|
136
|
+
actions,
|
|
137
|
+
routes: [...routes],
|
|
138
|
+
seen: [...seen],
|
|
139
|
+
touched: [...touched],
|
|
140
|
+
writes: [...writes.values()],
|
|
141
|
+
anomalies,
|
|
142
|
+
frontier: queue.filter((n) => n.key === undefined || !explored.has(n.key)).length,
|
|
143
|
+
unreachable,
|
|
144
|
+
budgetExhausted: spent(),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* A fresh start page, then the path. False when a step's control is gone or refused. Every state
|
|
149
|
+
* is reached this way, which is what makes returning to one a replay rather than a hope.
|
|
150
|
+
*/
|
|
151
|
+
export async function replayPath(port, path, act, spent) {
|
|
152
|
+
if (!(await port.reset()))
|
|
153
|
+
return false;
|
|
154
|
+
for (const step of path) {
|
|
155
|
+
if (spent())
|
|
156
|
+
return false;
|
|
157
|
+
const target = controlsOf((await port.look()).tree).find((c) => c.key === step.key);
|
|
158
|
+
if (target === undefined)
|
|
159
|
+
return false;
|
|
160
|
+
if (!(await act(target.ref, step.action, step.value)).ok)
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
function noteWrites(events, path, beforeState, successState, writes) {
|
|
166
|
+
for (const event of events) {
|
|
167
|
+
const key = writeKeyOf(event);
|
|
168
|
+
if (key === undefined || writes.has(key))
|
|
169
|
+
continue;
|
|
170
|
+
const [method = '', urlPath = ''] = key.split(' ');
|
|
171
|
+
const url = event.data['url'];
|
|
172
|
+
const concrete = 'string' === typeof url ? new URL(url, 'http://localhost').pathname : urlPath;
|
|
173
|
+
writes.set(key, { key, method, urlPath: concrete, path, beforeState, successState });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function noteAnomalies(events, control, anomalies) {
|
|
177
|
+
const list = [...events];
|
|
178
|
+
for (const e of list.filter(isConsoleError))
|
|
179
|
+
anomalies.push({
|
|
180
|
+
kind: 'console-error',
|
|
181
|
+
control,
|
|
182
|
+
detail: asString(e.data['message']) ?? e.type,
|
|
183
|
+
});
|
|
184
|
+
for (const e of failedRequests(list, FAILED_STATUS))
|
|
185
|
+
anomalies.push({
|
|
186
|
+
kind: 'failed-request',
|
|
187
|
+
control,
|
|
188
|
+
detail: `${asString(e.data['method']) ?? ''} ${asString(e.data['url']) ?? ''} → ${String(asNumber(e.data['status']) ?? '')}`,
|
|
189
|
+
});
|
|
190
|
+
for (const c of findContradictions(list, { actionSince: 0 })) {
|
|
191
|
+
if (isAdvisory(c.kind) || isAbsenceDerived(c.kind))
|
|
192
|
+
continue;
|
|
193
|
+
anomalies.push({ kind: c.kind, control, detail: `${c.claim}, but ${c.counter}` });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `branched` level: every write the explorer found, driven down its FAILURE path.
|
|
3
|
+
*
|
|
4
|
+
* The happy path is the one every drive already takes. What a user meets when the server is down is
|
|
5
|
+
* the one nobody checks, and it is where "the toast said Saved" over a lost write lives.
|
|
6
|
+
*
|
|
7
|
+
* The break is an ABORT, not a fabricated 500 — see portal/input/network-mutation.ts: an app can read
|
|
8
|
+
* a 500 body as empty data and look fine, but no code path mistakes a request that never completed
|
|
9
|
+
* for a successful one.
|
|
10
|
+
*
|
|
11
|
+
* The oracle needs nothing app-specific: with the write failing, the app must NOT arrive at the state
|
|
12
|
+
* it reaches when the write succeeds. Arriving there is claiming success over a failure. A
|
|
13
|
+
* contradiction the engine observes in the window counts too.
|
|
14
|
+
*/
|
|
15
|
+
import { type ExplorePort, type ExploredWrite } from './explorer.js';
|
|
16
|
+
export interface FailureBranches {
|
|
17
|
+
branched: string[];
|
|
18
|
+
unhandled: {
|
|
19
|
+
key: string;
|
|
20
|
+
detail: string;
|
|
21
|
+
}[];
|
|
22
|
+
/**
|
|
23
|
+
* Writes whose success changed nothing the explorer can see, so success and failure look the same
|
|
24
|
+
* and nothing can be concluded from where the app landed. Not a finding — a blind spot to check
|
|
25
|
+
* by hand, or to give the app a visible outcome.
|
|
26
|
+
*/
|
|
27
|
+
indistinguishable: string[];
|
|
28
|
+
/** Why nothing was driven, when nothing could be. */
|
|
29
|
+
skipped?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function driveFailureBranches(port: ExplorePort, writes: readonly ExploredWrite[], opts: {
|
|
32
|
+
maxActions: number;
|
|
33
|
+
}): Promise<FailureBranches>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `branched` level: every write the explorer found, driven down its FAILURE path.
|
|
3
|
+
*
|
|
4
|
+
* The happy path is the one every drive already takes. What a user meets when the server is down is
|
|
5
|
+
* the one nobody checks, and it is where "the toast said Saved" over a lost write lives.
|
|
6
|
+
*
|
|
7
|
+
* The break is an ABORT, not a fabricated 500 — see portal/input/network-mutation.ts: an app can read
|
|
8
|
+
* a 500 body as empty data and look fine, but no code path mistakes a request that never completed
|
|
9
|
+
* for a successful one.
|
|
10
|
+
*
|
|
11
|
+
* The oracle needs nothing app-specific: with the write failing, the app must NOT arrive at the state
|
|
12
|
+
* it reaches when the write succeeds. Arriving there is claiming success over a failure. A
|
|
13
|
+
* contradiction the engine observes in the window counts too.
|
|
14
|
+
*/
|
|
15
|
+
import { EventType, asNumber, isAbsenceDerived, isAdvisory, } from '@reticlehq/core';
|
|
16
|
+
import { findContradictions } from '@reticlehq/engine/disagreement/contradictions.js';
|
|
17
|
+
import { controlsOf, replayPath, stateKey, } from './explorer.js';
|
|
18
|
+
import { writeKeyOf } from './session-fold.js';
|
|
19
|
+
const NO_MOCKS = 'failure paths need a driven browser to break a request in (`reticle drive`, or RETICLE_CDP_URL); none were driven, so none were proved';
|
|
20
|
+
/** The target write, sent and never answered: an aborted request reports status 0. */
|
|
21
|
+
function aborted(e, key) {
|
|
22
|
+
return (EventType.NET_REQUEST === e.type &&
|
|
23
|
+
writeKeyOf(e) === key &&
|
|
24
|
+
(0 === asNumber(e.data['status']) || e.data['error'] !== undefined));
|
|
25
|
+
}
|
|
26
|
+
export async function driveFailureBranches(port, writes, opts) {
|
|
27
|
+
const out = { branched: [], unhandled: [], indistinguishable: [] };
|
|
28
|
+
const mock = port.mock;
|
|
29
|
+
if (mock === undefined)
|
|
30
|
+
return { ...out, ...(0 < writes.length ? { skipped: NO_MOCKS } : {}) };
|
|
31
|
+
let actions = 0;
|
|
32
|
+
const spent = () => actions >= opts.maxActions;
|
|
33
|
+
const act = (ref, action, value) => {
|
|
34
|
+
actions += 1;
|
|
35
|
+
return port.act(ref, action, value);
|
|
36
|
+
};
|
|
37
|
+
for (const write of writes) {
|
|
38
|
+
if (spent())
|
|
39
|
+
break;
|
|
40
|
+
const last = write.path.at(-1);
|
|
41
|
+
if (last === undefined)
|
|
42
|
+
continue;
|
|
43
|
+
try {
|
|
44
|
+
// Everything up to the step that fires the write, THEN the break. Installed before the replay,
|
|
45
|
+
// the abort also broke any setup step sending the same request, and the replay died short of
|
|
46
|
+
// the action it existed to reach.
|
|
47
|
+
if (!(await replayPath(port, write.path.slice(0, -1), act, spent)))
|
|
48
|
+
continue;
|
|
49
|
+
if (!(await mock([{ urlContains: write.urlPath, method: write.method, abort: true }])))
|
|
50
|
+
return { ...out, skipped: NO_MOCKS };
|
|
51
|
+
const control = controlsOf((await port.look()).tree).find((c) => c.key === last.key);
|
|
52
|
+
if (control === undefined)
|
|
53
|
+
continue;
|
|
54
|
+
const result = await act(control.ref, last.action, last.value);
|
|
55
|
+
// Driven means the write was SENT and did not complete. An action that never dispatched, or a
|
|
56
|
+
// request the rule did not match, drove nothing — and judging where the app landed after a
|
|
57
|
+
// write that succeeded would accuse it of claiming success over a failure that never happened.
|
|
58
|
+
if (!result.ok || !result.events.some((e) => aborted(e, write.key)))
|
|
59
|
+
continue;
|
|
60
|
+
out.branched.push(write.key);
|
|
61
|
+
const after = await port.look();
|
|
62
|
+
const reached = stateKey(after.route, controlsOf(after.tree));
|
|
63
|
+
// The failure is OURS, so it is declared. Without that, the engine's "UI moved beside a failed
|
|
64
|
+
// request" rule cannot tell an app that swallowed the error from one that correctly RENDERED
|
|
65
|
+
// it, and every well-behaved error message read as a finding. A success signal fired over the
|
|
66
|
+
// failure still contradicts. Found by driving the fixture.
|
|
67
|
+
const contradiction = findContradictions([...result.events], {
|
|
68
|
+
actionSince: 0,
|
|
69
|
+
expectedFailures: [{ method: write.method, urlContains: write.urlPath }],
|
|
70
|
+
}).find((c) => !isAdvisory(c.kind) && !isAbsenceDerived(c.kind));
|
|
71
|
+
if (contradiction !== undefined)
|
|
72
|
+
out.unhandled.push({
|
|
73
|
+
key: write.key,
|
|
74
|
+
detail: `${contradiction.claim}, but ${contradiction.counter}`,
|
|
75
|
+
});
|
|
76
|
+
// Landing where success lands only ACCUSES when success visibly moved the app. A write whose
|
|
77
|
+
// success shows nothing looks identical failing, and the first version of this called that
|
|
78
|
+
// "claimed success" — found by driving the fixture, whose add-item panel claims nothing at all.
|
|
79
|
+
else if (write.successState === write.beforeState)
|
|
80
|
+
out.indistinguishable.push(write.key);
|
|
81
|
+
else if (reached === write.successState)
|
|
82
|
+
out.unhandled.push({
|
|
83
|
+
key: write.key,
|
|
84
|
+
detail: `with ${write.key} failing, the app arrived exactly where it goes when the write succeeds (${after.route ?? 'same page'}) — it claimed success over a failure`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await mock([]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|