@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 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 server = await start(startOptions);
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.2.0",
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.2.0",
16
- "@reticlehq/server": "3.2.0"
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": {