@reticlehq/test 3.2.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/dist/boot.d.ts +44 -0
- package/dist/boot.js +106 -3
- package/package.json +4 -4
package/dist/boot.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { start } from '@reticlehq/server';
|
|
1
2
|
import type { RunningServer, ToolDeps, ToolInvoker } from '@reticlehq/server';
|
|
2
3
|
export interface BootedRun {
|
|
3
4
|
invoke: ToolInvoker;
|
|
@@ -15,10 +16,53 @@ export interface BootOptions {
|
|
|
15
16
|
now?: () => number;
|
|
16
17
|
/** Injectable ToolDeps builder so tests can wire a fake server without real IO. */
|
|
17
18
|
buildDeps?: (server: RunningServer) => ToolDeps;
|
|
19
|
+
/**
|
|
20
|
+
* Injectable `start`, defaulting to `@reticlehq/server`'s real one. The only reason to pass this is
|
|
21
|
+
* a test that needs to drive the `bridge.ready` rejection/cleanup path below deterministically — a
|
|
22
|
+
* real bind-race is, by construction, not something a test can reliably force to lose without timing
|
|
23
|
+
* it, which CLAUDE.md already treats as a bug in the test (reticlehq/reticle#1165 review, "Readiness
|
|
24
|
+
* failure lacks test coverage").
|
|
25
|
+
*/
|
|
26
|
+
startServer?: typeof start;
|
|
18
27
|
}
|
|
19
28
|
/**
|
|
20
29
|
* Production wiring: launch a headless real-input browser against `driveUrl`, then expose a
|
|
21
30
|
* programmatic ToolInvoker over it (no MCP/stdio). Tests inject a fake invoker into runSpecs
|
|
22
31
|
* directly and never reach this path.
|
|
32
|
+
*
|
|
33
|
+
* Probes the port before dialing it, the same way `drive`/`verify`/`status` already do
|
|
34
|
+
* (`probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus })`), because `start()` used to
|
|
35
|
+
* be the FIRST thing that found out — and on any machine already running a Reticle daemon, that is
|
|
36
|
+
* every machine with Reticle set up, a caller of this library function got the raw EADDRINUSE stack
|
|
37
|
+
* instead of a sentence it could act on (reticlehq/reticle#1141).
|
|
38
|
+
*
|
|
39
|
+
* The probe can't close the window between "nothing was listening" and the bind that follows, and —
|
|
40
|
+
* unlike the original version of this function — that window is now actually covered. `start()`
|
|
41
|
+
* itself resolves as soon as the `Bridge` object exists; the socket bind is asynchronous and only
|
|
42
|
+
* `bridge.ready` observes it (server/src/index.ts's `start` never awaits it, and `Bridge`'s
|
|
43
|
+
* constructor hands it back as a separate promise — server/src/portal/bridge/bridge.ts). A port lost
|
|
44
|
+
* to this race therefore surfaced as an unhandled rejection on `bridge.ready`, not as a rejection of
|
|
45
|
+
* `start()`, so the original `try { await start(...) } catch` here never actually saw it
|
|
46
|
+
* (reticlehq/reticle#1165 review, "Bind race escapes catch"). Awaiting `bridge.ready` closes that gap.
|
|
47
|
+
* If it rejects, `start()` had already gone far enough to launch the driven browser and allocate a
|
|
48
|
+
* pool for it (server/src/index.ts's `resolveRealInput`), so `server.close()` runs before this
|
|
49
|
+
* function throws — otherwise a lost race would leak exactly the orphaned browser process checking
|
|
50
|
+
* the port before `start()` exists to prevent (reticlehq/reticle#1141's original motivation).
|
|
51
|
+
*
|
|
52
|
+
* This deliberately does not reach for the CLI's `process.on('uncaughtException', ...)` trick
|
|
53
|
+
* (drive-command.ts): that command is a fresh, short-lived process that exits right after, while
|
|
54
|
+
* `bootSession` is a library call a test runner makes repeatedly inside one long-lived process —
|
|
55
|
+
* installing and tearing down a process-wide handler around every call would race concurrent specs
|
|
56
|
+
* booting their own sessions in the same worker.
|
|
57
|
+
*
|
|
58
|
+
* The true TOCTOU race (a listener grabbing the port in the gap between the probe and the bind) still
|
|
59
|
+
* has no deterministic reproduction on the real network — an artificially-timed test would be flaky by
|
|
60
|
+
* construction, which CLAUDE.md's own testing rules treat as a bug in the test, not a property of the
|
|
61
|
+
* machine. What IS deterministic, and now covered, is what `bootSession` does once that race is lost:
|
|
62
|
+
* `BootOptions.startServer` lets a test hand back a `RunningServer` whose `bridge.ready` rejects on
|
|
63
|
+
* demand, so `boot.test.ts` can assert the translation-to-refusal and the `server.close()` cleanup
|
|
64
|
+
* directly, without needing to actually win a race to exercise them (reticlehq/reticle#1165 review,
|
|
65
|
+
* "Readiness failure lacks test coverage"). The pre-flight refusal (a port already occupied before
|
|
66
|
+
* this call begins) is the other, simpler case `boot.test.ts` covers.
|
|
23
67
|
*/
|
|
24
68
|
export declare function bootSession(opts: BootOptions): Promise<BootedRun>;
|
package/dist/boot.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { ReticleDir } from '@reticlehq/core';
|
|
3
|
-
import { AnnotationStore, BaselineStore, FlowStore, ProjectStore, RecordingStore, createNodeFileSystem, createToolInvoker, start, } from '@reticlehq/server';
|
|
2
|
+
import { LOOPBACK_HOST, ReticleDir, ReticleEnv, RETICLE_DEFAULT_PORT } from '@reticlehq/core';
|
|
3
|
+
import { AnnotationStore, BaselineStore, FlowStore, ProjectStore, RecordingStore, PortPresence, createNodeFileSystem, createToolInvoker, describePresence, fetchStatus, probeDaemon, probePresence, start, } from '@reticlehq/server';
|
|
4
4
|
/**
|
|
5
5
|
* Build ToolDeps from a started server (Option b: zero further server changes). The only place
|
|
6
6
|
* in @reticlehq/test that touches real IO — and it does so only by delegating to @reticlehq/server's start.
|
|
@@ -22,19 +22,122 @@ function defaultBuildDeps(server, opts) {
|
|
|
22
22
|
};
|
|
23
23
|
return server.realInput !== undefined ? { ...base, realInput: server.realInput } : base;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* The two ways out of a taken port, named per CLAUDE.md's "no free strings" rule (reticlehq/reticle
|
|
27
|
+
* #1165 review) rather than assembled inline. `STOP_DAEMON_ADVICE` is only correct when the holder is
|
|
28
|
+
* a Reticle daemon `bootSession` itself could plausibly be asked to replace — for anything else,
|
|
29
|
+
* `reticle stop` cannot touch the process actually holding the port, and telling the caller to run it
|
|
30
|
+
* anyway leaves them still blocked with a dead lead (reticlehq/reticle#1165 review, "Stop advice
|
|
31
|
+
* misidentifies holder").
|
|
32
|
+
*/
|
|
33
|
+
const DIFFERENT_PORT_ADVICE = 'Pass a different `port` to bootSession';
|
|
34
|
+
const STOP_DAEMON_ADVICE = 'run `reticle stop` to free this one';
|
|
35
|
+
/**
|
|
36
|
+
* `describePresence`'s DAEMON sentence (server/src/command/daemon/binding/port-presence.ts) is a
|
|
37
|
+
* deliberate mid-sentence fragment with no trailing period — other call sites append their own
|
|
38
|
+
* punctuation, and some (`cli-verify.ts`) already do. Its FOREIGN sentences, by contrast, are already
|
|
39
|
+
* complete. Fixing the missing period on the shared DAEMON string would double it at those other call
|
|
40
|
+
* sites, so this joins onto whichever shape it gets rather than assuming one (reticlehq/reticle#1165
|
|
41
|
+
* review, "Refusal uses free strings" / punctuation note).
|
|
42
|
+
*/
|
|
43
|
+
function withFullStop(fragment) {
|
|
44
|
+
return /[.!?]$/.test(fragment) ? fragment : `${fragment}.`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The clear refusal `bootSession` throws instead of a raw `node:net` stack (reticlehq/reticle#1141).
|
|
48
|
+
* Names the port, folds in the CLI's own sentence for WHY it's unusable (`describePresence`), and
|
|
49
|
+
* gives exactly the advice that applies to `presence`: a daemon can be told to stop, anything else
|
|
50
|
+
* can only be waited out or dodged with a different port.
|
|
51
|
+
*/
|
|
52
|
+
function portTakenError(port, presence) {
|
|
53
|
+
const advice = PortPresence.DAEMON === presence
|
|
54
|
+
? `${DIFFERENT_PORT_ADVICE}, or ${STOP_DAEMON_ADVICE}.`
|
|
55
|
+
: `${DIFFERENT_PORT_ADVICE}.`;
|
|
56
|
+
return new Error(`bootSession can't use port ${String(port)} — ${withFullStop(describePresence(presence, port))} ${advice}`);
|
|
57
|
+
}
|
|
58
|
+
/** The one Node error `start()`/`bridge.ready` can still surface for a port `probePresence` just called FREE. */
|
|
59
|
+
function isPortRaceLoss(error) {
|
|
60
|
+
return error instanceof Error && 'EADDRINUSE' === error.code;
|
|
61
|
+
}
|
|
25
62
|
/**
|
|
26
63
|
* Production wiring: launch a headless real-input browser against `driveUrl`, then expose a
|
|
27
64
|
* programmatic ToolInvoker over it (no MCP/stdio). Tests inject a fake invoker into runSpecs
|
|
28
65
|
* directly and never reach this path.
|
|
66
|
+
*
|
|
67
|
+
* Probes the port before dialing it, the same way `drive`/`verify`/`status` already do
|
|
68
|
+
* (`probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus })`), because `start()` used to
|
|
69
|
+
* be the FIRST thing that found out — and on any machine already running a Reticle daemon, that is
|
|
70
|
+
* every machine with Reticle set up, a caller of this library function got the raw EADDRINUSE stack
|
|
71
|
+
* instead of a sentence it could act on (reticlehq/reticle#1141).
|
|
72
|
+
*
|
|
73
|
+
* The probe can't close the window between "nothing was listening" and the bind that follows, and —
|
|
74
|
+
* unlike the original version of this function — that window is now actually covered. `start()`
|
|
75
|
+
* itself resolves as soon as the `Bridge` object exists; the socket bind is asynchronous and only
|
|
76
|
+
* `bridge.ready` observes it (server/src/index.ts's `start` never awaits it, and `Bridge`'s
|
|
77
|
+
* constructor hands it back as a separate promise — server/src/portal/bridge/bridge.ts). A port lost
|
|
78
|
+
* to this race therefore surfaced as an unhandled rejection on `bridge.ready`, not as a rejection of
|
|
79
|
+
* `start()`, so the original `try { await start(...) } catch` here never actually saw it
|
|
80
|
+
* (reticlehq/reticle#1165 review, "Bind race escapes catch"). Awaiting `bridge.ready` closes that gap.
|
|
81
|
+
* If it rejects, `start()` had already gone far enough to launch the driven browser and allocate a
|
|
82
|
+
* pool for it (server/src/index.ts's `resolveRealInput`), so `server.close()` runs before this
|
|
83
|
+
* function throws — otherwise a lost race would leak exactly the orphaned browser process checking
|
|
84
|
+
* the port before `start()` exists to prevent (reticlehq/reticle#1141's original motivation).
|
|
85
|
+
*
|
|
86
|
+
* This deliberately does not reach for the CLI's `process.on('uncaughtException', ...)` trick
|
|
87
|
+
* (drive-command.ts): that command is a fresh, short-lived process that exits right after, while
|
|
88
|
+
* `bootSession` is a library call a test runner makes repeatedly inside one long-lived process —
|
|
89
|
+
* installing and tearing down a process-wide handler around every call would race concurrent specs
|
|
90
|
+
* booting their own sessions in the same worker.
|
|
91
|
+
*
|
|
92
|
+
* The true TOCTOU race (a listener grabbing the port in the gap between the probe and the bind) still
|
|
93
|
+
* has no deterministic reproduction on the real network — an artificially-timed test would be flaky by
|
|
94
|
+
* construction, which CLAUDE.md's own testing rules treat as a bug in the test, not a property of the
|
|
95
|
+
* machine. What IS deterministic, and now covered, is what `bootSession` does once that race is lost:
|
|
96
|
+
* `BootOptions.startServer` lets a test hand back a `RunningServer` whose `bridge.ready` rejects on
|
|
97
|
+
* demand, so `boot.test.ts` can assert the translation-to-refusal and the `server.close()` cleanup
|
|
98
|
+
* directly, without needing to actually win a race to exercise them (reticlehq/reticle#1165 review,
|
|
99
|
+
* "Readiness failure lacks test coverage"). The pre-flight refusal (a port already occupied before
|
|
100
|
+
* this call begins) is the other, simpler case `boot.test.ts` covers.
|
|
29
101
|
*/
|
|
30
102
|
export async function bootSession(opts) {
|
|
103
|
+
const port = opts.port ?? RETICLE_DEFAULT_PORT;
|
|
104
|
+
// The same host resolution `Bridge` itself falls back to when `startOptions` below passes no
|
|
105
|
+
// `host` (bridge-security.ts's `resolveBridgeSecurity`, then `Bridge`'s own constructor default) —
|
|
106
|
+
// so the preflight probe checks the address the bind will actually use (reticlehq/reticle#1165
|
|
107
|
+
// review, "Probe checks wrong host").
|
|
108
|
+
const host = process.env[ReticleEnv.HOST] ?? LOOPBACK_HOST;
|
|
109
|
+
const presence = await probePresence(port, {
|
|
110
|
+
tcpOpen: (p) => probeDaemon(p, host),
|
|
111
|
+
status: (p) => fetchStatus(p, host),
|
|
112
|
+
});
|
|
113
|
+
if (presence !== PortPresence.FREE) {
|
|
114
|
+
throw portTakenError(port, presence);
|
|
115
|
+
}
|
|
31
116
|
const startOptions = {
|
|
32
117
|
mcp: false,
|
|
33
118
|
driveUrl: opts.driveUrl,
|
|
34
119
|
headless: opts.headless ?? true,
|
|
35
120
|
...(opts.port !== undefined ? { port: opts.port } : {}),
|
|
36
121
|
};
|
|
37
|
-
const
|
|
122
|
+
const startServer = opts.startServer ?? start;
|
|
123
|
+
let server;
|
|
124
|
+
try {
|
|
125
|
+
server = await startServer(startOptions);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (isPortRaceLoss(error))
|
|
129
|
+
throw portTakenError(port, PortPresence.FOREIGN);
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
await server.bridge.ready;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
await server.close();
|
|
137
|
+
if (isPortRaceLoss(error))
|
|
138
|
+
throw portTakenError(port, PortPresence.FOREIGN);
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
38
141
|
const deps = (opts.buildDeps ?? ((s) => defaultBuildDeps(s, opts)))(server);
|
|
39
142
|
return { invoke: createToolInvoker(deps), close: server.close };
|
|
40
143
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reticlehq/test",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "Reticle test runner: reticleTest spec registry + a runner that drives Reticle tools without MCP/stdio.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@reticlehq/core": "3.
|
|
16
|
-
"@reticlehq/server": "3.
|
|
15
|
+
"@reticlehq/core": "3.4.0",
|
|
16
|
+
"@reticlehq/server": "3.4.0"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
|
-
"vitest": "^3.2.6"
|
|
19
|
+
"vitest": "^3.2.6 || ^4.0.0"
|
|
20
20
|
},
|
|
21
21
|
"peerDependenciesMeta": {
|
|
22
22
|
"vitest": {
|