@zgeoff/atc 3.3.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/agents/agent-adapter.ts +10 -0
- package/src/agents/build-cli-argv.ts +2 -4
- package/src/agents/claude-adapter.ts +13 -3
- package/src/agents/codex-adapter.ts +11 -2
- package/src/agents/gateway-adapter.ts +20 -8
- package/src/agents/grok-adapter.ts +16 -13
- package/src/agents/load-claude-config-bundle.ts +6 -6
- package/src/agents/print-codex-hook-file.ts +7 -3
- package/src/agents/print-grok-hook-file.ts +8 -3
- package/src/agents/resolve-agent-home.ts +7 -3
- package/src/agents/resolve-claude-global-config-path.ts +4 -4
- package/src/agents/update-claude-project-trust.ts +41 -30
- package/src/agents/with-claude-config-lock.ts +13 -1
- package/src/agents/write-atc-bridge.ts +10 -7
- package/src/agents/write-hook-settings.ts +13 -7
- package/src/client/boot-daemon.ts +48 -15
- package/src/client/dirs.ts +4 -2
- package/src/client/index.ts +17 -1
- package/src/client/log-client-event.ts +20 -0
- package/src/client/spawn-picker.ts +200 -121
- package/src/client/ui.ts +7 -7
- package/src/clients.ts +41 -11
- package/src/daemon/daemon.ts +143 -33
- package/src/daemon/event-signal.ts +19 -3
- package/src/daemon/make-hook-runner.ts +82 -22
- package/src/daemon/permission-registry.ts +11 -6
- package/src/daemon/restore-fleet.ts +62 -17
- package/src/daemon/run-eject-handoff.ts +20 -2
- package/src/daemon/sessions.ts +6 -0
- package/src/daemon/start-tcp-listener.ts +12 -10
- package/src/federation/daemon-caller.ts +25 -4
- package/src/federation/daemon-pool.ts +3 -1
- package/src/federation/open-gateway-caller.ts +6 -1
- package/src/grants.ts +36 -11
- package/src/hook-report.ts +17 -3
- package/src/mcp/read-report-texts.ts +11 -7
- package/src/mcp/start-mcp-http-server.ts +5 -1
- package/src/mcp-http-server.ts +85 -32
- package/src/report.ts +21 -3
- package/src/run-bridge-tap.ts +36 -7
- package/src/run-config-migrate.ts +35 -11
- package/src/run-gateway.ts +4 -2
- package/src/shared/build-atc-command.ts +7 -3
- package/src/shared/bun-sqlite-driver.ts +10 -0
- package/src/shared/claim-daemon-lock.ts +10 -4
- package/src/shared/collect-dir-roots.ts +6 -6
- package/src/shared/collect-hooks.ts +7 -6
- package/src/shared/config.ts +19 -9
- package/src/shared/system-clock.ts +24 -0
- package/src/sources/github/collect-github-repos.ts +1 -1
- package/src/sources/github/read-git-protocol.ts +1 -1
- package/src/sources/github/run-gh.ts +15 -9
- package/src/store/state-store.ts +57 -7
- package/src/tap.ts +44 -13
- package/src/test-utils/build-mock-agent-adapter.ts +25 -0
- package/src/test-utils/build-mock-agent-entry.ts +32 -0
- package/src/test-utils/build-mock-auth-binding.ts +28 -0
- package/src/test-utils/build-mock-auth-profile.ts +62 -0
- package/src/test-utils/build-mock-daemon-record.ts +26 -0
- package/src/test-utils/build-mock-fleet-entry.ts +33 -0
- package/src/test-utils/build-mock-gateway-config.ts +32 -0
- package/src/test-utils/build-mock-imp-identity.ts +18 -0
- package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
- package/src/test-utils/build-mock-message-record.ts +22 -0
- package/src/test-utils/build-mock-mirror-session.ts +42 -0
- package/src/test-utils/build-mock-registry-daemon.ts +28 -0
- package/src/test-utils/build-mock-session-descriptor.ts +66 -0
- package/src/test-utils/build-mock-session.ts +43 -0
- package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
- package/src/test-utils/build-stub-attention-adapter.ts +22 -0
- package/src/test-utils/build-stub-bridge-context.ts +20 -0
- package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
- package/src/test-utils/build-stub-channel-opener.ts +45 -0
- package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
- package/src/test-utils/build-stub-client-screens.ts +33 -0
- package/src/test-utils/build-stub-clock.ts +67 -0
- package/src/test-utils/build-stub-daemon-context.ts +81 -0
- package/src/test-utils/build-stub-daemon-requests.ts +103 -0
- package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
- package/src/test-utils/build-stub-execution-provider.ts +112 -0
- package/src/test-utils/build-stub-fleet-caller.ts +60 -0
- package/src/test-utils/build-stub-forking-gh.ts +10 -0
- package/src/test-utils/build-stub-gh.ts +50 -0
- package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
- package/src/test-utils/build-stub-harness-relay.ts +50 -0
- package/src/test-utils/build-stub-headless-runner.ts +60 -0
- package/src/test-utils/build-stub-held-gh.ts +19 -0
- package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
- package/src/test-utils/build-stub-held-provider.ts +58 -0
- package/src/test-utils/build-stub-held-zoxide.ts +13 -0
- package/src/test-utils/build-stub-host-hold.ts +31 -0
- package/src/test-utils/build-stub-host-provider.ts +26 -0
- package/src/test-utils/{fixture-imp-port.ts → build-stub-imp-port.ts} +119 -63
- package/src/test-utils/build-stub-log.ts +15 -0
- package/src/test-utils/build-stub-mcp-claude.ts +26 -0
- package/src/test-utils/build-stub-mcp-grok.ts +19 -0
- package/src/test-utils/build-stub-peer-socket.ts +81 -0
- package/src/test-utils/build-stub-picker-host.ts +36 -0
- package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
- package/src/test-utils/build-stub-pty-provider.ts +53 -0
- package/src/test-utils/build-stub-recording-atc.ts +10 -0
- package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
- package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
- package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
- package/src/test-utils/build-stub-targets.ts +58 -0
- package/src/test-utils/build-stub-terminal.ts +19 -0
- package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
- package/src/test-utils/build-stub-tui-claude.ts +47 -0
- package/src/test-utils/build-stub-tui-grok.ts +53 -0
- package/src/test-utils/build-stub-wait-clock.ts +23 -0
- package/src/test-utils/build-target-options-from-config.ts +37 -0
- package/src/test-utils/collect-process-tree.ts +75 -0
- package/src/test-utils/create-git-fixture.ts +120 -0
- package/src/test-utils/create-migrated-state-db.ts +12 -10
- package/src/test-utils/create-stub-bin.ts +19 -0
- package/src/test-utils/create-stub-claude.ts +75 -0
- package/src/test-utils/create-stub-codex.ts +31 -0
- package/src/test-utils/create-stub-composer.ts +65 -0
- package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
- package/src/test-utils/create-stub-grok.ts +44 -0
- package/src/test-utils/create-stub-guest-clis.ts +55 -0
- package/src/test-utils/create-stub-harness-guest.ts +35 -0
- package/src/test-utils/create-stub-recording-claude.ts +20 -0
- package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +5 -5
- package/src/test-utils/get-gateway-config.ts +3 -3
- package/src/test-utils/get-only-effect-ref.ts +21 -0
- package/src/test-utils/get-only-imp-name.ts +19 -0
- package/src/test-utils/get-records.ts +20 -0
- package/src/test-utils/get-string.ts +14 -0
- package/src/test-utils/keys.ts +26 -0
- package/src/test-utils/merge-deep.ts +36 -0
- package/src/test-utils/mock-overrides.ts +13 -0
- package/src/test-utils/open-repo-step.ts +24 -0
- package/src/test-utils/read-query-plan.ts +22 -0
- package/src/test-utils/resolve-atc-command.ts +13 -0
- package/src/test-utils/resolve-gateway-command.ts +13 -0
- package/src/test-utils/run-atc.ts +56 -0
- package/src/test-utils/run-legacy-daemon.ts +27 -5
- package/src/test-utils/run-listener-daemon.ts +5 -10
- package/src/test-utils/run-source-daemon.ts +2 -2
- package/src/test-utils/seed-faker.ts +7 -0
- package/src/test-utils/send-lines-before-handshake.ts +35 -0
- package/src/test-utils/send-mcp-request.ts +25 -0
- package/src/test-utils/setup-mcp-home.ts +99 -0
- package/src/test-utils/setup-mcp-http.ts +19 -31
- package/src/test-utils/spawn-claude-session.ts +34 -0
- package/src/test-utils/spawn-grok-session.ts +39 -0
- package/src/test-utils/start-cut-proxy.ts +25 -8
- package/src/test-utils/start-daemon-process.ts +192 -0
- package/src/test-utils/start-git-http-server.ts +21 -3
- package/src/test-utils/start-mcp-stdio.ts +195 -0
- package/src/test-utils/start-output-capture.ts +20 -0
- package/src/test-utils/start-stub-dropping-daemon.ts +103 -0
- package/src/test-utils/start-stub-impd-info.ts +32 -0
- package/src/test-utils/start-stub-impd.ts +138 -0
- package/src/test-utils/start-stub-legacy-daemon.ts +166 -0
- package/src/test-utils/start-stub-mcp-server.ts +50 -0
- package/src/test-utils/start-stub-refusing-listener.ts +38 -0
- package/src/test-utils/start-stub-reporter-socket.ts +77 -0
- package/src/test-utils/start-stub-restore-daemon.ts +58 -0
- package/src/test-utils/start-stub-session-bridge.ts +98 -0
- package/src/test-utils/start-stub-silent-server.ts +33 -0
- package/src/test-utils/start-stub-source-daemon.ts +39 -0
- package/src/test-utils/start-stub-stalled-listener.ts +40 -0
- package/src/test-utils/start-stub-tap.ts +38 -0
- package/src/test-utils/start-stub-tcp-daemon.ts +70 -0
- package/src/test-utils/start-stub-telemetry-collector.ts +26 -0
- package/src/test-utils/start-test-daemon.ts +215 -0
- package/src/test-utils/start-tui-harness.ts +284 -0
- package/src/test-utils/subscribe-to-socket-lines.ts +59 -12
- package/src/test-utils/try-send-request.ts +29 -0
- package/src/test-utils/wait-for-event.ts +41 -0
- package/src/test-utils/wait-for.ts +15 -3
- package/src/verify-restored-fleet.ts +62 -13
- package/src/workspace/check-repository-access.ts +4 -0
- package/src/workspace/collect-remote-refs.ts +4 -2
- package/src/workspace/run-git.ts +6 -0
- package/src/test-utils/start-legacy-daemon.ts +0 -111
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { isRecord } from '../shared/report';
|
|
2
|
+
|
|
3
|
+
type BridgeRequest = Readonly<Record<string, unknown>>;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The lines the bridge writes back for one request, in order, or `null` to
|
|
7
|
+
* end the connection without answering.
|
|
8
|
+
*/
|
|
9
|
+
type BridgeResponder = (request: BridgeRequest) => readonly BridgeRequest[] | null;
|
|
10
|
+
|
|
11
|
+
interface StubSessionBridge {
|
|
12
|
+
// Every request line received so far, parsed, in arrival order.
|
|
13
|
+
readonly requests: readonly BridgeRequest[];
|
|
14
|
+
|
|
15
|
+
// How many reads the stand-in has taken from its connections, so a test
|
|
16
|
+
// can wait until one piece of a split write has arrived.
|
|
17
|
+
readonly reads: number;
|
|
18
|
+
readonly [Symbol.dispose]: () => void;
|
|
19
|
+
readonly [Symbol.asyncDispose]: () => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A stand-in for the session bridge a remote host's commands dial: listens
|
|
24
|
+
* on the unix socket path and reads newline-delimited JSON requests,
|
|
25
|
+
* buffering a partial line per connection. It opens every tap, answering a
|
|
26
|
+
* `tap.open` request with `ok` under the request's id as the real bridge
|
|
27
|
+
* does once it attaches the tap, and hands every other request to the
|
|
28
|
+
* responder, writing back each line it returns. A line that is not a JSON
|
|
29
|
+
* object reaches the responder as an empty request. Disposal stops the
|
|
30
|
+
* listener; hold the result with `using` or `await using`.
|
|
31
|
+
*/
|
|
32
|
+
export function startStubSessionBridge(path: string, respond: BridgeResponder): StubSessionBridge {
|
|
33
|
+
const requests: BridgeRequest[] = [];
|
|
34
|
+
let reads = 0;
|
|
35
|
+
|
|
36
|
+
const server = Bun.listen<{ pending: string }>({
|
|
37
|
+
unix: path,
|
|
38
|
+
socket: {
|
|
39
|
+
open(socket) {
|
|
40
|
+
socket.data = { pending: '' };
|
|
41
|
+
},
|
|
42
|
+
data(socket, chunk) {
|
|
43
|
+
reads += 1;
|
|
44
|
+
|
|
45
|
+
const lines = `${socket.data.pending}${chunk.toString()}`.split('\n');
|
|
46
|
+
|
|
47
|
+
socket.data.pending = lines.pop() ?? '';
|
|
48
|
+
|
|
49
|
+
for (const line of lines.filter((text) => text !== '')) {
|
|
50
|
+
const request = parseRequest(line);
|
|
51
|
+
|
|
52
|
+
requests.push(request);
|
|
53
|
+
|
|
54
|
+
const answers =
|
|
55
|
+
request['op'] === 'tap.open' ? [{ id: request['id'], ok: true }] : respond(request);
|
|
56
|
+
|
|
57
|
+
if (answers === null) {
|
|
58
|
+
socket.end();
|
|
59
|
+
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const answer of answers) {
|
|
64
|
+
socket.write(`${JSON.stringify(answer)}\n`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
error() {},
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const stop = () => {
|
|
73
|
+
server.stop(true);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
requests,
|
|
78
|
+
get reads() {
|
|
79
|
+
return reads;
|
|
80
|
+
},
|
|
81
|
+
[Symbol.dispose]: stop,
|
|
82
|
+
[Symbol.asyncDispose]: () => {
|
|
83
|
+
stop();
|
|
84
|
+
|
|
85
|
+
return Promise.resolve();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseRequest(line: string): BridgeRequest {
|
|
91
|
+
try {
|
|
92
|
+
const parsed: unknown = JSON.parse(line);
|
|
93
|
+
|
|
94
|
+
return isRecord(parsed) && !Array.isArray(parsed) ? parsed : {};
|
|
95
|
+
} catch {
|
|
96
|
+
return {};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An HTTP stand-in for an upstream that hangs: it listens on a real port of
|
|
3
|
+
* the loopback address, takes every request, and never answers one. `paths`
|
|
4
|
+
* holds the request path of each request it took, in order. Disposal stops
|
|
5
|
+
* the server and drops the connections it holds; the asynchronous form
|
|
6
|
+
* resolves once it has stopped.
|
|
7
|
+
*/
|
|
8
|
+
export function startStubSilentServer() {
|
|
9
|
+
const paths: string[] = [];
|
|
10
|
+
|
|
11
|
+
const server = Bun.serve({
|
|
12
|
+
port: 0,
|
|
13
|
+
hostname: '127.0.0.1',
|
|
14
|
+
|
|
15
|
+
// A held request would otherwise end after Bun's idle timeout, and the
|
|
16
|
+
// upstream would stop being one that never answers.
|
|
17
|
+
idleTimeout: 0,
|
|
18
|
+
fetch: (request) => {
|
|
19
|
+
paths.push(new URL(request.url).pathname);
|
|
20
|
+
|
|
21
|
+
return new Promise<Response>(() => {});
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
url: `http://127.0.0.1:${String(server.port)}/`,
|
|
27
|
+
paths,
|
|
28
|
+
[Symbol.dispose]: () => {
|
|
29
|
+
void server.stop(true);
|
|
30
|
+
},
|
|
31
|
+
[Symbol.asyncDispose]: () => server.stop(true),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Starts a daemon composed with a fixture source, in a process of its own
|
|
5
|
+
* with the environment given, and resolves once it listens. Its home, sockets
|
|
6
|
+
* and state follow that environment as `atc daemon`'s would, so a client
|
|
7
|
+
* started with the same environment dials it instead of starting its own.
|
|
8
|
+
* `ATC_TEST_SOURCES` picks its sources, the fixture one unless it holds
|
|
9
|
+
* `none`; `ATC_TEST_FIXTURE_URL` holds the repository the fixture source
|
|
10
|
+
* lists, and `ATC_TEST_SOURCE_LOG` a file it appends each listing to.
|
|
11
|
+
* Disposal stops the process; hold the result with `await using`.
|
|
12
|
+
*/
|
|
13
|
+
export async function startStubSourceDaemon(env: Readonly<Record<string, string | undefined>>) {
|
|
14
|
+
const daemon = Bun.spawn([process.execPath, join(import.meta.dir, 'run-source-daemon.ts')], {
|
|
15
|
+
env: { ATC_TEST_SOURCES: 'fixture', ...env },
|
|
16
|
+
stdout: 'pipe',
|
|
17
|
+
stderr: 'ignore',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const stop = async () => {
|
|
21
|
+
daemon.kill();
|
|
22
|
+
|
|
23
|
+
await daemon.exited;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const reader = daemon.stdout.getReader();
|
|
27
|
+
|
|
28
|
+
const first = await reader.read();
|
|
29
|
+
|
|
30
|
+
reader.releaseLock();
|
|
31
|
+
|
|
32
|
+
if (first.done) {
|
|
33
|
+
await stop();
|
|
34
|
+
|
|
35
|
+
throw new Error('the source daemon exited before it listened');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { pid: daemon.pid, [Symbol.asyncDispose]: stop };
|
|
39
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
import type { Socket } from 'node:net';
|
|
3
|
+
|
|
4
|
+
interface StubStalledListener {
|
|
5
|
+
readonly [Symbol.dispose]: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A stand-in for a peer that stops reading: it listens on the unix socket
|
|
10
|
+
* path and accepts every connection but never reads from it, so what the
|
|
11
|
+
* kernel does not buffer stays queued on the sender's side. Resolves once
|
|
12
|
+
* it listens. Disposal destroys every connection it accepted and stops it;
|
|
13
|
+
* hold the result with `using`.
|
|
14
|
+
*/
|
|
15
|
+
export async function startStubStalledListener(path: string): Promise<StubStalledListener> {
|
|
16
|
+
const peers = new Set<Socket>();
|
|
17
|
+
|
|
18
|
+
const server = createServer((peer) => {
|
|
19
|
+
peer.pause();
|
|
20
|
+
peers.add(peer);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const listening = Promise.withResolvers<void>();
|
|
24
|
+
|
|
25
|
+
server.listen(path, () => {
|
|
26
|
+
listening.resolve();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
await listening.promise;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
[Symbol.dispose]: () => {
|
|
33
|
+
for (const peer of peers) {
|
|
34
|
+
peer.destroy();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
server.close();
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DaemonClient } from '../client/daemon-client';
|
|
2
|
+
import type { EventMsg } from '../protocol/protocol';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Makes the client the session's tap and acks each message the tap is
|
|
6
|
+
* handed, the way an agent's inbox reader does, so the daemon drains the
|
|
7
|
+
* whole backlog one message per ack. `messages` holds each InboxMessage in
|
|
8
|
+
* the order it arrived and `closed` each InboxClosed. The client's event
|
|
9
|
+
* callback is replaced.
|
|
10
|
+
*/
|
|
11
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a client is a live connection the tap drives
|
|
12
|
+
export async function startStubTap(client: DaemonClient, session: string) {
|
|
13
|
+
const messages: EventMsg[] = [];
|
|
14
|
+
const closed: EventMsg[] = [];
|
|
15
|
+
|
|
16
|
+
// An ack that a closing connection never answers changes nothing the
|
|
17
|
+
// caller reads.
|
|
18
|
+
const sendAck = async (message: unknown) => {
|
|
19
|
+
try {
|
|
20
|
+
await client.sendRequest('message.ack', { session, message });
|
|
21
|
+
} catch {}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
client.onEvent = (event) => {
|
|
25
|
+
if (event.ev === 'InboxMessage') {
|
|
26
|
+
messages.push(event);
|
|
27
|
+
void sendAck(event['message']);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (event.ev === 'InboxClosed') {
|
|
31
|
+
closed.push(event);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
await client.sendRequest('session.tap', { session });
|
|
36
|
+
|
|
37
|
+
return { messages, closed };
|
|
38
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { PROTOCOL_V } from '../protocol/protocol';
|
|
3
|
+
|
|
4
|
+
interface StubTCPDaemon {
|
|
5
|
+
readonly port: number;
|
|
6
|
+
|
|
7
|
+
// Every request method the stand-in received, in arrival order.
|
|
8
|
+
readonly seen: string[];
|
|
9
|
+
|
|
10
|
+
// How many reads the stand-in has taken from its connections, so a test
|
|
11
|
+
// can wait until one piece of a split write has arrived before it sends
|
|
12
|
+
// the next.
|
|
13
|
+
readonly reads: number;
|
|
14
|
+
readonly [Symbol.dispose]: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The two request fields the stand-in reads; every other field passes
|
|
18
|
+
// unread.
|
|
19
|
+
const REQUEST = z.object({ id: z.number(), m: z.string() });
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A stand-in for an atc daemon's TCP listener on a loopback port: it reads
|
|
23
|
+
* newline-delimited protocol requests and answers each with an ok that holds
|
|
24
|
+
* the request's method, recording every method in `seen`. It sends no
|
|
25
|
+
* handshake of its own and checks no token. `reads` counts the reads it has
|
|
26
|
+
* taken from every connection. Disposal stops it.
|
|
27
|
+
*/
|
|
28
|
+
export function startStubTCPDaemon(): StubTCPDaemon {
|
|
29
|
+
const seen: string[] = [];
|
|
30
|
+
let reads = 0;
|
|
31
|
+
|
|
32
|
+
const server = Bun.listen<{ buffer: string }>({
|
|
33
|
+
hostname: '127.0.0.1',
|
|
34
|
+
port: 0,
|
|
35
|
+
socket: {
|
|
36
|
+
open(socket) {
|
|
37
|
+
socket.data = { buffer: '' };
|
|
38
|
+
},
|
|
39
|
+
data(socket, buf) {
|
|
40
|
+
reads += 1;
|
|
41
|
+
|
|
42
|
+
const lines = `${socket.data.buffer}${buf.toString()}`.split('\n');
|
|
43
|
+
|
|
44
|
+
socket.data.buffer = lines.pop() ?? '';
|
|
45
|
+
|
|
46
|
+
for (const line of lines.filter((part) => part !== '')) {
|
|
47
|
+
const request = REQUEST.parse(JSON.parse(line));
|
|
48
|
+
|
|
49
|
+
seen.push(request.m);
|
|
50
|
+
|
|
51
|
+
socket.write(
|
|
52
|
+
`${JSON.stringify({ v: PROTOCOL_V, id: request.id, ok: { m: request.m } })}\n`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
error() {},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
port: server.port,
|
|
62
|
+
seen,
|
|
63
|
+
get reads() {
|
|
64
|
+
return reads;
|
|
65
|
+
},
|
|
66
|
+
[Symbol.dispose]: () => {
|
|
67
|
+
server.stop(true);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stand-in for better-auth's telemetry endpoint on a real HTTP port of the
|
|
3
|
+
* loopback address. A process whose `BETTER_AUTH_TELEMETRY_ENDPOINT` holds
|
|
4
|
+
* `url` sends its telemetry here. Every request is answered with HTTP 204,
|
|
5
|
+
* and `received` holds the URL of each request, in order. Disposal stops the
|
|
6
|
+
* server and resolves once it has stopped.
|
|
7
|
+
*/
|
|
8
|
+
export function startStubTelemetryCollector() {
|
|
9
|
+
const received: string[] = [];
|
|
10
|
+
|
|
11
|
+
const server = Bun.serve({
|
|
12
|
+
hostname: '127.0.0.1',
|
|
13
|
+
port: 0,
|
|
14
|
+
fetch: (request) => {
|
|
15
|
+
received.push(request.url);
|
|
16
|
+
|
|
17
|
+
return new Response(null, { status: 204 });
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
url: `http://127.0.0.1:${String(server.port)}/`,
|
|
23
|
+
received,
|
|
24
|
+
[Symbol.asyncDispose]: () => server.stop(true),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import type { Socket } from 'bun';
|
|
3
|
+
import { DaemonClient } from '../client/daemon-client';
|
|
4
|
+
import { startDaemon } from '../daemon/daemon';
|
|
5
|
+
import type { DaemonHandle, DaemonOptions } from '../daemon/daemon';
|
|
6
|
+
import type { EventMsg } from '../protocol/protocol';
|
|
7
|
+
import { setupTempDir } from './setup-temp-dir';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where a test daemon keeps its sockets and state, all inside one temp
|
|
11
|
+
* directory, so the daemon's lock and record files land there too.
|
|
12
|
+
*/
|
|
13
|
+
interface TestDaemonPaths {
|
|
14
|
+
readonly dir: string;
|
|
15
|
+
readonly socketPath: string;
|
|
16
|
+
readonly reporterSocketPath: string;
|
|
17
|
+
readonly eventsSocketPath: string;
|
|
18
|
+
readonly dbPath: string;
|
|
19
|
+
readonly statusPath: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every daemon option except the paths and the build string, which the
|
|
24
|
+
* harness owns.
|
|
25
|
+
*/
|
|
26
|
+
type TestDaemonOptions = Omit<
|
|
27
|
+
DaemonOptions,
|
|
28
|
+
'socketPath' | 'reporterSocketPath' | 'eventsSocketPath' | 'dbPath' | 'statusPath' | 'build'
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Builds the options for one boot from the daemon's paths. It runs before
|
|
33
|
+
* every boot, so it may also write what the daemon reads at start, such as
|
|
34
|
+
* a token file or a fake agent binary under the directory.
|
|
35
|
+
*/
|
|
36
|
+
type TestDaemonOptionsBuilder = (
|
|
37
|
+
paths: TestDaemonPaths,
|
|
38
|
+
) => TestDaemonOptions | Promise<TestDaemonOptions>;
|
|
39
|
+
|
|
40
|
+
interface TestDaemonConfig {
|
|
41
|
+
// The temp directory's name prefix.
|
|
42
|
+
readonly prefix?: string;
|
|
43
|
+
readonly options?: TestDaemonOptionsBuilder;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The build string the daemon and every client the harness opens send in
|
|
47
|
+
// their handshake.
|
|
48
|
+
const BUILD = 'atc/test-build';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A real daemon in a fresh temp directory, with a client that has already
|
|
52
|
+
* sent its handshake. The options builder chooses what the daemon wires;
|
|
53
|
+
* the harness sets the paths and the build, and collects the daemon's log
|
|
54
|
+
* lines unless the options set their own log. `events` collects every event
|
|
55
|
+
* the main client receives, across restarts. `openClient` opens another
|
|
56
|
+
* client over the unix socket and sends its handshake with the given
|
|
57
|
+
* params; `openTCPClient` connects to the TCP listener and sends nothing, so
|
|
58
|
+
* the test drives that handshake itself. `sendHookLines` writes reporter
|
|
59
|
+
* lines to the reporter socket and resolves once the daemon has closed the
|
|
60
|
+
* connection. `stop` closes every client and stops the daemon; `restart`
|
|
61
|
+
* does the same, then boots on the same paths and state with the options
|
|
62
|
+
* given or the last ones, and opens a new main client. Disposal stops what
|
|
63
|
+
* is running and removes the directory; hold the result with `await using`.
|
|
64
|
+
*/
|
|
65
|
+
export async function startTestDaemon(config: TestDaemonConfig = {}) {
|
|
66
|
+
await using stack = new AsyncDisposableStack();
|
|
67
|
+
|
|
68
|
+
const tmp = stack.use(setupTempDir(config.prefix ?? 'atc-test-daemon-'));
|
|
69
|
+
|
|
70
|
+
const paths: TestDaemonPaths = {
|
|
71
|
+
dir: tmp.dir,
|
|
72
|
+
socketPath: join(tmp.dir, 'daemon.sock'),
|
|
73
|
+
reporterSocketPath: join(tmp.dir, 'reporter.sock'),
|
|
74
|
+
eventsSocketPath: join(tmp.dir, 'events.sock'),
|
|
75
|
+
dbPath: join(tmp.dir, 'state.db'),
|
|
76
|
+
statusPath: join(tmp.dir, 'status.json'),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const logs: string[] = [];
|
|
80
|
+
const events: EventMsg[] = [];
|
|
81
|
+
|
|
82
|
+
const clients = new Set<DaemonClient>();
|
|
83
|
+
|
|
84
|
+
let buildOptions: TestDaemonOptionsBuilder = config.options ?? (() => ({}));
|
|
85
|
+
let live: DaemonHandle | null = null;
|
|
86
|
+
|
|
87
|
+
const openClient = async (hello: Readonly<Record<string, unknown>> = {}) => {
|
|
88
|
+
const client = await DaemonClient.open(paths.socketPath);
|
|
89
|
+
|
|
90
|
+
clients.add(client);
|
|
91
|
+
|
|
92
|
+
await client.sendRequest('daemon.hello', {
|
|
93
|
+
client: BUILD,
|
|
94
|
+
auth: { scheme: 'none' },
|
|
95
|
+
...hello,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return client;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const stop = async () => {
|
|
102
|
+
const stopping = live;
|
|
103
|
+
|
|
104
|
+
live = null;
|
|
105
|
+
|
|
106
|
+
for (const client of clients) {
|
|
107
|
+
client.stop();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
clients.clear();
|
|
111
|
+
|
|
112
|
+
await stopping?.stop();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
stack.defer(stop);
|
|
116
|
+
|
|
117
|
+
const boot = async () => {
|
|
118
|
+
const options = await buildOptions(paths);
|
|
119
|
+
|
|
120
|
+
const daemon = await startDaemon({
|
|
121
|
+
log: (line) => {
|
|
122
|
+
logs.push(line);
|
|
123
|
+
},
|
|
124
|
+
...options,
|
|
125
|
+
socketPath: paths.socketPath,
|
|
126
|
+
reporterSocketPath: paths.reporterSocketPath,
|
|
127
|
+
eventsSocketPath: paths.eventsSocketPath,
|
|
128
|
+
dbPath: paths.dbPath,
|
|
129
|
+
statusPath: paths.statusPath,
|
|
130
|
+
build: BUILD,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
live = daemon;
|
|
134
|
+
|
|
135
|
+
const client = await openClient();
|
|
136
|
+
|
|
137
|
+
client.onEvent = (event) => {
|
|
138
|
+
events.push(event);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return { daemon, client };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
let current = await boot();
|
|
145
|
+
|
|
146
|
+
const owned = stack.move();
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
...paths,
|
|
150
|
+
build: BUILD,
|
|
151
|
+
logs,
|
|
152
|
+
events,
|
|
153
|
+
get daemon(): DaemonHandle {
|
|
154
|
+
return current.daemon;
|
|
155
|
+
},
|
|
156
|
+
get client(): DaemonClient {
|
|
157
|
+
return current.client;
|
|
158
|
+
},
|
|
159
|
+
openClient,
|
|
160
|
+
async openTCPClient(): Promise<DaemonClient> {
|
|
161
|
+
const port = current.daemon.listenPort;
|
|
162
|
+
|
|
163
|
+
if (port === null) {
|
|
164
|
+
throw new Error('the test daemon started without a TCP listener');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const client = await DaemonClient.open({ hostname: '127.0.0.1', port });
|
|
168
|
+
|
|
169
|
+
clients.add(client);
|
|
170
|
+
|
|
171
|
+
return client;
|
|
172
|
+
},
|
|
173
|
+
async sendHookLines(...lines: readonly Readonly<Record<string, unknown>>[]): Promise<void> {
|
|
174
|
+
const payload = Buffer.from(lines.map((line) => `${JSON.stringify(line)}\n`).join(''));
|
|
175
|
+
const closed = Promise.withResolvers<void>();
|
|
176
|
+
let sent = 0;
|
|
177
|
+
|
|
178
|
+
// A socket write accepts only what fits its buffer, so the rest goes
|
|
179
|
+
// out as the socket drains, and the connection ends once all of it
|
|
180
|
+
// has gone.
|
|
181
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a socket is a live handle
|
|
182
|
+
const sendRemainder = (socket: Socket) => {
|
|
183
|
+
sent += socket.write(payload.subarray(sent));
|
|
184
|
+
|
|
185
|
+
if (sent === payload.length) {
|
|
186
|
+
socket.end();
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
await Bun.connect({
|
|
191
|
+
unix: paths.reporterSocketPath,
|
|
192
|
+
socket: {
|
|
193
|
+
open: sendRemainder,
|
|
194
|
+
drain: sendRemainder,
|
|
195
|
+
close() {
|
|
196
|
+
closed.resolve();
|
|
197
|
+
},
|
|
198
|
+
data() {},
|
|
199
|
+
error() {},
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await closed.promise;
|
|
204
|
+
},
|
|
205
|
+
stop,
|
|
206
|
+
async restart(options: TestDaemonOptionsBuilder = buildOptions): Promise<void> {
|
|
207
|
+
await stop();
|
|
208
|
+
|
|
209
|
+
buildOptions = options;
|
|
210
|
+
|
|
211
|
+
current = await boot();
|
|
212
|
+
},
|
|
213
|
+
[Symbol.asyncDispose]: () => owned.disposeAsync(),
|
|
214
|
+
};
|
|
215
|
+
}
|