@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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { span } from '../../trace.js';
|
|
8
8
|
import { ActionType, CRAWL_DEFAULTS, CrawlAnomalyKind, DANGEROUS_ACTION_CONFIRM_ARG, EventType, ReticleCommand, } from '@reticlehq/core';
|
|
9
9
|
import { crawlEmptyNote } from './crawl-empty.js';
|
|
10
|
+
import { failedRequests, isConsoleError } from '../exhaust/session-fold.js';
|
|
10
11
|
import { asNumber, asRecord, asString } from '@reticlehq/core';
|
|
11
12
|
import { parseInteractive } from '@reticlehq/core';
|
|
12
13
|
import { sourceOf } from '../../surface/tools/tools-helpers.js';
|
|
@@ -44,9 +45,6 @@ function isActivity(e) {
|
|
|
44
45
|
e.type === EventType.ANIM_START ||
|
|
45
46
|
e.type === EventType.ANIM_END);
|
|
46
47
|
}
|
|
47
|
-
function isConsoleError(e) {
|
|
48
|
-
return e.type === EventType.CONSOLE_ERROR || e.type === EventType.ERROR_UNCAUGHT;
|
|
49
|
-
}
|
|
50
48
|
const SOURCE_FRAME = /^(.*):(\d+):(\d+)$/;
|
|
51
49
|
const COMPONENT_STACK_FIELDS = ['componentStack', 'message'];
|
|
52
50
|
const URL_SOURCE_PROTOCOLS = new Set([
|
|
@@ -156,14 +154,6 @@ function sourceFromConsoleError(event) {
|
|
|
156
154
|
? undefined
|
|
157
155
|
: `${file}:${String(directLine)}`;
|
|
158
156
|
}
|
|
159
|
-
function failedRequests(events, floor) {
|
|
160
|
-
return events.filter((e) => {
|
|
161
|
-
if (e.type !== EventType.NET_REQUEST)
|
|
162
|
-
return false;
|
|
163
|
-
const status = asNumber(e.data['status']);
|
|
164
|
-
return status !== undefined && status >= floor;
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
157
|
/**
|
|
168
158
|
* Controls whose correct behaviour IS to do nothing when clicked.
|
|
169
159
|
*
|
|
@@ -198,17 +188,26 @@ export function legitimatelyInert(desc) {
|
|
|
198
188
|
* fails — this is a disclosure, and failing the whole crawl to report on coverage would be worse
|
|
199
189
|
* than the silence it is fixing.
|
|
200
190
|
*/
|
|
201
|
-
async function
|
|
191
|
+
async function revealedItems(session, opts, before) {
|
|
202
192
|
const snap = await session.command(ReticleCommand.SNAPSHOT, {
|
|
203
193
|
mode: 'interactive',
|
|
204
194
|
...(opts.scope !== undefined ? { scope: opts.scope } : {}),
|
|
205
195
|
});
|
|
206
196
|
if (!snap.ok)
|
|
207
|
-
return
|
|
197
|
+
return [];
|
|
208
198
|
const tree = (snap.result ?? {}).tree ?? '';
|
|
209
199
|
const known = new Set(before.map((i) => (i.ref !== '' ? i.ref : i.desc)));
|
|
210
|
-
return parseInteractive(tree).filter((i) => !known.has(i.ref !== '' ? i.ref : i.desc))
|
|
200
|
+
return parseInteractive(tree).filter((i) => !known.has(i.ref !== '' ? i.ref : i.desc));
|
|
211
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* How many times a crawl re-enumerates to follow what its own clicks revealed.
|
|
204
|
+
*
|
|
205
|
+
* ponytail: rounds, not a real frontier. Each round clicks what the previous one uncovered (a
|
|
206
|
+
* dashboard behind a login, a modal's contents), which is the depth a one-pass crawl never had. A
|
|
207
|
+
* page that reveals something new on every click would otherwise never end; past this it reports
|
|
208
|
+
* the rest as unvisited, as before. `maxSteps` still bounds the total.
|
|
209
|
+
*/
|
|
210
|
+
const REVEAL_ROUNDS = 2;
|
|
212
211
|
/**
|
|
213
212
|
* The confirmation half of the dead-control rule. Kept separate so the rule above reads as one
|
|
214
213
|
* question, and so the second click gets its own window: reusing the first would let the first
|
|
@@ -218,14 +217,14 @@ async function countRevealed(session, opts, before) {
|
|
|
218
217
|
* wrong here returns `false`: a confirmation that cannot run is not evidence, and the direction that
|
|
219
218
|
* costs a missed finding is better than the one that invents one.
|
|
220
219
|
*/
|
|
221
|
-
async function stillSilent(session, item, settleMs,
|
|
220
|
+
async function stillSilent(session, item, settleMs, confirm, sleep) {
|
|
222
221
|
const since = session.elapsed();
|
|
223
222
|
session.beginAction?.(ReticleTool.CRAWL, { ref: item.ref, action: ActionType.CLICK });
|
|
224
223
|
try {
|
|
225
224
|
const again = await session.command(ReticleCommand.ACT, {
|
|
226
225
|
ref: item.ref,
|
|
227
226
|
action: ActionType.CLICK,
|
|
228
|
-
args:
|
|
227
|
+
args: confirm ? { [DANGEROUS_ACTION_CONFIRM_ARG]: true } : {},
|
|
229
228
|
});
|
|
230
229
|
await sleep(settleMs);
|
|
231
230
|
// A control that has GONE (the first click removed it) is not a dead control — it did something.
|
|
@@ -260,152 +259,167 @@ export async function crawl(session, opts, sleep) {
|
|
|
260
259
|
const counts = { consoleErrors: 0, failedRequests: 0, deadControls: 0, contradictions: 0 };
|
|
261
260
|
const seen = new Set();
|
|
262
261
|
let stepsRun = 0;
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
262
|
+
const found = [...items];
|
|
263
|
+
let frontier = items;
|
|
264
|
+
for (let round = 0;; round += 1) {
|
|
265
|
+
// `confirmDangerous` covers the controls on the page the caller pointed the crawl at. What a
|
|
266
|
+
// click REVEALED is new: the "Yes, delete" behind a "Delete" was confirmed with the same flag,
|
|
267
|
+
// so one call finished a deletion nobody authorized the second half of.
|
|
268
|
+
const confirm = 0 === round && true === opts.confirmDangerous;
|
|
269
|
+
for (const item of frontier) {
|
|
270
|
+
if (stepsRun >= maxSteps)
|
|
271
|
+
break;
|
|
272
|
+
// Dedupe by ref (the unique element), not by label — two "Delete"/"Edit" controls share a desc
|
|
273
|
+
// but are different controls; collapsing them by label under-covers list/table UIs.
|
|
274
|
+
const dedupeKey = item.ref !== '' ? item.ref : item.desc;
|
|
275
|
+
if (seen.has(dedupeKey))
|
|
276
|
+
continue; // don't re-click the same control
|
|
277
|
+
seen.add(dedupeKey);
|
|
278
|
+
stepsRun += 1;
|
|
279
|
+
visited.push(item.desc);
|
|
280
|
+
const since = session.elapsed();
|
|
281
|
+
session.beginAction?.(ReticleTool.CRAWL, { ref: item.ref, action: ActionType.CLICK });
|
|
282
|
+
let act;
|
|
283
|
+
/*
|
|
284
|
+
* A full page load reconnects the SDK, which rejects whatever command was in flight.
|
|
285
|
+
*
|
|
286
|
+
* On a server-rendered app that is what following a link DOES — every navigation replaces the
|
|
287
|
+
* session — so the rejection is evidence the click worked, not that it failed. Crawl used to let
|
|
288
|
+
* it propagate and died on link one of every Django, Rails or plain-HTML app.
|
|
289
|
+
*
|
|
290
|
+
* Recorded as a navigation rather than swallowed: a control that took the page somewhere is the
|
|
291
|
+
* opposite of a dead one, and reporting it as dead would be the false negative crawl exists to
|
|
292
|
+
* find.
|
|
293
|
+
*/
|
|
294
|
+
let navigatedAway = false;
|
|
295
|
+
try {
|
|
296
|
+
// One span per control clicked, so a slow crawl names the control rather than reporting a
|
|
297
|
+
// single multi-second total. The settle sleep below is INSIDE it deliberately: it is part of
|
|
298
|
+
// what a step costs, and hiding it would make the fixed budget look free.
|
|
299
|
+
act = await span('crawl.step', { ref: item.ref, desc: item.desc }, async () => {
|
|
300
|
+
const clicked = await session.command(ReticleCommand.ACT, {
|
|
301
|
+
ref: item.ref,
|
|
302
|
+
action: ActionType.CLICK,
|
|
303
|
+
args: confirm ? { [DANGEROUS_ACTION_CONFIRM_ARG]: true } : {},
|
|
304
|
+
});
|
|
305
|
+
await sleep(settleMs);
|
|
306
|
+
return clicked;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
if (!isSessionReplacedError(err))
|
|
311
|
+
throw err;
|
|
312
|
+
navigatedAway = true;
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
// Close on every exit so a throw cannot leak the window onto the next control's events.
|
|
316
|
+
session.finishAction?.();
|
|
317
|
+
}
|
|
318
|
+
const events = session.eventsSince(since);
|
|
319
|
+
// Captured at act time, so it survives a click that unmounts its own control.
|
|
320
|
+
const src = sourceOf(asRecord(act?.result)['source']);
|
|
321
|
+
const controlSource = src === undefined ? undefined : `${src.file}:${String(src.line)}`;
|
|
322
|
+
const source = controlSource === undefined ? {} : { source: controlSource };
|
|
323
|
+
const errs = events.filter(isConsoleError);
|
|
324
|
+
for (const e of errs) {
|
|
325
|
+
counts.consoleErrors += 1;
|
|
326
|
+
const errorSource = sourceFromConsoleError(e) ?? controlSource;
|
|
327
|
+
anomalies.push({
|
|
328
|
+
kind: CrawlAnomalyKind.CONSOLE_ERROR,
|
|
295
329
|
ref: item.ref,
|
|
296
|
-
|
|
297
|
-
|
|
330
|
+
desc: item.desc,
|
|
331
|
+
detail: asString(e.data['message']) ?? e.type,
|
|
332
|
+
...(errorSource === undefined ? {} : { source: errorSource }),
|
|
298
333
|
});
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
throw err;
|
|
306
|
-
navigatedAway = true;
|
|
307
|
-
}
|
|
308
|
-
finally {
|
|
309
|
-
// Close on every exit so a throw cannot leak the window onto the next control's events.
|
|
310
|
-
session.finishAction?.();
|
|
311
|
-
}
|
|
312
|
-
const events = session.eventsSince(since);
|
|
313
|
-
// Captured at act time, so it survives a click that unmounts its own control.
|
|
314
|
-
const src = sourceOf(asRecord(act?.result)['source']);
|
|
315
|
-
const controlSource = src === undefined ? undefined : `${src.file}:${String(src.line)}`;
|
|
316
|
-
const source = controlSource === undefined ? {} : { source: controlSource };
|
|
317
|
-
const errs = events.filter(isConsoleError);
|
|
318
|
-
for (const e of errs) {
|
|
319
|
-
counts.consoleErrors += 1;
|
|
320
|
-
const errorSource = sourceFromConsoleError(e) ?? controlSource;
|
|
321
|
-
anomalies.push({
|
|
322
|
-
kind: CrawlAnomalyKind.CONSOLE_ERROR,
|
|
323
|
-
ref: item.ref,
|
|
324
|
-
desc: item.desc,
|
|
325
|
-
detail: asString(e.data['message']) ?? e.type,
|
|
326
|
-
...(errorSource === undefined ? {} : { source: errorSource }),
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
for (const e of failedRequests(events, CRAWL_DEFAULTS.FAILED_STATUS)) {
|
|
330
|
-
counts.failedRequests += 1;
|
|
331
|
-
const method = asString(e.data['method']) ?? '';
|
|
332
|
-
const url = asString(e.data['url']) ?? '';
|
|
333
|
-
const status = asNumber(e.data['status']);
|
|
334
|
-
anomalies.push({
|
|
335
|
-
kind: CrawlAnomalyKind.FAILED_REQUEST,
|
|
336
|
-
ref: item.ref,
|
|
337
|
-
desc: item.desc,
|
|
338
|
-
detail: `${method} ${url} → ${status ?? ''}`.trim(),
|
|
339
|
-
...source,
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
// CONTRADICTIONS: the channels disagree about what this click did. Reported per control, so a
|
|
343
|
-
// crawl of an unknown app surfaces its false greens without anyone knowing where to look.
|
|
344
|
-
// A crawl clicks controls that navigate, so its windows are the ones most likely to straddle a
|
|
345
|
-
// document boundary — and a finding here is reported against a specific control by name.
|
|
346
|
-
for (const c of findContradictions(events, {
|
|
347
|
-
currentDocumentId: session.currentDocumentId,
|
|
348
|
-
currentEditEpoch: session.currentEditEpoch,
|
|
349
|
-
appOrigin: session.url,
|
|
350
|
-
background: session.background,
|
|
351
|
-
// A hidden tab cannot be observed, so its silence is not evidence. Stated from the session
|
|
352
|
-
// rather than left to a heartbeat landing inside a 300ms window.
|
|
353
|
-
pageHidden: session.throttled?.(),
|
|
354
|
-
// The crawl's window IS one control's click, so it can name the floor the consequence rules
|
|
355
|
-
// need — without it a crawl would report nothing about the UI moving, which is most of what a
|
|
356
|
-
// crawl is for.
|
|
357
|
-
actionSince: since,
|
|
358
|
-
})) {
|
|
359
|
-
counts.contradictions += 1;
|
|
360
|
-
anomalies.push({
|
|
361
|
-
kind: c.kind,
|
|
362
|
-
ref: item.ref,
|
|
363
|
-
desc: item.desc,
|
|
364
|
-
detail: `${c.claim}, but ${c.counter} — ${c.detail}`,
|
|
365
|
-
...source,
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
// DEAD: the click dispatched but the app produced no activity and no error to explain it.
|
|
369
|
-
//
|
|
370
|
-
// Unless the control is SUPPOSED to be inert. A control clicked twice with no reaction may be
|
|
371
|
-
// legitimately inert — a `[disabled]` button, an already `[checked]` radio, a text input (a click
|
|
372
|
-
// focuses it; mutating would be the surprise). An anomaly list that is all false positives is
|
|
373
|
-
// worse than no anomaly list: the agent spends its budget disproving it and learns to skip it.
|
|
374
|
-
//
|
|
375
|
-
// Deliberately NOT fixed by counting focus as activity: focus moving is not the app reacting, and
|
|
376
|
-
// treating it as such would make a genuinely dead button that takes focus look alive — trading
|
|
377
|
-
// noise for the false negative this check exists to catch.
|
|
378
|
-
const dispatched = asRecord(act?.result)['dispatched'] !== false && true === act?.ok;
|
|
379
|
-
if (
|
|
380
|
-
// A control that took the page somewhere is the OPPOSITE of a dead one. Without this, every
|
|
381
|
-
// link on a server-rendered app would be reported as an anomaly by the check that exists to
|
|
382
|
-
// find controls which do nothing.
|
|
383
|
-
!navigatedAway &&
|
|
384
|
-
dispatched &&
|
|
385
|
-
0 === errs.length &&
|
|
386
|
-
!events.some(isActivity) &&
|
|
387
|
-
!legitimatelyInert(item.desc)) {
|
|
388
|
-
// CONFIRM IT. One silent sample is a sample, not a fact, and this is the strongest claim the
|
|
389
|
-
// crawler makes — "your button does nothing" is the finding a reader acts on immediately.
|
|
390
|
-
//
|
|
391
|
-
// Measured on a fixture whose FIXED twin has a working primary CTA: the crawl reported it dead
|
|
392
|
-
// on 2 of 3 runs, always a control the drive had already clicked successfully moments earlier.
|
|
393
|
-
// A false positive in this tier is the most expensive kind there is — it is the tier a verdict
|
|
394
|
-
// is built from, and the twin column is the whole claim this product makes.
|
|
395
|
-
//
|
|
396
|
-
// A second click costs one round trip on the rare control that looked dead, and nothing at all
|
|
397
|
-
// on every control that did not. A genuinely dead control is silent twice; the flake is not.
|
|
398
|
-
if (await stillSilent(session, item, settleMs, opts, sleep)) {
|
|
399
|
-
counts.deadControls += 1;
|
|
334
|
+
}
|
|
335
|
+
for (const e of failedRequests(events, CRAWL_DEFAULTS.FAILED_STATUS)) {
|
|
336
|
+
counts.failedRequests += 1;
|
|
337
|
+
const method = asString(e.data['method']) ?? '';
|
|
338
|
+
const url = asString(e.data['url']) ?? '';
|
|
339
|
+
const status = asNumber(e.data['status']);
|
|
400
340
|
anomalies.push({
|
|
401
|
-
kind: CrawlAnomalyKind.
|
|
341
|
+
kind: CrawlAnomalyKind.FAILED_REQUEST,
|
|
402
342
|
ref: item.ref,
|
|
403
343
|
desc: item.desc,
|
|
404
|
-
detail:
|
|
344
|
+
detail: `${method} ${url} → ${status ?? ''}`.trim(),
|
|
405
345
|
...source,
|
|
406
346
|
});
|
|
407
347
|
}
|
|
348
|
+
// CONTRADICTIONS: the channels disagree about what this click did. Reported per control, so a
|
|
349
|
+
// crawl of an unknown app surfaces its false greens without anyone knowing where to look.
|
|
350
|
+
// A crawl clicks controls that navigate, so its windows are the ones most likely to straddle a
|
|
351
|
+
// document boundary — and a finding here is reported against a specific control by name.
|
|
352
|
+
for (const c of findContradictions(events, {
|
|
353
|
+
currentDocumentId: session.currentDocumentId,
|
|
354
|
+
currentEditEpoch: session.currentEditEpoch,
|
|
355
|
+
appOrigin: session.url,
|
|
356
|
+
background: session.background,
|
|
357
|
+
// A hidden tab cannot be observed, so its silence is not evidence. Stated from the session
|
|
358
|
+
// rather than left to a heartbeat landing inside a 300ms window.
|
|
359
|
+
pageHidden: session.throttled?.(),
|
|
360
|
+
// The crawl's window IS one control's click, so it can name the floor the consequence rules
|
|
361
|
+
// need — without it a crawl would report nothing about the UI moving, which is most of what a
|
|
362
|
+
// crawl is for.
|
|
363
|
+
actionSince: since,
|
|
364
|
+
})) {
|
|
365
|
+
counts.contradictions += 1;
|
|
366
|
+
anomalies.push({
|
|
367
|
+
kind: c.kind,
|
|
368
|
+
ref: item.ref,
|
|
369
|
+
desc: item.desc,
|
|
370
|
+
detail: `${c.claim}, but ${c.counter} — ${c.detail}`,
|
|
371
|
+
...source,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
// DEAD: the click dispatched but the app produced no activity and no error to explain it.
|
|
375
|
+
//
|
|
376
|
+
// Unless the control is SUPPOSED to be inert. A control clicked twice with no reaction may be
|
|
377
|
+
// legitimately inert — a `[disabled]` button, an already `[checked]` radio, a text input (a click
|
|
378
|
+
// focuses it; mutating would be the surprise). An anomaly list that is all false positives is
|
|
379
|
+
// worse than no anomaly list: the agent spends its budget disproving it and learns to skip it.
|
|
380
|
+
//
|
|
381
|
+
// Deliberately NOT fixed by counting focus as activity: focus moving is not the app reacting, and
|
|
382
|
+
// treating it as such would make a genuinely dead button that takes focus look alive — trading
|
|
383
|
+
// noise for the false negative this check exists to catch.
|
|
384
|
+
const dispatched = asRecord(act?.result)['dispatched'] !== false && true === act?.ok;
|
|
385
|
+
if (
|
|
386
|
+
// A control that took the page somewhere is the OPPOSITE of a dead one. Without this, every
|
|
387
|
+
// link on a server-rendered app would be reported as an anomaly by the check that exists to
|
|
388
|
+
// find controls which do nothing.
|
|
389
|
+
!navigatedAway &&
|
|
390
|
+
dispatched &&
|
|
391
|
+
0 === errs.length &&
|
|
392
|
+
!events.some(isActivity) &&
|
|
393
|
+
!legitimatelyInert(item.desc)) {
|
|
394
|
+
// CONFIRM IT. One silent sample is a sample, not a fact, and this is the strongest claim the
|
|
395
|
+
// crawler makes — "your button does nothing" is the finding a reader acts on immediately.
|
|
396
|
+
//
|
|
397
|
+
// Measured on a fixture whose FIXED twin has a working primary CTA: the crawl reported it dead
|
|
398
|
+
// on 2 of 3 runs, always a control the drive had already clicked successfully moments earlier.
|
|
399
|
+
// A false positive in this tier is the most expensive kind there is — it is the tier a verdict
|
|
400
|
+
// is built from, and the twin column is the whole claim this product makes.
|
|
401
|
+
//
|
|
402
|
+
// A second click costs one round trip on the rare control that looked dead, and nothing at all
|
|
403
|
+
// on every control that did not. A genuinely dead control is silent twice; the flake is not.
|
|
404
|
+
if (await stillSilent(session, item, settleMs, confirm, sleep)) {
|
|
405
|
+
counts.deadControls += 1;
|
|
406
|
+
anomalies.push({
|
|
407
|
+
kind: CrawlAnomalyKind.DEAD_CONTROL,
|
|
408
|
+
ref: item.ref,
|
|
409
|
+
desc: item.desc,
|
|
410
|
+
detail: 'clicked TWICE and the app did nothing either time (no DOM/network/route/signal change)',
|
|
411
|
+
...source,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
408
415
|
}
|
|
416
|
+
if (coverageCapped || stepsRun >= maxSteps || REVEAL_ROUNDS <= round)
|
|
417
|
+
break;
|
|
418
|
+
const next = await revealedItems(session, opts, found);
|
|
419
|
+
if (0 === next.length)
|
|
420
|
+
break;
|
|
421
|
+
found.push(...next);
|
|
422
|
+
frontier = next;
|
|
409
423
|
}
|
|
410
424
|
// One more look, AFTER the clicks. The enumeration above happened before any of them, so anything
|
|
411
425
|
// the crawl itself unlocked — a dashboard behind a login, a modal's contents, a tab's panel — was
|
|
@@ -414,9 +428,9 @@ export async function crawl(session, opts, sleep) {
|
|
|
414
428
|
// as new, and a surface that SHRANK (a modal closed) reports nothing, because nothing went
|
|
415
429
|
// unvisited. Skipped when the snapshot was already capped: that case has its own note and a second
|
|
416
430
|
// one would only muddy it.
|
|
417
|
-
const revealed = coverageCapped ? 0 : await
|
|
431
|
+
const revealed = coverageCapped ? 0 : (await revealedItems(session, opts, found)).length;
|
|
418
432
|
return {
|
|
419
|
-
interactiveFound:
|
|
433
|
+
interactiveFound: found.length,
|
|
420
434
|
stepsRun,
|
|
421
435
|
anomalies,
|
|
422
436
|
counts,
|
|
@@ -424,7 +438,7 @@ export async function crawl(session, opts, sleep) {
|
|
|
424
438
|
// True when coverage was bounded for EITHER reason: the step budget ran out, or the page was
|
|
425
439
|
// bigger than one snapshot. Conflating "I stopped early" with "I saw everything" is what turns a
|
|
426
440
|
// partial sweep into "all controls healthy".
|
|
427
|
-
truncated:
|
|
441
|
+
truncated: found.length > stepsRun || coverageCapped || revealed > 0,
|
|
428
442
|
...(coverageCapped
|
|
429
443
|
? { coverageNote: CAPPED_SNAPSHOT_NOTE }
|
|
430
444
|
: revealed > 0
|
|
@@ -434,7 +448,7 @@ export async function crawl(session, opts, sleep) {
|
|
|
434
448
|
// same answer. See crawl-empty.
|
|
435
449
|
...(() => {
|
|
436
450
|
const note = crawlEmptyNote({
|
|
437
|
-
interactiveFound:
|
|
451
|
+
interactiveFound: found.length,
|
|
438
452
|
stepsRun,
|
|
439
453
|
maxSteps,
|
|
440
454
|
truncated: coverageCapped,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reticle_coverage { scope: "app" }` — fold this session into the ledger and say where "everything"
|
|
3
|
+
* stands, level by level.
|
|
4
|
+
*/
|
|
5
|
+
import type { ReticleEvent } from '@reticlehq/core';
|
|
6
|
+
import type { FileSystemPort } from '../../memory/project/fs/fs-port.js';
|
|
7
|
+
import { type RawScriptCoverage } from './code-coverage.js';
|
|
8
|
+
import { type LevelReport } from './ledger.js';
|
|
9
|
+
export interface AppCoverageReport {
|
|
10
|
+
levels: LevelReport[];
|
|
11
|
+
/** Writes whose failure path was driven and the app claimed success anyway. Findings, not gaps. */
|
|
12
|
+
unhandledWrites: string[];
|
|
13
|
+
/** Levels below the best they ever reached. */
|
|
14
|
+
regressed: {
|
|
15
|
+
level: string;
|
|
16
|
+
was: number;
|
|
17
|
+
now: number;
|
|
18
|
+
}[];
|
|
19
|
+
note?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function foldAppCoverage(input: {
|
|
22
|
+
fs: FileSystemPort;
|
|
23
|
+
reticleRoot: string;
|
|
24
|
+
seen: readonly string[];
|
|
25
|
+
session: {
|
|
26
|
+
url: string;
|
|
27
|
+
actedLabels(): ReadonlySet<string>;
|
|
28
|
+
provedLabels?(): ReadonlySet<string>;
|
|
29
|
+
eventsSince(cursor: number): ReticleEvent[];
|
|
30
|
+
};
|
|
31
|
+
takeCode?: (sessionUrl: string) => Promise<readonly RawScriptCoverage[] | undefined>;
|
|
32
|
+
now: () => number;
|
|
33
|
+
}): Promise<AppCoverageReport>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reticle_coverage { scope: "app" }` — fold this session into the ledger and say where "everything"
|
|
3
|
+
* stands, level by level.
|
|
4
|
+
*/
|
|
5
|
+
import { emptyCodeCoverage, foldCodeCoverage } from './code-coverage.js';
|
|
6
|
+
import { LedgerStore, levelsOf, regressions } from './ledger.js';
|
|
7
|
+
import { sessionDelta } from './session-fold.js';
|
|
8
|
+
const NO_CODE_NOTE = 'The `executed` level is unmeasured here: code coverage needs a browser Reticle drives (`reticle drive`, or RETICLE_CDP_URL), and collection starts at the first coverage request, so drive and ask again. The other levels are real.';
|
|
9
|
+
export async function foldAppCoverage(input) {
|
|
10
|
+
const { session } = input;
|
|
11
|
+
const take = await input.takeCode?.(session.url);
|
|
12
|
+
const delta = sessionDelta({
|
|
13
|
+
seen: input.seen,
|
|
14
|
+
acted: session.actedLabels(),
|
|
15
|
+
proved: session.provedLabels?.() ?? [],
|
|
16
|
+
url: session.url,
|
|
17
|
+
events: session.eventsSince(0),
|
|
18
|
+
});
|
|
19
|
+
const ledger = await new LedgerStore(input.fs, input.reticleRoot).merge({
|
|
20
|
+
...delta,
|
|
21
|
+
...(take === undefined
|
|
22
|
+
? {}
|
|
23
|
+
: { code: foldCodeCoverage(emptyCodeCoverage(), take), codeTakenAt: input.now() }),
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
levels: levelsOf(ledger),
|
|
27
|
+
unhandledWrites: ledger.writes.unhandled,
|
|
28
|
+
regressed: regressions(ledger),
|
|
29
|
+
...(take === undefined && 0 === Object.keys(ledger.code).length ? { note: NO_CODE_NOTE } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What of the app's own code ever ran, from the browser Reticle drives.
|
|
3
|
+
*
|
|
4
|
+
* Every other coverage number measures what Reticle already knew about — routes it reached, controls
|
|
5
|
+
* it listed. None can say what NEVER ran, including a branch no screen shows. V8 function coverage
|
|
6
|
+
* can, because it is counted against the app's source rather than against what the driving found.
|
|
7
|
+
*
|
|
8
|
+
* ponytail: FUNCTION coverage, keyed by name and start offset in the served (transformed) module.
|
|
9
|
+
* It needs no source maps and answers "did this handler ever run". Line coverage needs the source
|
|
10
|
+
* map of each module; add it when function granularity proves too coarse.
|
|
11
|
+
*/
|
|
12
|
+
/** One script as `page.coverage.stopJSCoverage()` reports it — only the fields read here. */
|
|
13
|
+
export interface RawScriptCoverage {
|
|
14
|
+
url: string;
|
|
15
|
+
functions: readonly {
|
|
16
|
+
functionName: string;
|
|
17
|
+
ranges: readonly {
|
|
18
|
+
startOffset: number;
|
|
19
|
+
endOffset: number;
|
|
20
|
+
count: number;
|
|
21
|
+
}[];
|
|
22
|
+
}[];
|
|
23
|
+
}
|
|
24
|
+
/** Per file, per function key: its name and whether it ever ran. JSON-safe so it can persist. */
|
|
25
|
+
export type CodeCoverage = Record<string, Record<string, {
|
|
26
|
+
name: string;
|
|
27
|
+
executed: boolean;
|
|
28
|
+
}>>;
|
|
29
|
+
export interface CodeSummary {
|
|
30
|
+
total: number;
|
|
31
|
+
executed: number;
|
|
32
|
+
files: {
|
|
33
|
+
file: string;
|
|
34
|
+
functions: number;
|
|
35
|
+
executed: number;
|
|
36
|
+
unexecuted: string[];
|
|
37
|
+
}[];
|
|
38
|
+
}
|
|
39
|
+
/** The repo-relative-looking path of an app script, or undefined for anything that is not the app. */
|
|
40
|
+
export declare function appFileOf(url: string): string | undefined;
|
|
41
|
+
export declare function emptyCodeCoverage(): CodeCoverage;
|
|
42
|
+
/**
|
|
43
|
+
* Fold one take into the accumulated coverage. A union: a function that ran once stays run, so a
|
|
44
|
+
* reload or a later drive can only add to what is known.
|
|
45
|
+
*/
|
|
46
|
+
export declare function foldCodeCoverage(into: CodeCoverage, take: readonly RawScriptCoverage[]): CodeCoverage;
|
|
47
|
+
export declare function summarizeCode(coverage: CodeCoverage): CodeSummary;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What of the app's own code ever ran, from the browser Reticle drives.
|
|
3
|
+
*
|
|
4
|
+
* Every other coverage number measures what Reticle already knew about — routes it reached, controls
|
|
5
|
+
* it listed. None can say what NEVER ran, including a branch no screen shows. V8 function coverage
|
|
6
|
+
* can, because it is counted against the app's source rather than against what the driving found.
|
|
7
|
+
*
|
|
8
|
+
* ponytail: FUNCTION coverage, keyed by name and start offset in the served (transformed) module.
|
|
9
|
+
* It needs no source maps and answers "did this handler ever run". Line coverage needs the source
|
|
10
|
+
* map of each module; add it when function granularity proves too coarse.
|
|
11
|
+
*/
|
|
12
|
+
/** Path segments that are never the app under test. */
|
|
13
|
+
// `dist`: built output is not source, and a monorepo serves Reticle's own SDK from it.
|
|
14
|
+
const NOT_APP = /(?:^|\/)(?:node_modules|@vite|@id|@react-refresh|@reticlehq|\.vite|dist)(?:\/|$)/;
|
|
15
|
+
const SOURCE_FILE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro)$/;
|
|
16
|
+
/** The repo-relative-looking path of an app script, or undefined for anything that is not the app. */
|
|
17
|
+
export function appFileOf(url) {
|
|
18
|
+
let path;
|
|
19
|
+
try {
|
|
20
|
+
path = new URL(url).pathname;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
if (NOT_APP.test(path) || !SOURCE_FILE.test(path))
|
|
26
|
+
return undefined;
|
|
27
|
+
return path.replace(/^\/+/, '');
|
|
28
|
+
}
|
|
29
|
+
export function emptyCodeCoverage() {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Fold one take into the accumulated coverage. A union: a function that ran once stays run, so a
|
|
34
|
+
* reload or a later drive can only add to what is known.
|
|
35
|
+
*/
|
|
36
|
+
export function foldCodeCoverage(into, take) {
|
|
37
|
+
const out = { ...into };
|
|
38
|
+
for (const script of take) {
|
|
39
|
+
const file = appFileOf(script.url);
|
|
40
|
+
if (file === undefined)
|
|
41
|
+
continue;
|
|
42
|
+
const fns = { ...(out[file] ?? {}) };
|
|
43
|
+
for (const f of script.functions) {
|
|
44
|
+
const first = f.ranges[0];
|
|
45
|
+
if (first === undefined)
|
|
46
|
+
continue;
|
|
47
|
+
// The module's own top-level body is reported as an anonymous function starting at 0. It
|
|
48
|
+
// always runs when the file loads, so counting it would inflate every file by one.
|
|
49
|
+
if ('' === f.functionName && 0 === first.startOffset)
|
|
50
|
+
continue;
|
|
51
|
+
const key = `${f.functionName}@${String(first.startOffset)}`;
|
|
52
|
+
const ran = first.count > 0 || true === fns[key]?.executed;
|
|
53
|
+
fns[key] = { name: '' === f.functionName ? '(anonymous)' : f.functionName, executed: ran };
|
|
54
|
+
}
|
|
55
|
+
out[file] = fns;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
export function summarizeCode(coverage) {
|
|
60
|
+
const files = Object.entries(coverage)
|
|
61
|
+
.map(([file, fns]) => {
|
|
62
|
+
const list = Object.values(fns);
|
|
63
|
+
return {
|
|
64
|
+
file,
|
|
65
|
+
functions: list.length,
|
|
66
|
+
executed: list.filter((f) => f.executed).length,
|
|
67
|
+
unexecuted: list.filter((f) => !f.executed).map((f) => f.name),
|
|
68
|
+
};
|
|
69
|
+
})
|
|
70
|
+
.filter((f) => f.functions > 0)
|
|
71
|
+
.sort((a, b) => a.file.localeCompare(b.file));
|
|
72
|
+
return {
|
|
73
|
+
total: files.reduce((n, f) => n + f.functions, 0),
|
|
74
|
+
executed: files.reduce((n, f) => n + f.executed, 0),
|
|
75
|
+
files,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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 type { FileSystemPort } from '../../memory/project/fs/fs-port.js';
|
|
9
|
+
import { type RawScriptCoverage } from './code-coverage.js';
|
|
10
|
+
import { type PortSession } from './session-port.js';
|
|
11
|
+
/** Default action budget: generous, because every edge replays its path from a fresh load. */
|
|
12
|
+
export declare const EXHAUST_DEFAULT_ACTIONS = 400;
|
|
13
|
+
export declare function runExhaustive(input: {
|
|
14
|
+
sessions: {
|
|
15
|
+
get(id: string): PortSession | undefined;
|
|
16
|
+
};
|
|
17
|
+
session: PortSession;
|
|
18
|
+
startUrl: string;
|
|
19
|
+
maxActions: number;
|
|
20
|
+
settleMs: number;
|
|
21
|
+
/** What to type into a field, given the page it is on — a secret is only for its own origin. */
|
|
22
|
+
fillValue: (label: string, pageUrl: string) => string;
|
|
23
|
+
now: () => number;
|
|
24
|
+
sleep: (ms: number) => Promise<void>;
|
|
25
|
+
setMocks?: (sessionUrl: string, rules: {
|
|
26
|
+
urlContains: string;
|
|
27
|
+
method?: string;
|
|
28
|
+
abort?: boolean;
|
|
29
|
+
}[]) => Promise<boolean>;
|
|
30
|
+
takeCode?: (sessionUrl: string) => Promise<readonly RawScriptCoverage[] | undefined>;
|
|
31
|
+
fs: FileSystemPort;
|
|
32
|
+
reticleRoot: string;
|
|
33
|
+
}): Promise<Record<string, unknown>>;
|