@zgeoff/atc 3.3.1 → 3.4.1

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.
Files changed (206) hide show
  1. package/package.json +5 -3
  2. package/src/agents/agent-adapter.ts +10 -0
  3. package/src/agents/build-agent-adapters.ts +21 -3
  4. package/src/agents/build-cli-argv.ts +2 -4
  5. package/src/agents/claude-adapter.ts +13 -3
  6. package/src/agents/codex-adapter.ts +31 -2
  7. package/src/agents/gateway-adapter.ts +20 -8
  8. package/src/agents/grok-adapter.ts +16 -13
  9. package/src/agents/load-claude-config-bundle.ts +6 -6
  10. package/src/agents/print-codex-hook-file.ts +7 -3
  11. package/src/agents/print-grok-hook-file.ts +8 -3
  12. package/src/agents/render-sdk-message.ts +57 -0
  13. package/src/agents/resolve-agent-home.ts +7 -3
  14. package/src/agents/resolve-claude-global-config-path.ts +4 -4
  15. package/src/agents/start-claude-headless-run.ts +2 -57
  16. package/src/agents/update-claude-project-trust.ts +41 -30
  17. package/src/agents/with-claude-config-lock.ts +37 -12
  18. package/src/agents/write-atc-bridge.ts +10 -7
  19. package/src/agents/write-hook-settings.ts +13 -7
  20. package/src/client/boot-daemon.ts +122 -39
  21. package/src/client/dirs.ts +4 -2
  22. package/src/client/index.ts +17 -1
  23. package/src/client/log-client-event.ts +20 -0
  24. package/src/client/resolve-workspace-root.ts +10 -5
  25. package/src/client/spawn-picker.ts +200 -121
  26. package/src/client/ui.ts +7 -7
  27. package/src/clients.ts +41 -11
  28. package/src/daemon/daemon.ts +143 -33
  29. package/src/daemon/event-signal.ts +19 -3
  30. package/src/daemon/make-hook-runner.ts +82 -22
  31. package/src/daemon/permission-registry.ts +11 -6
  32. package/src/daemon/restore-fleet.ts +62 -17
  33. package/src/daemon/run-eject-handoff.ts +20 -2
  34. package/src/daemon/session-runtime.ts +0 -4
  35. package/src/daemon/sessions.ts +6 -0
  36. package/src/daemon/start-tcp-listener.ts +12 -10
  37. package/src/federation/daemon-caller.ts +25 -4
  38. package/src/federation/daemon-pool.ts +3 -1
  39. package/src/federation/open-gateway-caller.ts +6 -1
  40. package/src/gateway.ts +2 -179
  41. package/src/grants.ts +36 -11
  42. package/src/hook-report.ts +17 -3
  43. package/src/mcp/read-report-texts.ts +11 -7
  44. package/src/mcp/start-mcp-http-server.ts +5 -1
  45. package/src/mcp-http-server.ts +88 -32
  46. package/src/report.ts +21 -3
  47. package/src/run-bridge-tap.ts +36 -7
  48. package/src/run-config-migrate.ts +35 -11
  49. package/src/run-gateway-cli.ts +327 -0
  50. package/src/run-gateway.ts +51 -18
  51. package/src/shared/build-atc-command.ts +7 -3
  52. package/src/shared/bun-sqlite-driver.ts +10 -0
  53. package/src/shared/claim-daemon-lock.ts +10 -4
  54. package/src/shared/collect-dir-roots.ts +6 -6
  55. package/src/shared/collect-hooks.ts +7 -6
  56. package/src/shared/config.ts +19 -9
  57. package/src/shared/system-clock.ts +24 -0
  58. package/src/sources/github/collect-github-repos.ts +1 -1
  59. package/src/sources/github/read-git-protocol.ts +1 -1
  60. package/src/sources/github/run-gh.ts +24 -9
  61. package/src/store/state-store.ts +57 -7
  62. package/src/tap.ts +44 -13
  63. package/src/test-utils/build-mock-agent-adapter.ts +25 -0
  64. package/src/test-utils/build-mock-agent-entry.ts +32 -0
  65. package/src/test-utils/build-mock-auth-binding.ts +28 -0
  66. package/src/test-utils/build-mock-auth-profile.ts +62 -0
  67. package/src/test-utils/build-mock-daemon-record.ts +26 -0
  68. package/src/test-utils/build-mock-execution-target.ts +25 -0
  69. package/src/test-utils/build-mock-fleet-entry.ts +33 -0
  70. package/src/test-utils/build-mock-gateway-config.ts +32 -0
  71. package/src/test-utils/build-mock-imp-identity.ts +18 -0
  72. package/src/test-utils/build-mock-imp-session-request.ts +53 -0
  73. package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
  74. package/src/test-utils/build-mock-message-record.ts +26 -0
  75. package/src/test-utils/build-mock-mirror-session.ts +42 -0
  76. package/src/test-utils/build-mock-registry-daemon.ts +28 -0
  77. package/src/test-utils/build-mock-session-descriptor.ts +66 -0
  78. package/src/test-utils/build-mock-session.ts +43 -0
  79. package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
  80. package/src/test-utils/build-mock-stored-event.ts +20 -0
  81. package/src/test-utils/build-mock-stored-row.ts +23 -0
  82. package/src/test-utils/build-stub-attention-adapter.ts +22 -0
  83. package/src/test-utils/build-stub-bridge-context.ts +20 -0
  84. package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
  85. package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
  86. package/src/test-utils/build-stub-channel-opener.ts +45 -0
  87. package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
  88. package/src/test-utils/build-stub-client-screens.ts +33 -0
  89. package/src/test-utils/build-stub-clock.ts +67 -0
  90. package/src/test-utils/build-stub-daemon-context.ts +81 -0
  91. package/src/test-utils/build-stub-daemon-requests.ts +103 -0
  92. package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
  93. package/src/test-utils/build-stub-execution-provider.ts +112 -0
  94. package/src/test-utils/build-stub-fleet-caller.ts +60 -0
  95. package/src/test-utils/build-stub-forking-gh.ts +10 -0
  96. package/src/test-utils/build-stub-gh.ts +50 -0
  97. package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
  98. package/src/test-utils/build-stub-harness-relay.ts +50 -0
  99. package/src/test-utils/build-stub-headless-runner.ts +60 -0
  100. package/src/test-utils/build-stub-held-gh.ts +19 -0
  101. package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
  102. package/src/test-utils/build-stub-held-provider.ts +58 -0
  103. package/src/test-utils/build-stub-held-zoxide.ts +13 -0
  104. package/src/test-utils/build-stub-host-hold.ts +31 -0
  105. package/src/test-utils/build-stub-host-provider.ts +26 -0
  106. package/src/test-utils/build-stub-log.ts +15 -0
  107. package/src/test-utils/build-stub-mcp-claude.ts +26 -0
  108. package/src/test-utils/build-stub-mcp-grok.ts +19 -0
  109. package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
  110. package/src/test-utils/build-stub-peer-socket.ts +81 -0
  111. package/src/test-utils/build-stub-picker-host.ts +36 -0
  112. package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
  113. package/src/test-utils/build-stub-pty-provider.ts +53 -0
  114. package/src/test-utils/build-stub-recording-atc.ts +10 -0
  115. package/src/test-utils/build-stub-recording-filter.ts +9 -0
  116. package/src/test-utils/build-stub-recording-git.ts +8 -0
  117. package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
  118. package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
  119. package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
  120. package/src/test-utils/build-stub-targets.ts +58 -0
  121. package/src/test-utils/build-stub-terminal.ts +19 -0
  122. package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
  123. package/src/test-utils/build-stub-tui-claude.ts +47 -0
  124. package/src/test-utils/build-stub-tui-grok.ts +53 -0
  125. package/src/test-utils/build-stub-wait-clock.ts +23 -0
  126. package/src/test-utils/build-target-options-from-config.ts +37 -0
  127. package/src/test-utils/collect-process-tree.ts +75 -0
  128. package/src/test-utils/create-git-fixture.ts +117 -0
  129. package/src/test-utils/create-migrated-state-db.ts +12 -10
  130. package/src/test-utils/create-stub-bin.ts +19 -0
  131. package/src/test-utils/create-stub-claude.ts +75 -0
  132. package/src/test-utils/create-stub-codex.ts +31 -0
  133. package/src/test-utils/create-stub-composer.ts +65 -0
  134. package/src/test-utils/create-stub-echo-claude.ts +21 -0
  135. package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
  136. package/src/test-utils/create-stub-grok.ts +44 -0
  137. package/src/test-utils/create-stub-guest-clis.ts +55 -0
  138. package/src/test-utils/create-stub-harness-guest.ts +35 -0
  139. package/src/test-utils/{fixture-imp-port.ts → create-stub-imp-port.ts} +144 -63
  140. package/src/test-utils/create-stub-recording-claude.ts +20 -0
  141. package/src/test-utils/create-stub-recording-codex.ts +20 -0
  142. package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
  143. package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +15 -9
  144. package/src/test-utils/get-gateway-config.ts +3 -3
  145. package/src/test-utils/get-only-effect-ref.ts +21 -0
  146. package/src/test-utils/get-only-imp-name.ts +19 -0
  147. package/src/test-utils/get-records.ts +20 -0
  148. package/src/test-utils/get-string.ts +14 -0
  149. package/src/test-utils/keys.ts +26 -0
  150. package/src/test-utils/merge-deep.ts +36 -0
  151. package/src/test-utils/mock-overrides.ts +13 -0
  152. package/src/test-utils/open-repo-step.ts +24 -0
  153. package/src/test-utils/read-query-plan.ts +22 -0
  154. package/src/test-utils/register-test-cleanup.ts +63 -0
  155. package/src/test-utils/resolve-atc-command.ts +13 -0
  156. package/src/test-utils/resolve-gateway-command.ts +13 -0
  157. package/src/test-utils/run-atc.ts +56 -0
  158. package/src/test-utils/run-legacy-daemon.ts +27 -5
  159. package/src/test-utils/run-listener-daemon.ts +5 -10
  160. package/src/test-utils/run-mcp-authorization.ts +7 -4
  161. package/src/test-utils/run-source-daemon.ts +2 -2
  162. package/src/test-utils/seed-faker.ts +7 -0
  163. package/src/test-utils/send-lines-before-handshake.ts +35 -0
  164. package/src/test-utils/send-mcp-request.ts +25 -0
  165. package/src/test-utils/setup-mcp-home.ts +110 -0
  166. package/src/test-utils/setup-mcp-http.ts +26 -30
  167. package/src/test-utils/setup-temp-dir.ts +7 -6
  168. package/src/test-utils/spawn-claude-session.ts +34 -0
  169. package/src/test-utils/spawn-grok-session.ts +39 -0
  170. package/src/test-utils/start-cut-proxy.ts +29 -8
  171. package/src/test-utils/start-daemon-process.ts +193 -0
  172. package/src/test-utils/start-git-http-server.ts +28 -4
  173. package/src/test-utils/start-mcp-stdio.ts +190 -0
  174. package/src/test-utils/start-output-capture.ts +20 -0
  175. package/src/test-utils/start-stub-answering-listener.ts +65 -0
  176. package/src/test-utils/start-stub-closing-listener.ts +31 -0
  177. package/src/test-utils/start-stub-dropping-daemon.ts +105 -0
  178. package/src/test-utils/start-stub-impd-info.ts +38 -0
  179. package/src/test-utils/start-stub-impd.ts +143 -0
  180. package/src/test-utils/start-stub-legacy-daemon.ts +171 -0
  181. package/src/test-utils/start-stub-mcp-server.ts +55 -0
  182. package/src/test-utils/start-stub-recording-listener.ts +47 -0
  183. package/src/test-utils/start-stub-refusing-listener.ts +43 -0
  184. package/src/test-utils/start-stub-reporter-socket.ts +79 -0
  185. package/src/test-utils/start-stub-restore-daemon.ts +59 -0
  186. package/src/test-utils/start-stub-session-bridge.ts +100 -0
  187. package/src/test-utils/start-stub-silent-server.ts +38 -0
  188. package/src/test-utils/start-stub-source-daemon.ts +41 -0
  189. package/src/test-utils/start-stub-stalled-client.ts +61 -0
  190. package/src/test-utils/start-stub-stalled-listener.ts +44 -0
  191. package/src/test-utils/start-stub-tap.ts +38 -0
  192. package/src/test-utils/start-stub-tcp-daemon.ts +75 -0
  193. package/src/test-utils/start-stub-telemetry-collector.ts +32 -0
  194. package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
  195. package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
  196. package/src/test-utils/start-test-daemon.ts +229 -0
  197. package/src/test-utils/start-tui-harness.ts +291 -0
  198. package/src/test-utils/subscribe-to-socket-lines.ts +81 -14
  199. package/src/test-utils/try-send-request.ts +29 -0
  200. package/src/test-utils/wait-for-event.ts +41 -0
  201. package/src/test-utils/wait-for.ts +15 -3
  202. package/src/verify-restored-fleet.ts +30 -17
  203. package/src/workspace/check-repository-access.ts +4 -0
  204. package/src/workspace/collect-remote-refs.ts +4 -2
  205. package/src/workspace/run-git.ts +6 -0
  206. package/src/test-utils/start-legacy-daemon.ts +0 -111
