@reticlehq/server 2.7.0 → 2.9.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/README.md +14 -5
- package/SKILL.md +130 -778
- package/dist/bridge/bridge.d.ts +23 -0
- package/dist/bridge/bridge.js +80 -2
- package/dist/bridge/bridge.test-harness.d.ts +5 -0
- package/dist/bridge/bridge.test-harness.js +6 -4
- package/dist/capsule/causal-summary.d.ts +36 -1
- package/dist/capsule/causal-summary.js +33 -7
- package/dist/capsule/divergence.js +7 -1
- package/dist/cli/chromium-hint.d.ts +33 -5
- package/dist/cli/chromium-hint.js +82 -12
- package/dist/cli/cli-doctor.js +65 -34
- package/dist/cli/cli-flow-commands.js +8 -8
- package/dist/cli/cli-kill.d.ts +58 -0
- package/dist/cli/cli-kill.js +162 -0
- package/dist/cli/cli-parse.d.ts +16 -1
- package/dist/cli/cli-parse.js +32 -9
- package/dist/cli/cli-port.d.ts +32 -0
- package/dist/cli/cli-port.js +82 -35
- package/dist/cli/cli-verify.d.ts +21 -0
- package/dist/cli/cli-verify.js +47 -3
- package/dist/cli/config-discovery.d.ts +35 -0
- package/dist/cli/config-discovery.js +163 -0
- package/dist/cli/doctor-daemon-line.js +4 -1
- package/dist/cli/doctor-project-line.d.ts +37 -0
- package/dist/cli/doctor-project-line.js +35 -0
- package/dist/cli/doctor-rows.d.ts +51 -0
- package/dist/cli/doctor-rows.js +54 -0
- package/dist/cli/doctor-sessions-line.d.ts +12 -0
- package/dist/cli/doctor-sessions-line.js +69 -0
- package/dist/cli/drive-attach.d.ts +99 -0
- package/dist/cli/drive-attach.js +203 -0
- package/dist/cli/drive-command.d.ts +13 -0
- package/dist/cli/drive-command.js +103 -0
- package/dist/cli/open-note.d.ts +20 -0
- package/dist/cli/open-note.js +37 -0
- package/dist/cli/port-holder.d.ts +10 -0
- package/dist/cli/port-holder.js +26 -2
- package/dist/cli/status-next-action.d.ts +37 -0
- package/dist/cli/status-next-action.js +61 -0
- package/dist/cli.js +119 -39
- package/dist/crawl/crawl-tools.js +12 -2
- package/dist/crawl/crawl.d.ts +13 -0
- package/dist/crawl/crawl.js +50 -2
- package/dist/events/contradictions.d.ts +46 -0
- package/dist/events/contradictions.js +35 -22
- package/dist/events/declared.d.ts +45 -0
- package/dist/events/declared.js +73 -0
- package/dist/events/echo-mismatch.d.ts +8 -2
- package/dist/events/echo-mismatch.js +33 -5
- package/dist/events/event-filters.d.ts +1 -1
- package/dist/events/event-filters.js +7 -4
- package/dist/events/predicate-eval.d.ts +17 -72
- package/dist/events/predicate-eval.js +117 -236
- package/dist/events/predicate-parse.js +31 -3
- package/dist/events/predicate-route.d.ts +32 -0
- package/dist/events/predicate-route.js +102 -0
- package/dist/events/predicate-schema.d.ts +99 -0
- package/dist/events/predicate-schema.js +309 -0
- package/dist/events/predicate.d.ts +8 -0
- package/dist/events/predicate.js +296 -22
- package/dist/flows/annotate-tools.js +3 -0
- package/dist/flows/annotate.js +18 -4
- package/dist/flows/coverage.d.ts +38 -5
- package/dist/flows/coverage.js +30 -4
- package/dist/flows/decision.d.ts +15 -0
- package/dist/flows/decision.js +1 -1
- package/dist/flows/flow-classify.js +1 -1
- package/dist/flows/flow-replay-run.js +8 -1
- package/dist/flows/flow-tools.js +21 -5
- package/dist/flows/heal-run.js +1 -1
- package/dist/flows/replay.js +3 -0
- package/dist/honesty/accepted-write.d.ts +3 -0
- package/dist/honesty/accepted-write.js +8 -3
- package/dist/honesty/already-true.js +15 -0
- package/dist/honesty/blind-spots.js +10 -2
- package/dist/honesty/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +1 -0
- package/dist/honesty/uncaptured-bodies.d.ts +4 -2
- package/dist/honesty/uncaptured-bodies.js +9 -2
- package/dist/honesty/unread-outcome.d.ts +8 -0
- package/dist/honesty/unread-outcome.js +36 -9
- package/dist/honesty/unsettled.d.ts +49 -0
- package/dist/honesty/unsettled.js +82 -0
- package/dist/honesty/verified.d.ts +36 -3
- package/dist/honesty/verified.js +54 -11
- package/dist/http-server.d.ts +19 -0
- package/dist/http-server.js +202 -4
- package/dist/impact/impact-recorder.d.ts +23 -0
- package/dist/impact/impact-recorder.js +76 -0
- package/dist/impact/impact-store.d.ts +47 -0
- package/dist/impact/impact-store.js +171 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +47 -10
- package/dist/init/agent-rules.d.ts +36 -0
- package/dist/init/agent-rules.js +132 -22
- package/dist/init/astro-patch.d.ts +2 -1
- package/dist/init/astro-patch.js +38 -6
- package/dist/init/capabilities.d.ts +22 -0
- package/dist/init/capabilities.js +76 -0
- package/dist/init/confirm.d.ts +114 -0
- package/dist/init/confirm.js +165 -0
- package/dist/init/connect-steps.d.ts +2 -0
- package/dist/init/connect-steps.js +50 -0
- package/dist/init/cra.js +3 -2
- package/dist/init/csp-check.d.ts +38 -0
- package/dist/init/csp-check.js +92 -0
- package/dist/init/csp-doctor.d.ts +25 -0
- package/dist/init/csp-doctor.js +49 -0
- package/dist/init/cursor.js +6 -9
- package/dist/init/detect.d.ts +8 -0
- package/dist/init/detect.js +33 -3
- package/dist/init/mcp-clients.d.ts +1 -0
- package/dist/init/mcp-clients.js +51 -7
- package/dist/init/mcp.d.ts +15 -0
- package/dist/init/mcp.js +19 -0
- package/dist/init/non-js-project.js +29 -5
- package/dist/init/plan-framework.d.ts +10 -0
- package/dist/init/plan-framework.js +78 -16
- package/dist/init/plan.d.ts +47 -6
- package/dist/init/plan.js +228 -69
- package/dist/init/run.d.ts +24 -2
- package/dist/init/run.js +157 -45
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +54 -6
- package/dist/init/snippets.d.ts +45 -5
- package/dist/init/snippets.js +194 -42
- package/dist/init/vite-config.js +53 -5
- package/dist/input/network-mock-tools.d.ts +19 -0
- package/dist/input/network-mock-tools.js +37 -5
- package/dist/input/scroll-find.d.ts +6 -0
- package/dist/input/scroll-find.js +17 -2
- package/dist/input/scroll-tools.js +4 -0
- package/dist/journal/attach-journal.d.ts +9 -1
- package/dist/journal/attach-journal.js +13 -2
- package/dist/journal/journal-query.d.ts +13 -0
- package/dist/journal/journal-query.js +28 -3
- package/dist/journal/workspace-gitignore.d.ts +10 -0
- package/dist/journal/workspace-gitignore.js +59 -0
- package/dist/license/license.js +1 -1
- package/dist/mcp/mcp-proxy.d.ts +35 -30
- package/dist/mcp/mcp-proxy.js +306 -104
- package/dist/mcp/mcp.d.ts +10 -1
- package/dist/mcp/mcp.js +70 -34
- package/dist/mcp/proxy-handshake.d.ts +82 -1
- package/dist/mcp/proxy-handshake.js +109 -2
- package/dist/mcp/proxy-lifecycle.d.ts +11 -2
- package/dist/mcp/proxy-lifecycle.js +12 -3
- package/dist/mcp/proxy-log.d.ts +63 -0
- package/dist/mcp/proxy-log.js +132 -0
- package/dist/mcp/server-instructions.d.ts +17 -0
- package/dist/mcp/server-instructions.js +56 -0
- package/dist/mcp/shared-params.d.ts +27 -0
- package/dist/mcp/shared-params.js +36 -0
- package/dist/pool/browser-pool.d.ts +22 -2
- package/dist/pool/browser-pool.js +44 -4
- package/dist/project/learned-routes.d.ts +24 -0
- package/dist/project/learned-routes.js +63 -0
- package/dist/project/project-store.d.ts +6 -0
- package/dist/project/project-store.js +25 -1
- package/dist/project/reticle-dir.d.ts +2 -0
- package/dist/project/reticle-dir.js +1 -0
- package/dist/session/connection-memory.d.ts +59 -0
- package/dist/session/connection-memory.js +108 -0
- package/dist/session/dev-command.d.ts +26 -0
- package/dist/session/dev-command.js +89 -0
- package/dist/session/dev-server-probe.d.ts +68 -0
- package/dist/session/dev-server-probe.js +102 -7
- package/dist/session/last-act.d.ts +26 -0
- package/dist/session/last-act.js +35 -1
- package/dist/session/live-control-tools.js +42 -5
- package/dist/session/no-session-diagnosis.d.ts +67 -2
- package/dist/session/no-session-diagnosis.js +236 -27
- package/dist/session/no-session-next-action.d.ts +46 -0
- package/dist/session/no-session-next-action.js +85 -0
- package/dist/session/no-session-watch.d.ts +40 -1
- package/dist/session/no-session-watch.js +174 -8
- package/dist/session/pending-commands.d.ts +12 -0
- package/dist/session/pending-commands.js +13 -1
- package/dist/session/ref-provenance.d.ts +54 -0
- package/dist/session/ref-provenance.js +61 -0
- package/dist/session/session-lease.js +1 -1
- package/dist/session/session-manager.d.ts +4 -0
- package/dist/session/session-manager.js +35 -5
- package/dist/session/session.d.ts +10 -1
- package/dist/session/session.js +146 -5
- package/dist/telemetry/app-instrumented.d.ts +8 -0
- package/dist/telemetry/app-instrumented.js +10 -0
- package/dist/telemetry/automation-hint.d.ts +17 -0
- package/dist/telemetry/automation-hint.js +71 -0
- package/dist/telemetry/bug-found.d.ts +15 -17
- package/dist/telemetry/bug-found.js +52 -21
- package/dist/telemetry/cli-telemetry.js +7 -1
- package/dist/telemetry/daemon-telemetry.js +24 -0
- package/dist/telemetry/feedback.js +1 -1
- package/dist/telemetry/init-telemetry.js +9 -1
- package/dist/telemetry/install-source.d.ts +56 -0
- package/dist/telemetry/install-source.js +74 -0
- package/dist/telemetry/instrumentation-stall.d.ts +50 -0
- package/dist/telemetry/instrumentation-stall.js +90 -0
- package/dist/telemetry/run-telemetry.js +4 -1
- package/dist/telemetry/session-metrics.d.ts +8 -0
- package/dist/telemetry/session-metrics.js +15 -0
- package/dist/telemetry/telemetry.d.ts +7 -1
- package/dist/telemetry/telemetry.js +26 -2
- package/dist/telemetry/tool-refused.d.ts +19 -0
- package/dist/telemetry/tool-refused.js +55 -0
- package/dist/telemetry/verification-of.js +46 -7
- package/dist/temp-dir.d.ts +27 -0
- package/dist/temp-dir.js +46 -0
- package/dist/tools/act-tools.js +209 -28
- package/dist/tools/assert-grade.d.ts +1 -1
- package/dist/tools/assert-grade.js +27 -5
- package/dist/tools/assert-verdict.js +49 -7
- package/dist/tools/browser-tools.js +10 -0
- package/dist/tools/contract-tools.js +4 -0
- package/dist/tools/dynamic-tools.js +16 -7
- package/dist/tools/error-recovery.d.ts +10 -0
- package/dist/tools/error-recovery.js +73 -9
- package/dist/tools/feedback-invite.js +1 -1
- package/dist/tools/invoke-tool.js +159 -26
- package/dist/tools/lease-hint.d.ts +30 -8
- package/dist/tools/lease-hint.js +65 -14
- package/dist/tools/lease-tools.d.ts +40 -1
- package/dist/tools/lease-tools.js +181 -83
- package/dist/tools/navigate-result.js +1 -1
- package/dist/tools/observe-tools.js +10 -4
- package/dist/tools/observed-nothing.js +1 -1
- package/dist/tools/react-grace.d.ts +52 -0
- package/dist/tools/react-grace.js +90 -0
- package/dist/tools/read-tools.js +67 -5
- package/dist/tools/resolve-target.d.ts +33 -0
- package/dist/tools/resolve-target.js +53 -0
- package/dist/tools/settle-in-flight.d.ts +94 -0
- package/dist/tools/settle-in-flight.js +146 -0
- package/dist/tools/snapshot-delta.d.ts +10 -0
- package/dist/tools/snapshot-delta.js +194 -9
- package/dist/tools/tool-kit.d.ts +8 -0
- package/dist/tools/tool-surface.d.ts +60 -0
- package/dist/tools/tool-surface.js +86 -1
- package/dist/tools/tools.js +61 -8
- package/dist/tools/unadvertised-help.js +9 -6
- package/docs/README.md +55 -11
- package/docs/actions.mdx +120 -0
- package/docs/agent-cheatsheet.md +95 -29
- package/docs/architecture.md +52 -35
- package/docs/benchmarks.md +127 -75
- package/docs/best-practices.mdx +216 -0
- package/docs/capabilities.mdx +534 -0
- package/docs/cli/affected.mdx +53 -0
- package/docs/cli/capsules.mdx +70 -0
- package/docs/cli/cloud.mdx +153 -0
- package/docs/cli/doctor.mdx +109 -0
- package/docs/cli/drive.mdx +74 -0
- package/docs/cli/feedback.mdx +67 -0
- package/docs/cli/gate.mdx +64 -0
- package/docs/cli/help.mdx +94 -0
- package/docs/cli/hunt.mdx +73 -0
- package/docs/cli/identify.mdx +76 -0
- package/docs/cli/init.mdx +121 -0
- package/docs/cli/kill.mdx +91 -0
- package/docs/cli/license.mdx +48 -0
- package/docs/cli/mcp.mdx +61 -0
- package/docs/cli/open.mdx +74 -0
- package/docs/cli/restart.mdx +60 -0
- package/docs/cli/rollback.mdx +35 -0
- package/docs/cli/serve.mdx +69 -0
- package/docs/cli/status.mdx +88 -0
- package/docs/cli/stop.mdx +64 -0
- package/docs/cli/telemetry.mdx +57 -0
- package/docs/cli/update.mdx +52 -0
- package/docs/cli/verify.mdx +76 -0
- package/docs/cli/version.mdx +39 -0
- package/docs/cli/watch.mdx +62 -0
- package/docs/cli.mdx +241 -0
- package/docs/debugging.md +16 -10
- package/docs/deploy-checks.md +45 -12
- package/docs/desktop.mdx +384 -0
- package/docs/docs.json +364 -0
- package/docs/enterprise.md +22 -16
- package/docs/faq.mdx +102 -0
- package/docs/fixtures.md +13 -7
- package/docs/flows.md +29 -23
- package/docs/for-agents.md +71 -0
- package/docs/frameworks.mdx +238 -0
- package/docs/gate-plan.md +48 -42
- package/docs/gates.md +28 -22
- package/docs/getting-started.md +129 -77
- package/docs/human-control.md +24 -18
- package/docs/index.mdx +143 -0
- package/docs/install-agentic.mdx +224 -0
- package/docs/install-manual.mdx +196 -0
- package/docs/instrumentation.mdx +238 -0
- package/docs/integration-patterns.md +41 -35
- package/docs/local-registry.md +19 -13
- package/docs/multi-agent-testing.md +19 -13
- package/docs/packages/babel-plugin.mdx +79 -0
- package/docs/packages/browser.mdx +111 -0
- package/docs/packages/core.mdx +116 -0
- package/docs/packages/electron.mdx +85 -0
- package/docs/packages/eslint-plugin.mdx +86 -0
- package/docs/packages/next.mdx +89 -0
- package/docs/packages/react.mdx +91 -0
- package/docs/packages/server.mdx +98 -0
- package/docs/packages/tauri.mdx +63 -0
- package/docs/packages/test.mdx +119 -0
- package/docs/packages/vite-plugin.mdx +94 -0
- package/docs/packages.mdx +218 -0
- package/docs/platform-integration.md +35 -26
- package/docs/predicates.mdx +248 -0
- package/docs/quickstart.mdx +257 -0
- package/docs/recipes.mdx +282 -0
- package/docs/skill-file.mdx +107 -0
- package/docs/state-management.mdx +199 -0
- package/docs/style.css +14 -0
- package/docs/system-map.md +50 -33
- package/docs/telemetry-contract.md +106 -52
- package/docs/telemetry-events.mdx +475 -0
- package/docs/telemetry.md +63 -55
- package/docs/testing.md +44 -6
- package/docs/token-efficiency.md +37 -27
- package/docs/tools-act-and-wait.mdx +173 -0
- package/docs/tools-act-sequence.mdx +137 -0
- package/docs/tools-act.mdx +95 -0
- package/docs/tools-assert.mdx +129 -0
- package/docs/tools-console.mdx +63 -0
- package/docs/tools-inspect.mdx +88 -0
- package/docs/tools-navigate.mdx +99 -0
- package/docs/tools-network.mdx +119 -0
- package/docs/tools-observe.mdx +145 -0
- package/docs/tools-overview.mdx +160 -0
- package/docs/tools-query.mdx +126 -0
- package/docs/tools-session-and-feedback.mdx +112 -0
- package/docs/tools-sessions.mdx +94 -0
- package/docs/tools-snapshot.mdx +129 -0
- package/docs/tools-state.mdx +104 -0
- package/docs/tools-tools-and-run.mdx +156 -0
- package/docs/tools-wait-for.mdx +85 -0
- package/docs/troubleshooting.mdx +263 -0
- package/docs/usage.md +209 -165
- package/docs/vs-chrome-devtools-mcp.mdx +123 -0
- package/docs/vs-playwright-mcp.mdx +125 -0
- package/docs/vs-screenshots.mdx +137 -0
- package/docs/what-is-reticle.mdx +63 -0
- package/docs/why-reticle.mdx +109 -0
- package/package.json +5 -5
- package/docs/desktop-apps.md +0 -256
- package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
- package/docs/matrix/2.5.0/codex-machine.json +0 -20
- package/docs/matrix/2.5.0/cursor-machine.json +0 -22
- package/docs/matrix/2.5.0/gemini-machine.json +0 -22
- package/docs/matrix/2.5.0/opencode-machine.json +0 -22
- package/docs/matrix/2.5.0/vscode-machine.json +0 -22
- package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
- package/docs/matrix/MATRIX.md +0 -26
- package/docs/matrix/README.md +0 -59
- package/docs/mint.json +0 -61
package/dist/bridge/bridge.d.ts
CHANGED
|
@@ -15,7 +15,30 @@ type SessionReadyHandler = (session: Session) => void;
|
|
|
15
15
|
export declare const WS_CLOSE_REASON: {
|
|
16
16
|
readonly PROTOCOL_MISMATCH: "protocol version mismatch — upgrade @reticlehq/browser";
|
|
17
17
|
readonly AUTH_FAILED: "authentication failed — reload the page to pick up the current pairing token";
|
|
18
|
+
/**
|
|
19
|
+
* The pool of half-open handshakes was full, so this dial was turned away before it could say
|
|
20
|
+
* anything. Every other refusal here records why and this one did not — it closed the socket and
|
|
21
|
+
* returned — so an app that was running, instrumented and actively dialling looked exactly like an
|
|
22
|
+
* app nobody had started, and the diagnosis went hunting a stopped dev server.
|
|
23
|
+
*
|
|
24
|
+
* Names the cause and the fact that it is transient, because the remedy differs from every other
|
|
25
|
+
* refusal on this path: nothing about the app is wrong and retrying is the correct response.
|
|
26
|
+
*/
|
|
27
|
+
readonly HANDSHAKE_POOL_FULL: string;
|
|
18
28
|
};
|
|
29
|
+
/**
|
|
30
|
+
* A dial turned away at the ORIGIN gate, in words the reader can act on.
|
|
31
|
+
*
|
|
32
|
+
* This is the refusal that used to leave no trace anywhere. It happens before any HELLO, so it
|
|
33
|
+
* produced no `noteClosure`, no `session_disconnected`, nothing but an `origin_rejected` log line —
|
|
34
|
+
* and the daemon log is the file nobody opens until they have already lost the afternoon. Reported
|
|
35
|
+
* from a multi-tenant app resolving its tenant from the `Host` header: on a hosts-file alias every
|
|
36
|
+
* dial was refused here, `doctor` reported every check green, and the lease hint blamed the port.
|
|
37
|
+
*
|
|
38
|
+
* The origin is IN the message because it is the fix: it is either the alias to allow-list, or the
|
|
39
|
+
* proof that the page is not being served from where the developer thinks it is.
|
|
40
|
+
*/
|
|
41
|
+
export declare function originRejectedReason(origin: string | undefined): string;
|
|
19
42
|
interface BridgeOptions {
|
|
20
43
|
port: number;
|
|
21
44
|
host?: string;
|
package/dist/bridge/bridge.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as http from 'node:http';
|
|
2
2
|
import { authFailureReason } from './auth-failure-reason.js';
|
|
3
|
+
import { impactSnapshot, recordImpact } from '../impact/impact-recorder.js';
|
|
3
4
|
import { WebSocketServer } from 'ws';
|
|
4
5
|
import { EventType, HumanControlKind, RETICLE_WS_PATH, ReticleMessageSchema, LOOPBACK_HOST, MessageKind, ReticleEnv, RETICLE_PROTOCOL_VERSION, TRANSPORT_LIMITS, isLocalPage, isLoopbackHostname, isOpaqueOrigin, OPAQUE_ORIGIN, isHighValueEvent, RATE_CAP_HIGH_VALUE_RESERVE_RATIO, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
|
|
5
6
|
import { Session, SessionManager } from '../session/session.js';
|
|
@@ -38,7 +39,39 @@ function refreshIdentity(session, hello) {
|
|
|
38
39
|
export const WS_CLOSE_REASON = {
|
|
39
40
|
PROTOCOL_MISMATCH: 'protocol version mismatch — upgrade @reticlehq/browser',
|
|
40
41
|
AUTH_FAILED: 'authentication failed — reload the page to pick up the current pairing token',
|
|
42
|
+
/**
|
|
43
|
+
* The pool of half-open handshakes was full, so this dial was turned away before it could say
|
|
44
|
+
* anything. Every other refusal here records why and this one did not — it closed the socket and
|
|
45
|
+
* returned — so an app that was running, instrumented and actively dialling looked exactly like an
|
|
46
|
+
* app nobody had started, and the diagnosis went hunting a stopped dev server.
|
|
47
|
+
*
|
|
48
|
+
* Names the cause and the fact that it is transient, because the remedy differs from every other
|
|
49
|
+
* refusal on this path: nothing about the app is wrong and retrying is the correct response.
|
|
50
|
+
*/
|
|
51
|
+
HANDSHAKE_POOL_FULL: 'refused at the handshake pool — too many connections were mid-handshake on this daemon, so ' +
|
|
52
|
+
'this dial never got to identify itself. Nothing is wrong with the app; the pool drains on its ' +
|
|
53
|
+
'own and reloading the page reconnects. If it persists, something else on this machine is ' +
|
|
54
|
+
'opening sockets to this port.',
|
|
41
55
|
};
|
|
56
|
+
/**
|
|
57
|
+
* A dial turned away at the ORIGIN gate, in words the reader can act on.
|
|
58
|
+
*
|
|
59
|
+
* This is the refusal that used to leave no trace anywhere. It happens before any HELLO, so it
|
|
60
|
+
* produced no `noteClosure`, no `session_disconnected`, nothing but an `origin_rejected` log line —
|
|
61
|
+
* and the daemon log is the file nobody opens until they have already lost the afternoon. Reported
|
|
62
|
+
* from a multi-tenant app resolving its tenant from the `Host` header: on a hosts-file alias every
|
|
63
|
+
* dial was refused here, `doctor` reported every check green, and the lease hint blamed the port.
|
|
64
|
+
*
|
|
65
|
+
* The origin is IN the message because it is the fix: it is either the alias to allow-list, or the
|
|
66
|
+
* proof that the page is not being served from where the developer thinks it is.
|
|
67
|
+
*/
|
|
68
|
+
export function originRejectedReason(origin) {
|
|
69
|
+
const named = origin === undefined || 0 === origin.length ? 'a page sending no Origin' : origin;
|
|
70
|
+
return (`a browser dialled this daemon and was REFUSED at the origin gate: ${named} is not allowed. ` +
|
|
71
|
+
'Off localhost the SDK needs BOTH `allowNonLocalhost: true` AND a pairing token, and the ' +
|
|
72
|
+
'daemon needs that origin allow-listed (RETICLE_ALLOWED_ORIGINS). The app is running and ' +
|
|
73
|
+
'instrumented — it was turned away, so do not go looking for a stopped dev server.');
|
|
74
|
+
}
|
|
42
75
|
/** WS close codes + reasons the bridge sends to the SDK (1008 = policy violation, 1013 = try again later). */
|
|
43
76
|
const WS_CLOSE = {
|
|
44
77
|
TOO_MANY_HANDSHAKES: [1013, 'too many pending handshakes'],
|
|
@@ -135,6 +168,8 @@ export class Bridge {
|
|
|
135
168
|
* authFailureReason.
|
|
136
169
|
*/
|
|
137
170
|
#servedProjects = new Set();
|
|
171
|
+
/** Session ids already counted in the impact record - a reconnect is not a new session. */
|
|
172
|
+
#countedSessions = new Set();
|
|
138
173
|
#allowedOrigins;
|
|
139
174
|
#maxMessagesPerSecond;
|
|
140
175
|
#maxSessions;
|
|
@@ -198,7 +233,7 @@ export class Bridge {
|
|
|
198
233
|
verifyClient: ({ origin }, done) => {
|
|
199
234
|
const allowed = this.#originAllowed(origin);
|
|
200
235
|
if (!allowed)
|
|
201
|
-
|
|
236
|
+
this.#noteOriginRejected(origin);
|
|
202
237
|
done(allowed, 403, 'Forbidden');
|
|
203
238
|
},
|
|
204
239
|
});
|
|
@@ -229,7 +264,7 @@ export class Bridge {
|
|
|
229
264
|
verifyClient: ({ origin }, done) => {
|
|
230
265
|
const allowed = this.#originAllowed(origin);
|
|
231
266
|
if (!allowed)
|
|
232
|
-
|
|
267
|
+
this.#noteOriginRejected(origin);
|
|
233
268
|
done(allowed, 403, 'Forbidden');
|
|
234
269
|
},
|
|
235
270
|
});
|
|
@@ -244,12 +279,27 @@ export class Bridge {
|
|
|
244
279
|
});
|
|
245
280
|
});
|
|
246
281
|
}
|
|
282
|
+
// A PERMANENT 'error' listener, on top of the one `ready` installs and then removes.
|
|
283
|
+
//
|
|
284
|
+
// `ws` re-emits every 'error' from the server it is attached to, and both `ready` branches above
|
|
285
|
+
// drop their listener the moment the port is bound. From then on the emitter is bare, and an
|
|
286
|
+
// EventEmitter that emits 'error' with no listener THROWS — which in the daemon is an
|
|
287
|
+
// uncaughtException, which is an exit, which takes every agent and every session on this port
|
|
288
|
+
// with it. Logging one is all it takes; the transport itself needs no other reaction.
|
|
289
|
+
this.#wss.on('error', (err) => {
|
|
290
|
+
log('bridge_ws_error', { error: err.message });
|
|
291
|
+
});
|
|
247
292
|
this.#wss.on('connection', (socket) => {
|
|
248
293
|
this.#onConnection(socket);
|
|
249
294
|
});
|
|
250
295
|
}
|
|
251
296
|
#onConnection(socket) {
|
|
252
297
|
if (this.#pendingConnections >= this.#maxPendingConnections) {
|
|
298
|
+
// On the channel the no-session diagnosis reads, not only into the log. This refusal happens
|
|
299
|
+
// before any HELLO, so without it the dial leaves no trace at all — the same hole the origin
|
|
300
|
+
// gate had, and the same fix.
|
|
301
|
+
this.sessions.noteClosure(WS_CLOSE_REASON.HANDSHAKE_POOL_FULL, this.#clock());
|
|
302
|
+
log('bridge_handshake_pool_full', { pending: this.#pendingConnections });
|
|
253
303
|
socket.close(...WS_CLOSE.TOO_MANY_HANDSHAKES);
|
|
254
304
|
return;
|
|
255
305
|
}
|
|
@@ -362,6 +412,16 @@ export class Bridge {
|
|
|
362
412
|
if (parsed.projectId !== undefined)
|
|
363
413
|
this.#servedProjects.add(parsed.projectId);
|
|
364
414
|
this.#onSessionCreate?.(session); // attach the durable journal before any events stream in
|
|
415
|
+
// A tab that has just connected has no impact record yet, so the report would read "nothing
|
|
416
|
+
// recorded" over a file with a month of history in it. Push what is already on disk as soon
|
|
417
|
+
// as there is somewhere to push it to; a session is also a thing that HAPPENED, so it counts.
|
|
418
|
+
// A reconnecting tab keeps its id, so counting every connect made a page reload look like
|
|
419
|
+
// a fresh session - the number climbed while nothing new happened.
|
|
420
|
+
if (!this.#countedSessions.has(session.id)) {
|
|
421
|
+
this.#countedSessions.add(session.id);
|
|
422
|
+
recordImpact({ sessions: 1 });
|
|
423
|
+
}
|
|
424
|
+
session.pushImpact(impactSnapshot, true);
|
|
365
425
|
const replaced = this.sessions.add(session);
|
|
366
426
|
if (replaced !== undefined) {
|
|
367
427
|
// Name the newcomer. A field report had a live session vanish during `reticle_lease` and the
|
|
@@ -374,6 +434,10 @@ export class Bridge {
|
|
|
374
434
|
byUrl: session.url,
|
|
375
435
|
previousUrl: replaced.url,
|
|
376
436
|
});
|
|
437
|
+
// Hand the displaced session its replacement BEFORE ending it, so a tool call still holding
|
|
438
|
+
// the old handle can finish against the live connection instead of returning an error whose
|
|
439
|
+
// only answer is to go and rediscover an id that has not changed. See Session.succeededBy.
|
|
440
|
+
replaced.succeededBy(session);
|
|
377
441
|
replaced.disconnect(`session replaced by a newer connection claiming the same id (${session.id}) from ${session.url}`);
|
|
378
442
|
}
|
|
379
443
|
// The daemon is the single judge of skew, and HELLO is where the page announces itself.
|
|
@@ -448,6 +512,20 @@ export class Bridge {
|
|
|
448
512
|
log('socket_error', { error: err.message });
|
|
449
513
|
});
|
|
450
514
|
}
|
|
515
|
+
/**
|
|
516
|
+
* Record a refused dial where something OTHER than the daemon log can read it.
|
|
517
|
+
*
|
|
518
|
+
* The log line stays — it is the forensic record — but a line in `~/.reticle/daemon-<port>.log` is
|
|
519
|
+
* invisible to the agent that is, at that exact moment, being told "no browser session connected"
|
|
520
|
+
* with no reason attached. `noteClosure` is the channel the no-session diagnosis already reads, so
|
|
521
|
+
* the refusal now travels the same route as an auth failure and a protocol mismatch, and the loop
|
|
522
|
+
* (the reason is only in the page console; the console needs a session; the refusal prevents one)
|
|
523
|
+
* is broken from the daemon side.
|
|
524
|
+
*/
|
|
525
|
+
#noteOriginRejected(origin) {
|
|
526
|
+
log('origin_rejected', { origin: origin ?? 'missing' });
|
|
527
|
+
this.sessions.noteClosure(originRejectedReason(origin), this.#clock());
|
|
528
|
+
}
|
|
451
529
|
#originAllowed(origin) {
|
|
452
530
|
// A real browser SDK connection always carries an Origin. A missing Origin means a non-browser
|
|
453
531
|
// local process (another app, a malicious npm postinstall, an extension's native host) — untrusted
|
|
@@ -25,6 +25,11 @@ export declare class FakeBrowser {
|
|
|
25
25
|
name: string;
|
|
26
26
|
args: Record<string, unknown>;
|
|
27
27
|
}[];
|
|
28
|
+
/** The snapshot tree to return. Mutable so tests can vary the page between calls. */
|
|
29
|
+
snapshotResult: {
|
|
30
|
+
tree: string;
|
|
31
|
+
status: Record<string, unknown>;
|
|
32
|
+
};
|
|
28
33
|
constructor(port: number, sessionId: string, hasCapabilities?: boolean);
|
|
29
34
|
open(): Promise<void>;
|
|
30
35
|
/** Re-send the HELLO on the live socket, the way the SDK does when capabilities register late. */
|
|
@@ -32,6 +32,11 @@ export class FakeBrowser {
|
|
|
32
32
|
queryResolves = true;
|
|
33
33
|
/** Records every command the bridge sent (for replay assertions). */
|
|
34
34
|
received = [];
|
|
35
|
+
/** The snapshot tree to return. Mutable so tests can vary the page between calls. */
|
|
36
|
+
snapshotResult = {
|
|
37
|
+
tree: '- button "Pay" (ref=e7)\n- dialog "Order confirmed" (ref=e12)',
|
|
38
|
+
status: { route: '/checkout' },
|
|
39
|
+
};
|
|
35
40
|
constructor(port, sessionId, hasCapabilities = false) {
|
|
36
41
|
this.sessionId = sessionId;
|
|
37
42
|
this.hasCapabilities = hasCapabilities;
|
|
@@ -147,10 +152,7 @@ export class FakeBrowser {
|
|
|
147
152
|
};
|
|
148
153
|
}
|
|
149
154
|
else if (name === ReticleCommand.SNAPSHOT) {
|
|
150
|
-
result =
|
|
151
|
-
tree: '- button "Pay" (ref=e7)\n- dialog "Order confirmed" (ref=e12)',
|
|
152
|
-
status: { route: '/checkout' },
|
|
153
|
-
};
|
|
155
|
+
result = this.snapshotResult;
|
|
154
156
|
}
|
|
155
157
|
else if (name === ReticleCommand.CAPABILITIES) {
|
|
156
158
|
if (!this.handlesCapabilities) {
|
|
@@ -56,9 +56,44 @@ export interface CausalSummary {
|
|
|
56
56
|
stateSettleMs?: number;
|
|
57
57
|
/** Before→after for each changed storage key. Values capped. */
|
|
58
58
|
storageDiffs: StorageDiff[];
|
|
59
|
+
/**
|
|
60
|
+
* How many entries each list dropped to stay within MAX_SUMMARY_ENTRIES. Present only when
|
|
61
|
+
* something was dropped, so a trim is never silent — the same rule `ignoredDevTooling` follows.
|
|
62
|
+
*/
|
|
63
|
+
elided?: Partial<Record<CappedList, number>>;
|
|
59
64
|
route?: string;
|
|
60
65
|
signals: string[];
|
|
61
66
|
layoutShift?: number;
|
|
62
67
|
longTasks: number;
|
|
68
|
+
/**
|
|
69
|
+
* TRUE when no store was subscribed, so `statePathsChanged`/`stateDiffs` are empty because nothing
|
|
70
|
+
* was watching — not because the app changed nothing.
|
|
71
|
+
*
|
|
72
|
+
* The two readings are the same empty list, and the wrong one is the one an agent takes: "state
|
|
73
|
+
* did not change" is a claim about the app, and this codebase would rather say "I could not see"
|
|
74
|
+
* than say something confident and wrong. Present only when the channel is dark, so a wired app
|
|
75
|
+
* pays nothing and the field's PRESENCE is what carries the meaning — the same rule
|
|
76
|
+
* `stateSettleMs` and `elided` follow.
|
|
77
|
+
*/
|
|
78
|
+
stateUnwatched?: true;
|
|
79
|
+
}
|
|
80
|
+
/** What only the SESSION knows — level facts the event window cannot contain. See `stateUnwatched`. */
|
|
81
|
+
export interface SummaryContext {
|
|
82
|
+
stateUnwatched?: boolean;
|
|
63
83
|
}
|
|
64
|
-
|
|
84
|
+
/**
|
|
85
|
+
* THE INVARIANT: every field of this summary has a bounded size that does not depend on how much the
|
|
86
|
+
* app did. A verdict travels in the same payload as its evidence, so an unbounded evidence field is
|
|
87
|
+
* an unbounded chance that the verdict is the part a client truncates — and an agent that cannot read
|
|
88
|
+
* the verdict has run the verification and got nothing, which is indistinguishable from not running
|
|
89
|
+
* it. Measured: one click on a page with a registered TanStack Query store returned ~363KB, because
|
|
90
|
+
* `capValue` bounded each diff and nothing bounded the COUNT of them.
|
|
91
|
+
*
|
|
92
|
+
* 20 keeps the signal that is actually read — the first paths to move, and the settle window over all
|
|
93
|
+
* of them — while the full names of everything that changed stay one `reticle_state` call away.
|
|
94
|
+
*/
|
|
95
|
+
export declare const MAX_SUMMARY_ENTRIES = 20;
|
|
96
|
+
/** The lists that are capped, named so `elided` can say which one lost entries. */
|
|
97
|
+
type CappedList = 'statePathsChanged' | 'storageKeysChanged' | 'stateDiffs' | 'storageDiffs' | 'signals';
|
|
98
|
+
export declare function causalSummary(events: readonly ReticleEvent[], context?: SummaryContext): CausalSummary;
|
|
99
|
+
export {};
|
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
import { EventType, PerfMetric, isDevToolingUrl } from '@reticlehq/core';
|
|
2
|
+
/**
|
|
3
|
+
* THE INVARIANT: every field of this summary has a bounded size that does not depend on how much the
|
|
4
|
+
* app did. A verdict travels in the same payload as its evidence, so an unbounded evidence field is
|
|
5
|
+
* an unbounded chance that the verdict is the part a client truncates — and an agent that cannot read
|
|
6
|
+
* the verdict has run the verification and got nothing, which is indistinguishable from not running
|
|
7
|
+
* it. Measured: one click on a page with a registered TanStack Query store returned ~363KB, because
|
|
8
|
+
* `capValue` bounded each diff and nothing bounded the COUNT of them.
|
|
9
|
+
*
|
|
10
|
+
* 20 keeps the signal that is actually read — the first paths to move, and the settle window over all
|
|
11
|
+
* of them — while the full names of everything that changed stay one `reticle_state` call away.
|
|
12
|
+
*/
|
|
13
|
+
export const MAX_SUMMARY_ENTRIES = 20;
|
|
14
|
+
/** Trim one list to the cap and report the loss. Order is arrival order, so the head is the earliest. */
|
|
15
|
+
function cap(list, name, elided) {
|
|
16
|
+
if (list.length <= MAX_SUMMARY_ENTRIES)
|
|
17
|
+
return list;
|
|
18
|
+
elided[name] = list.length - MAX_SUMMARY_ENTRIES;
|
|
19
|
+
return list.slice(0, MAX_SUMMARY_ENTRIES);
|
|
20
|
+
}
|
|
2
21
|
function pushUnique(list, value) {
|
|
3
22
|
if ('string' === typeof value && value.length > 0 && !list.includes(value))
|
|
4
23
|
list.push(value);
|
|
@@ -37,7 +56,7 @@ function stateSettle(diffs) {
|
|
|
37
56
|
const spread = Math.max(...times) - Math.min(...times);
|
|
38
57
|
return spread > 0 ? { stateSettleMs: spread } : {};
|
|
39
58
|
}
|
|
40
|
-
export function causalSummary(events) {
|
|
59
|
+
export function causalSummary(events, context = {}) {
|
|
41
60
|
let netTotal = 0;
|
|
42
61
|
let netErrors = 0;
|
|
43
62
|
let ignoredDevTooling = 0;
|
|
@@ -118,6 +137,11 @@ export function causalSummary(events) {
|
|
|
118
137
|
break;
|
|
119
138
|
}
|
|
120
139
|
}
|
|
140
|
+
const elided = {};
|
|
141
|
+
// `stateSettleMs` is computed over EVERY diff, before the cap: the interval is a fact about the
|
|
142
|
+
// store, and shrinking it because the evidence was trimmed would make the transient window read as
|
|
143
|
+
// narrower than it was — the one number here that must survive the trim intact.
|
|
144
|
+
const settle = stateSettle(stateDiffs);
|
|
121
145
|
return {
|
|
122
146
|
net: {
|
|
123
147
|
total: netTotal,
|
|
@@ -126,14 +150,16 @@ export function causalSummary(events) {
|
|
|
126
150
|
...(headline === undefined ? {} : { headline }),
|
|
127
151
|
},
|
|
128
152
|
consoleErrors,
|
|
129
|
-
statePathsChanged,
|
|
130
|
-
storageKeysChanged,
|
|
131
|
-
stateDiffs,
|
|
132
|
-
...
|
|
133
|
-
storageDiffs,
|
|
153
|
+
statePathsChanged: cap(statePathsChanged, 'statePathsChanged', elided),
|
|
154
|
+
storageKeysChanged: cap(storageKeysChanged, 'storageKeysChanged', elided),
|
|
155
|
+
stateDiffs: cap(stateDiffs, 'stateDiffs', elided),
|
|
156
|
+
...settle,
|
|
157
|
+
storageDiffs: cap(storageDiffs, 'storageDiffs', elided),
|
|
134
158
|
...(route === undefined ? {} : { route }),
|
|
135
|
-
signals,
|
|
159
|
+
signals: cap(signals, 'signals', elided),
|
|
160
|
+
...(0 === Object.keys(elided).length ? {} : { elided }),
|
|
136
161
|
...(layoutShift === undefined ? {} : { layoutShift }),
|
|
137
162
|
longTasks,
|
|
163
|
+
...(true === context.stateUnwatched ? { stateUnwatched: true } : {}),
|
|
138
164
|
};
|
|
139
165
|
}
|
|
@@ -7,7 +7,13 @@ function satisfies(link, events) {
|
|
|
7
7
|
case ConsequenceKind.SIGNAL:
|
|
8
8
|
return events.some((e) => e.type === EventType.SIGNAL && e.data['name'] === link.name);
|
|
9
9
|
case ConsequenceKind.STATE:
|
|
10
|
-
|
|
10
|
+
// Matched against the store name OR the changed path, because the link carries whichever half
|
|
11
|
+
// the predicate gave: `store` when it named one, and `path` otherwise (predicateToExpectedLinks).
|
|
12
|
+
// The event carries both. Comparing only the store meant every assertion written by path
|
|
13
|
+
// reported "never changed" however the app behaved — a false claim printed beside a `stateDiffs`
|
|
14
|
+
// entry showing that exact path changing.
|
|
15
|
+
return events.some((e) => e.type === EventType.STATE_CHANGE &&
|
|
16
|
+
(e.data['name'] === link.name || e.data['path'] === link.name));
|
|
11
17
|
case ConsequenceKind.NET:
|
|
12
18
|
return net(events).some((e) => 'string' === typeof e.data['url'] &&
|
|
13
19
|
e.data['url'].includes(link.urlContains) &&
|
|
@@ -7,9 +7,18 @@
|
|
|
7
7
|
* says missing afterwards. Reported from the field by an agent whose machine already had five
|
|
8
8
|
* chromium builds, none of them the wanted one, with no way to learn which one was wanted.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The same reporter came back, because pinning the command fixes only half of it. Their five builds
|
|
11
|
+
* sat in the standard Windows browsers root and the line still read a flat `missing`, which leaves
|
|
12
|
+
* two readings — "the check is broken" and "none of these count" — and no way to choose. Present at
|
|
13
|
+
* the wrong revision and absent entirely are different problems: one is a single pinned download,
|
|
14
|
+
* the other means the lookup is aimed at a root nothing was ever installed into. Collapsing them is
|
|
15
|
+
* what made the loop unbreakable.
|
|
16
|
+
*
|
|
17
|
+
* Three things fix it, and they are what any unsatisfiable check needs: pin the command to the
|
|
18
|
+
* version doing the asking, say what was probed, and separate the verdicts that have different
|
|
19
|
+
* fixes. The wanted revision and the browsers root are both read back off the path Playwright itself
|
|
20
|
+
* resolves, so `PLAYWRIGHT_BROWSERS_PATH` and every platform default are honoured by construction
|
|
21
|
+
* rather than by a table of roots this file would have to keep in step with Playwright's.
|
|
13
22
|
*/
|
|
14
23
|
/** What the check found, gathered by the caller so this stays pure and testable. */
|
|
15
24
|
export interface ChromiumProbe {
|
|
@@ -19,6 +28,12 @@ export interface ChromiumProbe {
|
|
|
19
28
|
exists: boolean;
|
|
20
29
|
/** Version of the playwright the DAEMON bundles — not whatever `npx` would fetch. */
|
|
21
30
|
playwrightVersion?: string | undefined;
|
|
31
|
+
/** The revision that path names, e.g. `chromium-1223`. Undefined if the path has no revision in it. */
|
|
32
|
+
wantedRevision?: string | undefined;
|
|
33
|
+
/** The browsers root the lookup used, wherever `PLAYWRIGHT_BROWSERS_PATH` and the platform put it. */
|
|
34
|
+
browsersRoot?: string | undefined;
|
|
35
|
+
/** Chromium revisions actually sitting in that root, wanted or not. */
|
|
36
|
+
installedRevisions?: readonly string[] | undefined;
|
|
22
37
|
}
|
|
23
38
|
/**
|
|
24
39
|
* The install command, pinned when we know what to pin to.
|
|
@@ -27,11 +42,24 @@ export interface ChromiumProbe {
|
|
|
27
42
|
* at all is a worse one.
|
|
28
43
|
*/
|
|
29
44
|
export declare function chromiumInstallCommand(playwrightVersion?: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Split a resolved executable path into the browsers root and the revision under it, or null.
|
|
47
|
+
*
|
|
48
|
+
* Reading them back off Playwright's own answer is the point. The alternative is reconstructing the
|
|
49
|
+
* root from `PLAYWRIGHT_BROWSERS_PATH` plus a per-platform default, which is a copy of Playwright's
|
|
50
|
+
* resolution rules that drifts the first time they change one — and getting it wrong here means
|
|
51
|
+
* telling a user their browsers are missing while looking in the wrong directory, which is the exact
|
|
52
|
+
* failure this file exists to stop.
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseChromiumRevision(executablePath: string): {
|
|
55
|
+
revision: string;
|
|
56
|
+
root: string;
|
|
57
|
+
} | null;
|
|
30
58
|
/**
|
|
31
59
|
* Ask the playwright THIS process would use, not the one `npx` would fetch.
|
|
32
60
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
61
|
+
* `executablePath()` resolves the revision the daemon will actually launch AND the root it will look
|
|
62
|
+
* in, which is why the probe never reads `PLAYWRIGHT_BROWSERS_PATH` itself.
|
|
35
63
|
*/
|
|
36
64
|
export declare function probeChromium(): Promise<ChromiumProbe>;
|
|
37
65
|
/** The bundled playwright's version, or undefined if it cannot be read (the hint then goes unpinned). */
|
|
@@ -7,12 +7,31 @@
|
|
|
7
7
|
* says missing afterwards. Reported from the field by an agent whose machine already had five
|
|
8
8
|
* chromium builds, none of them the wanted one, with no way to learn which one was wanted.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The same reporter came back, because pinning the command fixes only half of it. Their five builds
|
|
11
|
+
* sat in the standard Windows browsers root and the line still read a flat `missing`, which leaves
|
|
12
|
+
* two readings — "the check is broken" and "none of these count" — and no way to choose. Present at
|
|
13
|
+
* the wrong revision and absent entirely are different problems: one is a single pinned download,
|
|
14
|
+
* the other means the lookup is aimed at a root nothing was ever installed into. Collapsing them is
|
|
15
|
+
* what made the loop unbreakable.
|
|
16
|
+
*
|
|
17
|
+
* Three things fix it, and they are what any unsatisfiable check needs: pin the command to the
|
|
18
|
+
* version doing the asking, say what was probed, and separate the verdicts that have different
|
|
19
|
+
* fixes. The wanted revision and the browsers root are both read back off the path Playwright itself
|
|
20
|
+
* resolves, so `PLAYWRIGHT_BROWSERS_PATH` and every platform default are honoured by construction
|
|
21
|
+
* rather than by a table of roots this file would have to keep in step with Playwright's.
|
|
13
22
|
*/
|
|
14
|
-
import { existsSync } from 'node:fs';
|
|
23
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
15
24
|
import { createRequire } from 'node:module';
|
|
25
|
+
/**
|
|
26
|
+
* A browsers-root entry that is a chromium build.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately not matching `chromium_headless_shell-*`: it lives beside the full build and is a
|
|
29
|
+
* different artifact, so counting it would let a root holding only headless shells report the full
|
|
30
|
+
* browser as present-but-mismatched.
|
|
31
|
+
*/
|
|
32
|
+
const CHROMIUM_DIR = /^chromium-\d+$/;
|
|
33
|
+
/** The same directory name as a path segment, on either separator, so Windows paths split too. */
|
|
34
|
+
const CHROMIUM_SEGMENT = /[\\/](chromium-\d+)[\\/]/;
|
|
16
35
|
/**
|
|
17
36
|
* The install command, pinned when we know what to pin to.
|
|
18
37
|
*
|
|
@@ -23,20 +42,58 @@ export function chromiumInstallCommand(playwrightVersion) {
|
|
|
23
42
|
const pin = playwrightVersion === undefined ? 'playwright' : `playwright@${playwrightVersion}`;
|
|
24
43
|
return `npx ${pin} install chromium`;
|
|
25
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Split a resolved executable path into the browsers root and the revision under it, or null.
|
|
47
|
+
*
|
|
48
|
+
* Reading them back off Playwright's own answer is the point. The alternative is reconstructing the
|
|
49
|
+
* root from `PLAYWRIGHT_BROWSERS_PATH` plus a per-platform default, which is a copy of Playwright's
|
|
50
|
+
* resolution rules that drifts the first time they change one — and getting it wrong here means
|
|
51
|
+
* telling a user their browsers are missing while looking in the wrong directory, which is the exact
|
|
52
|
+
* failure this file exists to stop.
|
|
53
|
+
*/
|
|
54
|
+
export function parseChromiumRevision(executablePath) {
|
|
55
|
+
const match = CHROMIUM_SEGMENT.exec(executablePath);
|
|
56
|
+
if (null === match)
|
|
57
|
+
return null;
|
|
58
|
+
const revision = match[1];
|
|
59
|
+
if (revision === undefined)
|
|
60
|
+
return null;
|
|
61
|
+
return { revision, root: executablePath.slice(0, match.index) };
|
|
62
|
+
}
|
|
63
|
+
/** Chromium revisions present in a browsers root. Empty on an unreadable or absent root. */
|
|
64
|
+
function installedRevisions(root) {
|
|
65
|
+
try {
|
|
66
|
+
return readdirSync(root)
|
|
67
|
+
.filter((entry) => CHROMIUM_DIR.test(entry))
|
|
68
|
+
.sort();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// No root at all is the commonest case here and is not an error: it means nothing was installed.
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
26
75
|
/**
|
|
27
76
|
* Ask the playwright THIS process would use, not the one `npx` would fetch.
|
|
28
77
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
78
|
+
* `executablePath()` resolves the revision the daemon will actually launch AND the root it will look
|
|
79
|
+
* in, which is why the probe never reads `PLAYWRIGHT_BROWSERS_PATH` itself.
|
|
31
80
|
*/
|
|
32
81
|
export async function probeChromium() {
|
|
33
82
|
try {
|
|
34
83
|
const { chromium } = await import('playwright');
|
|
35
84
|
const executablePath = chromium.executablePath();
|
|
85
|
+
const parsed = parseChromiumRevision(executablePath);
|
|
36
86
|
return {
|
|
37
87
|
executablePath,
|
|
38
88
|
exists: existsSync(executablePath),
|
|
39
89
|
playwrightVersion: bundledPlaywrightVersion(),
|
|
90
|
+
...(null === parsed
|
|
91
|
+
? {}
|
|
92
|
+
: {
|
|
93
|
+
wantedRevision: parsed.revision,
|
|
94
|
+
browsersRoot: parsed.root,
|
|
95
|
+
installedRevisions: installedRevisions(parsed.root),
|
|
96
|
+
}),
|
|
40
97
|
};
|
|
41
98
|
}
|
|
42
99
|
catch {
|
|
@@ -62,14 +119,27 @@ export function bundledPlaywrightVersion() {
|
|
|
62
119
|
}
|
|
63
120
|
/** The doctor line for the Chromium check — verdict, what was probed, and how to satisfy it. */
|
|
64
121
|
export function chromiumHint(probe) {
|
|
65
|
-
if (probe.exists)
|
|
66
|
-
|
|
122
|
+
if (probe.exists) {
|
|
123
|
+
// Naming the revision on the happy line too: it is the number the mismatch line talks about, and
|
|
124
|
+
// a reader comparing two machines has nothing to compare without it.
|
|
125
|
+
return probe.wantedRevision === undefined
|
|
126
|
+
? '✓ installed'
|
|
127
|
+
: `✓ installed (${probe.wantedRevision})`;
|
|
128
|
+
}
|
|
67
129
|
const command = chromiumInstallCommand(probe.playwrightVersion);
|
|
130
|
+
if (probe.executablePath === undefined) {
|
|
131
|
+
return `✗ the playwright package is not installed; run: ${command}`;
|
|
132
|
+
}
|
|
133
|
+
const present = probe.installedRevisions ?? [];
|
|
134
|
+
if (present.length > 0 && probe.wantedRevision !== undefined) {
|
|
135
|
+
// The reported machine. Every fact here is one the reader could not otherwise get: what is
|
|
136
|
+
// wanted, what is there, and where "there" is. `npx playwright install chromium` unpinned adds
|
|
137
|
+
// a build to this list and changes nothing, which is what happened.
|
|
138
|
+
return (`✗ wrong revision — the bundled playwright wants ${probe.wantedRevision}; ` +
|
|
139
|
+
`${probe.browsersRoot ?? ''} holds ${present.join(', ')}. run: ${command}`);
|
|
140
|
+
}
|
|
68
141
|
// Naming the path is what turns "missing" from a verdict into evidence: a reader with browsers on
|
|
69
142
|
// disk can see immediately whether this is a missing install or a lookup pointed somewhere else
|
|
70
143
|
// (PLAYWRIGHT_BROWSERS_PATH, a different browsers root, a revision bump).
|
|
71
|
-
|
|
72
|
-
? 'the playwright package is not installed'
|
|
73
|
-
: `looked for ${probe.executablePath}`;
|
|
74
|
-
return `✗ missing — ${probed}; run: ${command}`;
|
|
144
|
+
return `✗ missing — looked for ${probe.executablePath}; run: ${command}`;
|
|
75
145
|
}
|