@@ -0,0 +1,171 @@
1
+ import type { SocketHandler } from 'bun';
2
+ import type { DaemonFeature } from '../protocol/daemon-features';
3
+ import {
4
+ MAX_CHUNK,
5
+ MAX_LINE,
6
+ PROTOCOL_V,
7
+ decodeMessage,
8
+ encodeMessage,
9
+ } from '../protocol/protocol';
10
+ import { registerTestCleanup } from './register-test-cleanup';
11
+
12
+ interface ReceivedRequest {
13
+ readonly m: string;
14
+ readonly p: Readonly<Record<string, unknown>> | undefined;
15
+ }
16
+
17
+ // A TCP address to listen on in place of a unix socket; port 0 lets the
18
+ // kernel choose one.
19
+ interface StubLegacyDaemonTCPAddress {
20
+ readonly hostname: string;
21
+ readonly port: number;
22
+ }
23
+
24
+ interface StubLegacyDaemonOptions {
25
+ readonly features?: readonly DaemonFeature[];
26
+
27
+ // The protocol version the daemon speaks; a hello on any other version is
28
+ // refused with `protocol_mismatch`, as a real daemon refuses it.
29
+ readonly protocol?: number;
30
+
31
+ readonly replies?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
32
+
33
+ // Who stops the daemon: the current test, once it finishes, or the
34
+ // caller alone, for a process that runs it outside any test; the test
35
+ // unless set.
36
+ readonly owner?: 'test' | 'caller';
37
+ }
38
+
39
+ /**
40
+ * An older atc daemon listening on a unix socket, or on the TCP address
41
+ * given in its place, and speaking the current
42
+ * protocol version, or the one `protocol` holds. Its `daemon.hello` announces `features` when the options
43
+ * hold that list, and announces none without it, as a daemon from before
44
+ * features did. It answers `daemon.hello`, `daemon.ping`, and each method in
45
+ * `replies` with that method's reply, and refuses every other method with
46
+ * `unknown_method`. Every request it receives is recorded in `requests`, and
47
+ * `connections` counts the connections it accepted and those still open.
48
+ * `port` holds the TCP port it bound, `null` on a unix socket. It stops
49
+ * once the current test finishes, so it must run inside a test, unless the
50
+ * options make the caller its owner; `stop` or disposal stops it sooner, and
51
+ * a second stop does nothing.
52
+ */
53
+ export function startStubLegacyDaemon(
54
+ address: string | StubLegacyDaemonTCPAddress,
55
+ options: StubLegacyDaemonOptions = {},
56
+ ) {
57
+ const answers: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
58
+ 'daemon.hello': {
59
+ daemon: 'atc/legacy-build',
60
+ limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
61
+ lastUsedAgent: 'claude',
62
+ ...(options.features === undefined ? {} : { features: options.features }),
63
+ },
64
+ 'daemon.ping': {},
65
+ ...options.replies,
66
+ };
67
+
68
+ const protocol = options.protocol ?? PROTOCOL_V;
69
+ const requests: ReceivedRequest[] = [];
70
+ const connections = { accepted: 0, open: 0 };
71
+
72
+ const handlers: SocketHandler<{ buffer: string }> = {
73
+ open(socket) {
74
+ socket.data = { buffer: '' };
75
+ connections.accepted += 1;
76
+ connections.open += 1;
77
+ },
78
+ close() {
79
+ connections.open -= 1;
80
+ },
81
+ data(socket, chunk) {
82
+ const lines = `${socket.data.buffer}${chunk.toString()}`.split('\n');
83
+
84
+ socket.data.buffer = lines.pop() ?? '';
85
+
86
+ for (const line of lines) {
87
+ const decoded = decodeMessage(line);
88
+
89
+ if (decoded.kind !== 'request') {
90
+ continue;
91
+ }
92
+
93
+ const req = decoded.msg;
94
+
95
+ requests.push({ m: req.m, p: req.p });
96
+
97
+ if (req.m === 'daemon.hello' && req.v !== protocol) {
98
+ const client = req.p?.['client'];
99
+
100
+ socket.write(
101
+ encodeMessage({
102
+ v: protocol,
103
+ id: req.id,
104
+ err: {
105
+ code: 'protocol_mismatch',
106
+ msg: `${String(client)} speaks protocol v${req.v}, daemon atc/legacy-build speaks v${protocol}; restart the daemon so both run the same build`,
107
+ },
108
+ }),
109
+ );
110
+
111
+ continue;
112
+ }
113
+
114
+ const answer = answers[req.m];
115
+
116
+ const reply =
117
+ answer === undefined
118
+ ? {
119
+ v: protocol,
120
+ id: req.id,
121
+ err: { code: 'unknown_method' as const, msg: `unknown method '${req.m}'` },
122
+ }
123
+ : { v: protocol, id: req.id, ok: answer };
124
+
125
+ socket.write(encodeMessage(reply));
126
+ }
127
+ },
128
+ };
129
+
130
+ const server = startListener(address, handlers);
131
+ const stop = options.owner === 'caller' ? server.stop : registerTestCleanup(server.stop);
132
+
133
+ return {
134
+ requests,
135
+ connections,
136
+ port: server.port,
137
+ stop,
138
+ [Symbol.dispose]: stop,
139
+ };
140
+ }
141
+
142
+ // Listens on the unix socket path, or on the TCP address in its place, and
143
+ // returns the port bound, `null` for a unix socket.
144
+ function startListener(
145
+ address: string | StubLegacyDaemonTCPAddress,
146
+ handlers: Readonly<SocketHandler<{ buffer: string }>>,
147
+ ): { readonly port: number | null; readonly stop: () => void } {
148
+ if (typeof address === 'string') {
149
+ const unix = Bun.listen<{ buffer: string }>({ unix: address, socket: handlers });
150
+
151
+ return {
152
+ port: null,
153
+ stop: () => {
154
+ unix.stop(true);
155
+ },
156
+ };
157
+ }
158
+
159
+ const tcp = Bun.listen<{ buffer: string }>({
160
+ hostname: address.hostname,
161
+ port: address.port,
162
+ socket: handlers,
163
+ });
164
+
165
+ return {
166
+ port: tcp.port,
167
+ stop: () => {
168
+ tcp.stop(true);
169
+ },
170
+ };
171
+ }
@@ -0,0 +1,55 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+
3
+ interface StubMCPRequest {
4
+ readonly method: string;
5
+ readonly path: string;
6
+ readonly authorization: string | null;
7
+ readonly type: string | null;
8
+ readonly body: unknown;
9
+ }
10
+
11
+ interface StubMCPServer {
12
+ readonly url: string;
13
+
14
+ // Every request the stand-in received, in arrival order.
15
+ readonly requests: readonly StubMCPRequest[];
16
+
17
+ readonly [Symbol.dispose]: () => void;
18
+ }
19
+
20
+ /**
21
+ * A stand-in for an MCP HTTP server on a loopback port: it answers every
22
+ * request with the JSON body given, and records each request's method,
23
+ * path, authorization and content-type headers, and JSON body. `url` holds
24
+ * its origin. It stops once the current test finishes, so it must run
25
+ * inside a test; disposal stops it sooner, and a second stop does nothing.
26
+ */
27
+ export function startStubMCPServer(answer: unknown): StubMCPServer {
28
+ const requests: StubMCPRequest[] = [];
29
+
30
+ const server = Bun.serve({
31
+ hostname: '127.0.0.1',
32
+ port: 0,
33
+ async fetch(request) {
34
+ requests.push({
35
+ method: request.method,
36
+ path: new URL(request.url).pathname,
37
+ authorization: request.headers.get('authorization'),
38
+ type: request.headers.get('content-type'),
39
+ body: await request.json(),
40
+ });
41
+
42
+ return Response.json(answer);
43
+ },
44
+ });
45
+
46
+ const stop = registerTestCleanup(() => server.stop(true));
47
+
48
+ return {
49
+ url: `http://127.0.0.1:${String(server.port)}`,
50
+ requests,
51
+ [Symbol.dispose]: () => {
52
+ void stop();
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,47 @@
1
+ import type { Socket } from 'bun';
2
+ import { registerTestCleanup } from './register-test-cleanup';
3
+
4
+ interface StubRecordingListener {
5
+ // Resolves with the server's side of the first connection it accepts.
6
+ readonly accepted: Promise<Socket>;
7
+
8
+ // What each read took from any connection, decoded, in arrival order.
9
+ readonly received: readonly string[];
10
+ readonly [Symbol.dispose]: () => void;
11
+ }
12
+
13
+ /**
14
+ * A stand-in for a peer that a test writes through: it listens on the unix
15
+ * socket path, hands the test the server's side of the first connection it
16
+ * accepts, records what each read takes from any connection, and sends
17
+ * nothing of its own. It stops, dropping every connection it holds, once
18
+ * the current test finishes, so it must run inside a test; disposal stops it
19
+ * sooner, and a second stop does nothing.
20
+ */
21
+ export function startStubRecordingListener(path: string): StubRecordingListener {
22
+ const accepted = Promise.withResolvers<Socket>();
23
+ const received: string[] = [];
24
+
25
+ const server = Bun.listen({
26
+ unix: path,
27
+ socket: {
28
+ open(socket) {
29
+ accepted.resolve(socket);
30
+ },
31
+ data(_socket, data) {
32
+ received.push(data.toString());
33
+ },
34
+ error() {},
35
+ },
36
+ });
37
+
38
+ const stop = registerTestCleanup(() => {
39
+ server.stop(true);
40
+ });
41
+
42
+ return {
43
+ accepted: accepted.promise,
44
+ received,
45
+ [Symbol.dispose]: stop,
46
+ };
47
+ }
@@ -0,0 +1,43 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+
3
+ interface StubRefusingListener {
4
+ readonly port: number;
5
+
6
+ // What each read took from any connection, decoded, in arrival order.
7
+ readonly received: readonly string[];
8
+
9
+ readonly [Symbol.dispose]: () => void;
10
+ }
11
+
12
+ /**
13
+ * A stand-in for a daemon's TCP listener refusing a connection whose first
14
+ * line is not a handshake: on a loopback port, it records what each read
15
+ * takes from a connection and ends that connection, answering nothing.
16
+ * It stops once the current test finishes, so it must run inside a test;
17
+ * disposal stops it sooner, and a second stop does nothing.
18
+ */
19
+ export function startStubRefusingListener(): StubRefusingListener {
20
+ const received: string[] = [];
21
+
22
+ const server = Bun.listen({
23
+ hostname: '127.0.0.1',
24
+ port: 0,
25
+ socket: {
26
+ data(socket, data) {
27
+ received.push(data.toString());
28
+ socket.end();
29
+ },
30
+ error() {},
31
+ },
32
+ });
33
+
34
+ const stop = registerTestCleanup(() => {
35
+ server.stop(true);
36
+ });
37
+
38
+ return {
39
+ port: server.port,
40
+ received,
41
+ [Symbol.dispose]: stop,
42
+ };
43
+ }
@@ -0,0 +1,79 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+ import { waitFor } from './wait-for';
3
+
4
+ interface StubReporterSocket {
5
+ // Every complete line received so far, newline removed, in arrival order.
6
+ readonly lines: readonly string[];
7
+
8
+ // How many reads the stand-in has taken from its connections, so a test
9
+ // can wait until one piece of a split write has arrived.
10
+ readonly reads: number;
11
+ readonly waitForLine: (timeoutMs?: number) => Promise<string>;
12
+ readonly [Symbol.dispose]: () => void;
13
+ readonly [Symbol.asyncDispose]: () => Promise<void>;
14
+ }
15
+
16
+ /**
17
+ * A stand-in for the daemon's reporter socket: listens on the unix socket
18
+ * path and collects each newline-terminated line any connection writes,
19
+ * buffering a partial line per connection, as the daemon reads the lines
20
+ * hook and report commands send. It never writes back, and leaves each
21
+ * connection for the sender to close. `waitForLine` resolves with the first
22
+ * line once one has arrived, and rejects naming the socket when none arrives
23
+ * within `timeoutMs`, 5 seconds by default. The listener stops once the
24
+ * current test finishes, so it must run inside a test; disposal stops it
25
+ * sooner, and a second stop does nothing.
26
+ */
27
+ export function startStubReporterSocket(path: string): StubReporterSocket {
28
+ const lines: string[] = [];
29
+ let reads = 0;
30
+
31
+ const server = Bun.listen<{ pending: string }>({
32
+ unix: path,
33
+ socket: {
34
+ open(socket) {
35
+ socket.data = { pending: '' };
36
+ },
37
+ data(socket, chunk) {
38
+ reads += 1;
39
+
40
+ const parts = `${socket.data.pending}${chunk.toString()}`.split('\n');
41
+
42
+ socket.data.pending = parts.pop() ?? '';
43
+
44
+ lines.push(...parts);
45
+ },
46
+ error() {},
47
+ },
48
+ });
49
+
50
+ const stop = registerTestCleanup(() => {
51
+ server.stop(true);
52
+ });
53
+
54
+ return {
55
+ lines,
56
+ get reads() {
57
+ return reads;
58
+ },
59
+ waitForLine: (timeoutMs = 5000) =>
60
+ waitFor(
61
+ () => {
62
+ const [first] = lines;
63
+
64
+ if (first === undefined) {
65
+ throw new Error(`no line has arrived at ${path}`);
66
+ }
67
+
68
+ return first;
69
+ },
70
+ { timeoutMs },
71
+ ),
72
+ [Symbol.dispose]: stop,
73
+ [Symbol.asyncDispose]: () => {
74
+ stop();
75
+
76
+ return Promise.resolve();
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,59 @@
1
+ import { LineDecoder } from '../protocol/line-decoder';
2
+ import { PROTOCOL_V, decodeMessage, encodeMessage } from '../protocol/protocol';
3
+ import { registerTestCleanup } from './register-test-cleanup';
4
+
5
+ /**
6
+ * A daemon stand-in on a unix socket path that speaks the real wire format
7
+ * to a fleet restore check: it answers every `fleet.restore` with an empty
8
+ * result, answers each other request with the next reply the test pushes
9
+ * onto `lists`, and withholds the answer to any request that finds `lists`
10
+ * empty. `methods` holds the method of each request it took, in order.
11
+ * The listener stops once the current test finishes, so it must run inside
12
+ * a test; disposal stops it sooner, and a second stop does nothing.
13
+ */
14
+ export function startStubRestoreDaemon(socketPath: string) {
15
+ const lists: Readonly<Record<string, unknown>>[] = [];
16
+ const methods: string[] = [];
17
+
18
+ // Each connection gets a decoder of its own, so a partial line from one
19
+ // client never joins a line from another.
20
+ const server = Bun.listen<{ lines: LineDecoder }>({
21
+ unix: socketPath,
22
+ socket: {
23
+ open(socket) {
24
+ socket.data = { lines: new LineDecoder() };
25
+ },
26
+ data(socket, buf) {
27
+ const requests = socket.data.lines
28
+ .splitChunk(buf)
29
+ .map((line) => decodeMessage(line))
30
+ .filter((decoded) => decoded.kind === 'request');
31
+
32
+ for (const request of requests) {
33
+ methods.push(request.msg.m);
34
+
35
+ const ok = request.msg.m === 'fleet.restore' ? {} : lists.shift();
36
+
37
+ if (ok !== undefined) {
38
+ socket.write(encodeMessage({ v: PROTOCOL_V, id: request.msg.id, ok }));
39
+ }
40
+ }
41
+ },
42
+ },
43
+ });
44
+
45
+ const stop = registerTestCleanup(() => {
46
+ server.stop(true);
47
+ });
48
+
49
+ return {
50
+ lists,
51
+ methods,
52
+ [Symbol.dispose]: stop,
53
+ [Symbol.asyncDispose]: () => {
54
+ stop();
55
+
56
+ return Promise.resolve();
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,100 @@
1
+ import { isRecord } from '../shared/report';
2
+ import { registerTestCleanup } from './register-test-cleanup';
3
+
4
+ type BridgeRequest = Readonly<Record<string, unknown>>;
5
+
6
+ /**
7
+ * The lines the bridge writes back for one request, in order, or `null` to
8
+ * end the connection without answering.
9
+ */
10
+ type BridgeResponder = (request: BridgeRequest) => readonly BridgeRequest[] | null;
11
+
12
+ interface StubSessionBridge {
13
+ // Every request line received so far, parsed, in arrival order.
14
+ readonly requests: readonly BridgeRequest[];
15
+
16
+ // How many reads the stand-in has taken from its connections, so a test
17
+ // can wait until one piece of a split write has arrived.
18
+ readonly reads: number;
19
+ readonly [Symbol.dispose]: () => void;
20
+ readonly [Symbol.asyncDispose]: () => Promise<void>;
21
+ }
22
+
23
+ /**
24
+ * A stand-in for the session bridge a remote host's commands dial: listens
25
+ * on the unix socket path and reads newline-delimited JSON requests,
26
+ * buffering a partial line per connection. It opens every tap, answering a
27
+ * `tap.open` request with `ok` under the request's id as the real bridge
28
+ * does once it attaches the tap, and hands every other request to the
29
+ * responder, writing back each line it returns. A line that is not a JSON
30
+ * object reaches the responder as an empty request. The listener stops once
31
+ * the current test finishes, so it must run inside a test; disposal stops it
32
+ * sooner, and a second stop does nothing.
33
+ */
34
+ export function startStubSessionBridge(path: string, respond: BridgeResponder): StubSessionBridge {
35
+ const requests: BridgeRequest[] = [];
36
+ let reads = 0;
37
+
38
+ const server = Bun.listen<{ pending: string }>({
39
+ unix: path,
40
+ socket: {
41
+ open(socket) {
42
+ socket.data = { pending: '' };
43
+ },
44
+ data(socket, chunk) {
45
+ reads += 1;
46
+
47
+ const lines = `${socket.data.pending}${chunk.toString()}`.split('\n');
48
+
49
+ socket.data.pending = lines.pop() ?? '';
50
+
51
+ for (const line of lines.filter((text) => text !== '')) {
52
+ const request = parseRequest(line);
53
+
54
+ requests.push(request);
55
+
56
+ const answers =
57
+ request['op'] === 'tap.open' ? [{ id: request['id'], ok: true }] : respond(request);
58
+
59
+ if (answers === null) {
60
+ socket.end();
61
+
62
+ return;
63
+ }
64
+
65
+ for (const answer of answers) {
66
+ socket.write(`${JSON.stringify(answer)}\n`);
67
+ }
68
+ }
69
+ },
70
+ error() {},
71
+ },
72
+ });
73
+
74
+ const stop = registerTestCleanup(() => {
75
+ server.stop(true);
76
+ });
77
+
78
+ return {
79
+ requests,
80
+ get reads() {
81
+ return reads;
82
+ },
83
+ [Symbol.dispose]: stop,
84
+ [Symbol.asyncDispose]: () => {
85
+ stop();
86
+
87
+ return Promise.resolve();
88
+ },
89
+ };
90
+ }
91
+
92
+ function parseRequest(line: string): BridgeRequest {
93
+ try {
94
+ const parsed: unknown = JSON.parse(line);
95
+
96
+ return isRecord(parsed) && !Array.isArray(parsed) ? parsed : {};
97
+ } catch {
98
+ return {};
99
+ }
100
+ }
@@ -0,0 +1,38 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+
3
+ /**
4
+ * An HTTP stand-in for an upstream that hangs: it listens on a real port of
5
+ * the loopback address, takes every request, and never answers one. `paths`
6
+ * holds the request path of each request it took, in order. The server
7
+ * stops, dropping the connections it holds, once the current test finishes,
8
+ * so it must run inside a test; disposal stops it sooner, the asynchronous
9
+ * form resolving once it has stopped, and a second stop does nothing.
10
+ */
11
+ export function startStubSilentServer() {
12
+ const paths: string[] = [];
13
+
14
+ const server = Bun.serve({
15
+ port: 0,
16
+ hostname: '127.0.0.1',
17
+
18
+ // A held request would otherwise end after Bun's idle timeout, and the
19
+ // upstream would stop being one that never answers.
20
+ idleTimeout: 0,
21
+ fetch: (request) => {
22
+ paths.push(new URL(request.url).pathname);
23
+
24
+ return new Promise<Response>(() => {});
25
+ },
26
+ });
27
+
28
+ const stop = registerTestCleanup(() => server.stop(true));
29
+
30
+ return {
31
+ url: `http://127.0.0.1:${String(server.port)}/`,
32
+ paths,
33
+ [Symbol.dispose]: () => {
34
+ void stop();
35
+ },
36
+ [Symbol.asyncDispose]: stop,
37
+ };
38
+ }
@@ -0,0 +1,41 @@
1
+ import { join } from 'node:path';
2
+ import { registerTestCleanup } from './register-test-cleanup';
3
+
4
+ /**
5
+ * Starts a daemon composed with a fixture source, in a process of its own
6
+ * with the environment given, and resolves once it listens. Its home, sockets
7
+ * and state follow that environment as `atc daemon`'s would, so a client
8
+ * started with the same environment dials it instead of starting its own.
9
+ * `ATC_TEST_SOURCES` picks its sources, the fixture one unless it holds
10
+ * `none`; `ATC_TEST_FIXTURE_URL` holds the repository the fixture source
11
+ * lists, and `ATC_TEST_SOURCE_LOG` a file it appends each listing to.
12
+ * The process stops once the current test finishes, so it must run inside
13
+ * a test; disposal stops it sooner, and a second stop does nothing.
14
+ */
15
+ export async function startStubSourceDaemon(env: Readonly<Record<string, string | undefined>>) {
16
+ const daemon = Bun.spawn([process.execPath, join(import.meta.dir, 'run-source-daemon.ts')], {
17
+ env: { ATC_TEST_SOURCES: 'fixture', ...env },
18
+ stdout: 'pipe',
19
+ stderr: 'ignore',
20
+ });
21
+
22
+ const stop = registerTestCleanup(async () => {
23
+ daemon.kill();
24
+
25
+ await daemon.exited;
26
+ });
27
+
28
+ const reader = daemon.stdout.getReader();
29
+
30
+ const first = await reader.read();
31
+
32
+ reader.releaseLock();
33
+
34
+ if (first.done) {
35
+ await stop();
36
+
37
+ throw new Error('the source daemon exited before it listened');
38
+ }
39
+
40
+ return { pid: daemon.pid, [Symbol.asyncDispose]: stop };
41
+ }
@@ -0,0 +1,61 @@
1
+ import { createConnection } from 'node:net';
2
+ import { PROTOCOL_V, encodeMessage } from '../protocol/protocol';
3
+ import { registerTestCleanup } from './register-test-cleanup';
4
+
5
+ interface StubStalledClient {
6
+ // Each chunk the client has read off its connection, in order.
7
+ readonly chunks: readonly unknown[];
8
+
9
+ // How many bytes have arrived on the connection and wait unread.
10
+ readonly countUnreadBytes: () => number;
11
+ readonly [Symbol.dispose]: () => void;
12
+ }
13
+
14
+ /**
15
+ * A stand-in for a daemon client that stops reading: it connects to the
16
+ * daemon's unix socket, sends a `daemon.hello` with no auth as `client`,
17
+ * reads the first chunk the daemon sends, and then holds its next read on
18
+ * a promise that never resolves, so whatever the daemon sends it after
19
+ * that backs up. Resolves once that first chunk arrives. The connection is
20
+ * destroyed once the current test finishes, so it must run inside a test;
21
+ * disposal destroys it sooner, and a second stop does nothing.
22
+ */
23
+ export async function startStubStalledClient(
24
+ socketPath: string,
25
+ client: string,
26
+ ): Promise<StubStalledClient> {
27
+ const held = Promise.withResolvers<void>();
28
+ const first = Promise.withResolvers<void>();
29
+ const chunks: unknown[] = [];
30
+ const socket = createConnection(socketPath);
31
+
32
+ const stop = registerTestCleanup(() => {
33
+ socket.destroy();
34
+ });
35
+
36
+ socket.write(
37
+ encodeMessage({
38
+ v: PROTOCOL_V,
39
+ id: 1,
40
+ m: 'daemon.hello',
41
+ p: { client, auth: { scheme: 'none' } },
42
+ }),
43
+ );
44
+
45
+ void (async () => {
46
+ for await (const chunk of socket) {
47
+ chunks.push(chunk);
48
+ first.resolve();
49
+
50
+ await held.promise;
51
+ }
52
+ })();
53
+
54
+ await first.promise;
55
+
56
+ return {
57
+ chunks,
58
+ countUnreadBytes: () => socket.readableLength,
59
+ [Symbol.dispose]: stop,
60
+ };
61
+ }