@kb-labs/shared-testing-e2e 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/dist/index.d.ts +325 -0
- package/dist/index.js +681 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @kb-labs/shared-testing-e2e
|
|
2
|
+
|
|
3
|
+
E2E test harness for the KB Labs platform. Wraps `kb-dev` so tests can boot real services in `beforeAll`, assert against real HTTP/WS/SSE, and clean up in `afterAll`.
|
|
4
|
+
|
|
5
|
+
## Quickstart
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { beforeAll, afterAll, describe, it, expect } from 'vitest';
|
|
9
|
+
import { KbDevController, httpClient } from '@kb-labs/shared-testing-e2e';
|
|
10
|
+
|
|
11
|
+
const controller = new KbDevController();
|
|
12
|
+
let client: ReturnType<typeof httpClient>;
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
await controller.ensureServices(['state-daemon', 'gateway']);
|
|
16
|
+
client = httpClient(controller.getServiceUrl('gateway'));
|
|
17
|
+
}, 120_000);
|
|
18
|
+
|
|
19
|
+
afterAll(async () => {
|
|
20
|
+
await controller.dispose();
|
|
21
|
+
}, 60_000);
|
|
22
|
+
|
|
23
|
+
describe('gateway health', () => {
|
|
24
|
+
it('responds to /health', async () => {
|
|
25
|
+
const res = await client.get('/health');
|
|
26
|
+
expect(res.ok).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## What's in the box
|
|
32
|
+
|
|
33
|
+
- **`KbDevController`** — drives `kb-dev ensure|status|stop --json` as a subprocess. One instance per test file.
|
|
34
|
+
- **`httpClient(baseUrl)`** — thin `fetch` wrapper with JSON parsing and timeouts.
|
|
35
|
+
- **`connectWs(url, opts)`** — WebSocket client with message tracking + `closeAllTrackedSockets()` for `afterEach`.
|
|
36
|
+
- **`readSse(url)`** — async iterator over Server-Sent Events.
|
|
37
|
+
- **`registerAgent(client, { namespaceId })` / `registerHost(...)`** — gateway auth helpers (JWT via `/auth/register` → `/auth/token`).
|
|
38
|
+
- **`createIsolatedProjectRoot()`** — temp dir with its own `.kb/devservices.yaml` for tests that mutate marketplace lock / plugins.
|
|
39
|
+
- **`makeTestNamespace(import.meta.url)`** — unique-per-test resource namespace for isolation across shared services.
|
|
40
|
+
|
|
41
|
+
## Lifecycle rules
|
|
42
|
+
|
|
43
|
+
1. **One `KbDevController` per test file.** Per-test boot is 3–10s and will ruin your day.
|
|
44
|
+
2. **Always `dispose()` in `afterAll`** — otherwise services leak across test runs.
|
|
45
|
+
3. **Prefer namespaced IDs** for every resource a test creates; use `afterEach` to clean them up explicitly even when services are shared.
|
|
46
|
+
4. **Use `createIsolatedProjectRoot()`** for any test that mutates `.kb/marketplace.lock`, `.kb/plugins.json`, or similar project-scoped state. Never touch the real workspace.
|
|
47
|
+
|
|
48
|
+
## Testing the harness itself
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pnpm --filter @kb-labs/shared-testing-e2e build
|
|
52
|
+
pnpm --filter @kb-labs/shared-testing-e2e test
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The default test suite is hermetic (no kb-dev spawn, no docker). Full boot-cycle tests are gated behind `KB_E2E_BOOT=1` because `state-daemon` depends on `redis` which requires Docker.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { WebSocket } from 'ws';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Type definitions for the e2e test harness.
|
|
5
|
+
*
|
|
6
|
+
* Service ID and status shapes mirror `infra/kb-labs-dev/internal/manager/events.go`.
|
|
7
|
+
* Keep in sync when kb-dev adds/removes fields.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Known service IDs defined in `.kb/devservices.yaml`.
|
|
11
|
+
* Tests should reference services by ID; the controller resolves the URL.
|
|
12
|
+
*/
|
|
13
|
+
type ServiceId = 'qdrant' | 'redis' | 'state-daemon' | 'workflow' | 'rest' | 'marketplace' | 'gateway' | 'studio' | 'kb-web' | 'kb-docs' | 'kb-app' | 'host-agent' | 'runtime-server';
|
|
14
|
+
/** Service lifecycle states reported by kb-dev. */
|
|
15
|
+
type ServiceState = 'alive' | 'starting' | 'failed' | 'dead' | 'stopping';
|
|
16
|
+
interface ServiceHealth {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
latency?: string;
|
|
19
|
+
slow?: boolean;
|
|
20
|
+
}
|
|
21
|
+
interface ServiceStatus {
|
|
22
|
+
state: ServiceState;
|
|
23
|
+
pid?: number;
|
|
24
|
+
pgid?: number;
|
|
25
|
+
startedBy?: string;
|
|
26
|
+
startedAt?: string;
|
|
27
|
+
uptime?: string;
|
|
28
|
+
health?: ServiceHealth;
|
|
29
|
+
port?: number;
|
|
30
|
+
url?: string;
|
|
31
|
+
deps?: string[];
|
|
32
|
+
depsState?: Record<string, string>;
|
|
33
|
+
detail?: string;
|
|
34
|
+
logsTail?: string[];
|
|
35
|
+
}
|
|
36
|
+
interface StatusSummary {
|
|
37
|
+
alive: number;
|
|
38
|
+
starting: number;
|
|
39
|
+
failed: number;
|
|
40
|
+
dead: number;
|
|
41
|
+
stopping: number;
|
|
42
|
+
total: number;
|
|
43
|
+
}
|
|
44
|
+
/** Full status snapshot returned by `kb-dev status --json`. */
|
|
45
|
+
interface StatusSnapshot {
|
|
46
|
+
ok: boolean;
|
|
47
|
+
services: Record<string, ServiceStatus>;
|
|
48
|
+
summary: StatusSummary;
|
|
49
|
+
}
|
|
50
|
+
/** Per-service action performed during ensure/stop/restart. */
|
|
51
|
+
interface KbDevAction {
|
|
52
|
+
service: string;
|
|
53
|
+
action: 'started' | 'stopped' | 'skipped' | 'failed' | 'restarted' | string;
|
|
54
|
+
reason?: string;
|
|
55
|
+
elapsed?: string;
|
|
56
|
+
error?: string;
|
|
57
|
+
logsTail?: string[];
|
|
58
|
+
}
|
|
59
|
+
/** Unified result envelope returned by ensure/stop/restart/ready. */
|
|
60
|
+
interface KbDevResult {
|
|
61
|
+
ok: boolean;
|
|
62
|
+
actions?: KbDevAction[];
|
|
63
|
+
hint?: string;
|
|
64
|
+
}
|
|
65
|
+
/** Options for booting a KbDevController. */
|
|
66
|
+
interface KbDevControllerOptions {
|
|
67
|
+
/** Absolute path to the project root (directory containing `.kb/devservices.yaml`). */
|
|
68
|
+
projectRoot?: string;
|
|
69
|
+
/** Absolute path to the kb-dev binary. Defaults to `<projectRoot>/scripts/kb-dev`. */
|
|
70
|
+
kbDevBin?: string;
|
|
71
|
+
/** Extra env vars passed to every kb-dev subprocess. */
|
|
72
|
+
env?: Record<string, string>;
|
|
73
|
+
/** Called with every stdout/stderr line of every subprocess. For diagnostics. */
|
|
74
|
+
logSink?: (line: string) => void;
|
|
75
|
+
}
|
|
76
|
+
interface EnsureOptions {
|
|
77
|
+
/** Timeout for ensure+ready (ms). Default 60_000. */
|
|
78
|
+
timeoutMs?: number;
|
|
79
|
+
/** Kill port occupants before starting. Forwarded as `--force`. */
|
|
80
|
+
force?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Controller that drives kb-dev as a subprocess and exposes its JSON
|
|
85
|
+
* agent protocol as a typed TypeScript API for e2e tests.
|
|
86
|
+
*
|
|
87
|
+
* Lifecycle: one controller per test file (beforeAll/afterAll).
|
|
88
|
+
* Services booted through this controller are shared across every
|
|
89
|
+
* `describe`/`it` in that file.
|
|
90
|
+
*/
|
|
91
|
+
declare class KbDevController {
|
|
92
|
+
readonly projectRoot: string;
|
|
93
|
+
readonly kbDevBin: string;
|
|
94
|
+
private readonly env;
|
|
95
|
+
private readonly logSink?;
|
|
96
|
+
private readonly ring;
|
|
97
|
+
/** Services this controller started (and therefore should clean up). */
|
|
98
|
+
private readonly startedByUs;
|
|
99
|
+
/** Cached last status snapshot (invalidated on any mutating call). */
|
|
100
|
+
private lastStatus?;
|
|
101
|
+
constructor(opts?: KbDevControllerOptions);
|
|
102
|
+
/**
|
|
103
|
+
* Ensure every listed service is `alive`. Idempotent — if a service is
|
|
104
|
+
* already running, kb-dev reports `skipped` and we don't respawn.
|
|
105
|
+
*
|
|
106
|
+
* Throws with a formatted hint + recent log tail on failure.
|
|
107
|
+
*/
|
|
108
|
+
ensureServices(ids: ServiceId[], opts?: EnsureOptions): Promise<StatusSnapshot>;
|
|
109
|
+
/**
|
|
110
|
+
* Block until a single service reports `alive` or the timeout expires.
|
|
111
|
+
*/
|
|
112
|
+
ready(id: ServiceId, timeoutMs?: number): Promise<void>;
|
|
113
|
+
/** Query current status for all services. */
|
|
114
|
+
status(): Promise<StatusSnapshot>;
|
|
115
|
+
/**
|
|
116
|
+
* Stop the given services (or everything this controller started, if none given).
|
|
117
|
+
* Always best-effort — errors are logged but never thrown.
|
|
118
|
+
*/
|
|
119
|
+
stopServices(ids?: ServiceId[]): Promise<void>;
|
|
120
|
+
/** Disposes the controller — stops every service it started. */
|
|
121
|
+
dispose(): Promise<void>;
|
|
122
|
+
/**
|
|
123
|
+
* Return the HTTP base URL for a service, e.g. `http://localhost:4000`.
|
|
124
|
+
* Throws if the service is not in the last known status snapshot or has no URL.
|
|
125
|
+
*/
|
|
126
|
+
getServiceUrl(id: ServiceId): string;
|
|
127
|
+
/** Return the port for a service, or throw if unknown. */
|
|
128
|
+
getServicePort(id: ServiceId): number;
|
|
129
|
+
/** Snapshot of recent subprocess log lines, newest last. */
|
|
130
|
+
dumpRecentLogs(): string[];
|
|
131
|
+
private requireService;
|
|
132
|
+
private waitUntilAlive;
|
|
133
|
+
private formatError;
|
|
134
|
+
/**
|
|
135
|
+
* Run a kb-dev subcommand and parse the stdout as JSON.
|
|
136
|
+
* kb-dev prints only the JSON object on stdout when `--json` is set;
|
|
137
|
+
* any logs go to stderr.
|
|
138
|
+
*/
|
|
139
|
+
private runJson;
|
|
140
|
+
private run;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Minimal HTTP client wrapper used by e2e tests.
|
|
145
|
+
*
|
|
146
|
+
* Intentionally thin — tests should be able to read one line and know
|
|
147
|
+
* exactly what request went out. No retries, no interceptors, no magic.
|
|
148
|
+
*/
|
|
149
|
+
interface HttpClientOptions {
|
|
150
|
+
/** Extra headers included on every request. */
|
|
151
|
+
headers?: Record<string, string>;
|
|
152
|
+
/** Per-request timeout in ms. Default 15_000. */
|
|
153
|
+
timeoutMs?: number;
|
|
154
|
+
}
|
|
155
|
+
interface HttpResponse<T = unknown> {
|
|
156
|
+
status: number;
|
|
157
|
+
ok: boolean;
|
|
158
|
+
headers: Headers;
|
|
159
|
+
/** Parsed JSON body, or `undefined` if the response had no body / non-JSON content. */
|
|
160
|
+
body: T | undefined;
|
|
161
|
+
/** Raw text body, always populated. */
|
|
162
|
+
text: string;
|
|
163
|
+
}
|
|
164
|
+
declare class HttpClient {
|
|
165
|
+
private readonly baseUrl;
|
|
166
|
+
private readonly defaults;
|
|
167
|
+
constructor(baseUrl: string, defaults?: HttpClientOptions);
|
|
168
|
+
get<T = unknown>(path: string, opts?: HttpClientOptions): Promise<HttpResponse<T>>;
|
|
169
|
+
post<T = unknown>(path: string, body: unknown, opts?: HttpClientOptions): Promise<HttpResponse<T>>;
|
|
170
|
+
delete<T = unknown>(path: string, opts?: HttpClientOptions): Promise<HttpResponse<T>>;
|
|
171
|
+
put<T = unknown>(path: string, body: unknown, opts?: HttpClientOptions): Promise<HttpResponse<T>>;
|
|
172
|
+
options<T = unknown>(path: string, opts?: HttpClientOptions): Promise<HttpResponse<T>>;
|
|
173
|
+
/** Build an absolute URL for a path under this client's base. */
|
|
174
|
+
url(path: string): string;
|
|
175
|
+
private request;
|
|
176
|
+
}
|
|
177
|
+
declare function httpClient(baseUrl: string, opts?: HttpClientOptions): HttpClient;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Test-friendly WebSocket wrapper.
|
|
181
|
+
*
|
|
182
|
+
* Adapted from `infra/kb-labs-gateway/apps/gateway-app/src/__tests__/live-gateway.e2e.test.ts`
|
|
183
|
+
* — same tracking semantics so the harness can clean up leaked sockets
|
|
184
|
+
* between tests even when an assertion fails mid-flight.
|
|
185
|
+
*/
|
|
186
|
+
interface WsOptions {
|
|
187
|
+
headers?: Record<string, string>;
|
|
188
|
+
/** ms. Default 8000. */
|
|
189
|
+
openTimeoutMs?: number;
|
|
190
|
+
}
|
|
191
|
+
/** Opaque handle returned from `connectWs`. */
|
|
192
|
+
interface WsHandle {
|
|
193
|
+
readonly socket: WebSocket;
|
|
194
|
+
/** Send a JSON-stringified message. */
|
|
195
|
+
send(data: unknown): void;
|
|
196
|
+
/** Wait for the next message, optionally satisfying a predicate. */
|
|
197
|
+
waitForMessage<T = unknown>(opts?: {
|
|
198
|
+
timeoutMs?: number;
|
|
199
|
+
predicate?: (msg: T) => boolean;
|
|
200
|
+
}): Promise<T>;
|
|
201
|
+
/** Collect the next N messages (parsed as JSON). */
|
|
202
|
+
collect<T = unknown>(count: number, timeoutMs?: number): Promise<T[]>;
|
|
203
|
+
close(code?: number): void;
|
|
204
|
+
}
|
|
205
|
+
declare function closeAllTrackedSockets(graceMs?: number): Promise<void>;
|
|
206
|
+
declare function connectWs(url: string, opts?: WsOptions): Promise<WsHandle>;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Minimal Server-Sent Events reader.
|
|
210
|
+
*
|
|
211
|
+
* Consumes `text/event-stream` responses and yields one `SseEvent` per
|
|
212
|
+
* `event:`/`data:` pair. Terminates on close, explicit `untilEvent`, or timeout.
|
|
213
|
+
*/
|
|
214
|
+
interface SseEvent {
|
|
215
|
+
/** SSE `event:` field. Empty string if absent. */
|
|
216
|
+
event: string;
|
|
217
|
+
/** SSE `data:` field, concatenated with newlines if multi-line. */
|
|
218
|
+
data: string;
|
|
219
|
+
/** Optional `id:` field. */
|
|
220
|
+
id?: string;
|
|
221
|
+
/** Parsed JSON body if `data` is valid JSON, else undefined. */
|
|
222
|
+
json?: unknown;
|
|
223
|
+
}
|
|
224
|
+
interface SseOptions {
|
|
225
|
+
headers?: Record<string, string>;
|
|
226
|
+
/** If set, the iterator terminates after the first event with this name. */
|
|
227
|
+
untilEvent?: string;
|
|
228
|
+
/** Wall-clock timeout in ms. Default 30_000. */
|
|
229
|
+
timeoutMs?: number;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Async iterator over an SSE stream. Caller is responsible for iterating
|
|
233
|
+
* (e.g. `for await (const event of readSse(url)) { ... }`).
|
|
234
|
+
*/
|
|
235
|
+
declare function readSse(url: string, opts?: SseOptions): AsyncGenerator<SseEvent>;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Gateway auth helpers.
|
|
239
|
+
*
|
|
240
|
+
* Extracted from `infra/kb-labs-gateway/apps/gateway-app/src/__tests__/live-gateway.e2e.test.ts`
|
|
241
|
+
* (`getJwtToken`, `registerHost`) so every e2e test can reuse the same flow.
|
|
242
|
+
*
|
|
243
|
+
* Flow:
|
|
244
|
+
* 1. POST /auth/register { name, namespaceId } -> { clientId, clientSecret, hostId }
|
|
245
|
+
* 2. POST /auth/token { clientId, clientSecret } -> { accessToken }
|
|
246
|
+
*/
|
|
247
|
+
|
|
248
|
+
interface AgentCredentials {
|
|
249
|
+
accessToken: string;
|
|
250
|
+
clientId: string;
|
|
251
|
+
hostId: string;
|
|
252
|
+
}
|
|
253
|
+
interface HostCredentials {
|
|
254
|
+
hostId: string;
|
|
255
|
+
machineToken: string;
|
|
256
|
+
}
|
|
257
|
+
declare function registerAgent(client: HttpClient, opts?: {
|
|
258
|
+
name?: string;
|
|
259
|
+
namespaceId: string;
|
|
260
|
+
}): Promise<AgentCredentials>;
|
|
261
|
+
declare function registerHost(client: HttpClient, opts: {
|
|
262
|
+
name?: string;
|
|
263
|
+
namespaceId: string;
|
|
264
|
+
capabilities?: string[];
|
|
265
|
+
workspacePaths?: string[];
|
|
266
|
+
}): Promise<HostCredentials>;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* An isolated temporary project root with its own `.kb/` directory.
|
|
270
|
+
*
|
|
271
|
+
* Used by marketplace/plugin e2e tests so real workspace `.kb/marketplace.lock`
|
|
272
|
+
* and `.kb/plugins.json` are never mutated.
|
|
273
|
+
*
|
|
274
|
+
* Pass the returned `root` via `KB_PROJECT_ROOT` env when starting kb-dev.
|
|
275
|
+
*/
|
|
276
|
+
interface IsolatedProjectRoot {
|
|
277
|
+
/** Absolute path to the temp project root. */
|
|
278
|
+
root: string;
|
|
279
|
+
/** Absolute path to `<root>/.kb/devservices.yaml`. */
|
|
280
|
+
devConfigPath: string;
|
|
281
|
+
/** Delete the temp dir. Safe to call multiple times. */
|
|
282
|
+
cleanup(): Promise<void>;
|
|
283
|
+
}
|
|
284
|
+
interface IsolatedProjectRootOptions {
|
|
285
|
+
/**
|
|
286
|
+
* If given, copies the devservices.yaml from this existing workspace root.
|
|
287
|
+
* If omitted, a minimal config with only `state-daemon` is written.
|
|
288
|
+
*/
|
|
289
|
+
copyDevConfigFrom?: string;
|
|
290
|
+
/** Prefix for the temp dir name (for human diagnosis). */
|
|
291
|
+
prefix?: string;
|
|
292
|
+
}
|
|
293
|
+
declare function createIsolatedProjectRoot(opts?: IsolatedProjectRootOptions): Promise<IsolatedProjectRoot>;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Build a deterministic-yet-unique namespace prefix for e2e test resources.
|
|
297
|
+
*
|
|
298
|
+
* Shape: `e2e-<fileHash>-<nanoid>`
|
|
299
|
+
*
|
|
300
|
+
* - `fileHash` is 6 hex chars of sha1(filePath) — lets you grep leaked resources
|
|
301
|
+
* back to the test file that created them.
|
|
302
|
+
* - `nanoid` is a 10-char random suffix — lets parallel runs never collide.
|
|
303
|
+
*
|
|
304
|
+
* Pass `import.meta.url` from the test file.
|
|
305
|
+
*/
|
|
306
|
+
declare function makeTestNamespace(fileUrlOrPath: string, prefix?: string): string;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Walk up from a starting directory until we find `.kb/devservices.yaml`.
|
|
310
|
+
* Returns the absolute path of the project root, or `null` if not found.
|
|
311
|
+
*/
|
|
312
|
+
declare function findWorkspaceRoot(startDir?: string): string | null;
|
|
313
|
+
/**
|
|
314
|
+
* Resolve the workspace root for the current package, honoring env overrides.
|
|
315
|
+
* Precedence:
|
|
316
|
+
* 1. `KB_PROJECT_ROOT` env var (matches kb-dev itself).
|
|
317
|
+
* 2. Walk up from `process.cwd()`.
|
|
318
|
+
* 3. Walk up from this source file's directory (handles running tests
|
|
319
|
+
* from deeply nested package dirs).
|
|
320
|
+
*
|
|
321
|
+
* Throws if no root can be located.
|
|
322
|
+
*/
|
|
323
|
+
declare function resolveWorkspaceRoot(): string;
|
|
324
|
+
|
|
325
|
+
export { type AgentCredentials, type EnsureOptions, type HostCredentials, HttpClient, type HttpClientOptions, type HttpResponse, type IsolatedProjectRoot, type IsolatedProjectRootOptions, type KbDevAction, KbDevController, type KbDevControllerOptions, type KbDevResult, type ServiceHealth, type ServiceId, type ServiceState, type ServiceStatus, type SseEvent, type SseOptions, type StatusSnapshot, type StatusSummary, type WsHandle, type WsOptions, closeAllTrackedSockets, connectWs, createIsolatedProjectRoot, findWorkspaceRoot, httpClient, makeTestNamespace, readSse, registerAgent, registerHost, resolveWorkspaceRoot };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { resolve, dirname, join } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { WebSocket } from 'ws';
|
|
6
|
+
import { mkdtemp, mkdir, readFile, copyFile, writeFile, rm } from 'fs/promises';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
import { createHash } from 'crypto';
|
|
9
|
+
import { nanoid } from 'nanoid';
|
|
10
|
+
|
|
11
|
+
// src/kb-dev-controller.ts
|
|
12
|
+
function findWorkspaceRoot(startDir) {
|
|
13
|
+
let dir = startDir ?? process.cwd();
|
|
14
|
+
for (let i = 0; i < 20; i++) {
|
|
15
|
+
if (existsSync(resolve(dir, ".kb/devservices.yaml"))) {
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
const parent = dirname(dir);
|
|
19
|
+
if (parent === dir) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
dir = parent;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
function resolveWorkspaceRoot() {
|
|
27
|
+
const fromEnv = process.env.KB_PROJECT_ROOT;
|
|
28
|
+
if (fromEnv && existsSync(resolve(fromEnv, ".kb/devservices.yaml"))) {
|
|
29
|
+
return fromEnv;
|
|
30
|
+
}
|
|
31
|
+
const fromCwd = findWorkspaceRoot(process.cwd());
|
|
32
|
+
if (fromCwd) {
|
|
33
|
+
return fromCwd;
|
|
34
|
+
}
|
|
35
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const fromHere = findWorkspaceRoot(here);
|
|
37
|
+
if (fromHere) {
|
|
38
|
+
return fromHere;
|
|
39
|
+
}
|
|
40
|
+
throw new Error(
|
|
41
|
+
"Could not locate KB Labs workspace root (.kb/devservices.yaml). Set KB_PROJECT_ROOT or run from inside the workspace."
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/kb-dev-controller.ts
|
|
46
|
+
var RingBuffer = class {
|
|
47
|
+
constructor(capacity) {
|
|
48
|
+
this.capacity = capacity;
|
|
49
|
+
}
|
|
50
|
+
capacity;
|
|
51
|
+
buf = [];
|
|
52
|
+
push(line) {
|
|
53
|
+
this.buf.push(line);
|
|
54
|
+
if (this.buf.length > this.capacity) {
|
|
55
|
+
this.buf.splice(0, this.buf.length - this.capacity);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
snapshot() {
|
|
59
|
+
return [...this.buf];
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
var KbDevController = class {
|
|
63
|
+
projectRoot;
|
|
64
|
+
kbDevBin;
|
|
65
|
+
env;
|
|
66
|
+
logSink;
|
|
67
|
+
ring = new RingBuffer(400);
|
|
68
|
+
/** Services this controller started (and therefore should clean up). */
|
|
69
|
+
startedByUs = /* @__PURE__ */ new Set();
|
|
70
|
+
/** Cached last status snapshot (invalidated on any mutating call). */
|
|
71
|
+
lastStatus;
|
|
72
|
+
constructor(opts = {}) {
|
|
73
|
+
this.projectRoot = opts.projectRoot ?? resolveWorkspaceRoot();
|
|
74
|
+
this.kbDevBin = opts.kbDevBin ?? resolve(this.projectRoot, "tools/kb-dev/kb-dev");
|
|
75
|
+
if (!existsSync(this.kbDevBin)) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`kb-dev binary not found at ${this.kbDevBin}. Build it with: cd tools/kb-dev && make build`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
this.env = {
|
|
81
|
+
...process.env,
|
|
82
|
+
...opts.env ?? {},
|
|
83
|
+
KB_PROJECT_ROOT: this.projectRoot
|
|
84
|
+
};
|
|
85
|
+
this.logSink = opts.logSink;
|
|
86
|
+
}
|
|
87
|
+
// ── Public API ────────────────────────────────────────────────────────────
|
|
88
|
+
/**
|
|
89
|
+
* Ensure every listed service is `alive`. Idempotent — if a service is
|
|
90
|
+
* already running, kb-dev reports `skipped` and we don't respawn.
|
|
91
|
+
*
|
|
92
|
+
* Throws with a formatted hint + recent log tail on failure.
|
|
93
|
+
*/
|
|
94
|
+
async ensureServices(ids, opts = {}) {
|
|
95
|
+
const timeoutMs = opts.timeoutMs ?? 6e4;
|
|
96
|
+
const args = ["ensure", ...ids, "--json"];
|
|
97
|
+
if (opts.force) {
|
|
98
|
+
args.push("--force");
|
|
99
|
+
}
|
|
100
|
+
const result = await this.runJson(args, timeoutMs);
|
|
101
|
+
if (!result.ok) {
|
|
102
|
+
throw this.formatError(`kb-dev ensure ${ids.join(" ")}`, result);
|
|
103
|
+
}
|
|
104
|
+
for (const action of result.actions ?? []) {
|
|
105
|
+
if (action.action === "started") {
|
|
106
|
+
this.startedByUs.add(action.service);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const snapshot = await this.waitUntilAlive(ids, timeoutMs);
|
|
110
|
+
this.lastStatus = snapshot;
|
|
111
|
+
return snapshot;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Block until a single service reports `alive` or the timeout expires.
|
|
115
|
+
*/
|
|
116
|
+
async ready(id, timeoutMs = 6e4) {
|
|
117
|
+
const deadline = Date.now() + timeoutMs;
|
|
118
|
+
while (Date.now() < deadline) {
|
|
119
|
+
const snap = await this.status();
|
|
120
|
+
const svc = snap.services[id];
|
|
121
|
+
if (svc?.state === "alive") {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (svc?.state === "failed") {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Service ${id} entered 'failed' state.
|
|
127
|
+
Logs tail:
|
|
128
|
+
${(svc.logsTail ?? []).join("\n")}`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
await sleep(500);
|
|
132
|
+
}
|
|
133
|
+
throw new Error(`Timeout waiting for ${id} to become alive (${timeoutMs}ms)`);
|
|
134
|
+
}
|
|
135
|
+
/** Query current status for all services. */
|
|
136
|
+
async status() {
|
|
137
|
+
const result = await this.runJson(["status", "--json"], 1e4);
|
|
138
|
+
this.lastStatus = result;
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Stop the given services (or everything this controller started, if none given).
|
|
143
|
+
* Always best-effort — errors are logged but never thrown.
|
|
144
|
+
*/
|
|
145
|
+
async stopServices(ids) {
|
|
146
|
+
const toStop = ids ?? Array.from(this.startedByUs);
|
|
147
|
+
if (toStop.length === 0) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
await this.runJson(["stop", ...toStop, "--json"], 3e4);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
this.logSink?.(`[kb-dev] stop failed (ignored): ${err.message}`);
|
|
154
|
+
}
|
|
155
|
+
for (const id of toStop) {
|
|
156
|
+
this.startedByUs.delete(id);
|
|
157
|
+
}
|
|
158
|
+
this.lastStatus = void 0;
|
|
159
|
+
}
|
|
160
|
+
/** Disposes the controller — stops every service it started. */
|
|
161
|
+
async dispose() {
|
|
162
|
+
await this.stopServices();
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Return the HTTP base URL for a service, e.g. `http://localhost:4000`.
|
|
166
|
+
* Throws if the service is not in the last known status snapshot or has no URL.
|
|
167
|
+
*/
|
|
168
|
+
getServiceUrl(id) {
|
|
169
|
+
const svc = this.requireService(id);
|
|
170
|
+
if (!svc.url) {
|
|
171
|
+
throw new Error(`Service ${id} has no URL in its status (state=${svc.state})`);
|
|
172
|
+
}
|
|
173
|
+
return svc.url;
|
|
174
|
+
}
|
|
175
|
+
/** Return the port for a service, or throw if unknown. */
|
|
176
|
+
getServicePort(id) {
|
|
177
|
+
const svc = this.requireService(id);
|
|
178
|
+
if (!svc.port) {
|
|
179
|
+
throw new Error(`Service ${id} has no port in its status (state=${svc.state})`);
|
|
180
|
+
}
|
|
181
|
+
return svc.port;
|
|
182
|
+
}
|
|
183
|
+
/** Snapshot of recent subprocess log lines, newest last. */
|
|
184
|
+
dumpRecentLogs() {
|
|
185
|
+
return this.ring.snapshot();
|
|
186
|
+
}
|
|
187
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
188
|
+
requireService(id) {
|
|
189
|
+
if (!this.lastStatus) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`No status snapshot yet for ${id}. Call ensureServices() or status() first.`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const svc = this.lastStatus.services[id];
|
|
195
|
+
if (!svc) {
|
|
196
|
+
throw new Error(`Service ${id} not found in status snapshot`);
|
|
197
|
+
}
|
|
198
|
+
return svc;
|
|
199
|
+
}
|
|
200
|
+
async waitUntilAlive(ids, timeoutMs) {
|
|
201
|
+
const deadline = Date.now() + timeoutMs;
|
|
202
|
+
let lastSnap;
|
|
203
|
+
while (Date.now() < deadline) {
|
|
204
|
+
lastSnap = await this.status();
|
|
205
|
+
const allAlive = ids.every((id) => lastSnap?.services[id]?.state === "alive");
|
|
206
|
+
if (allAlive) {
|
|
207
|
+
return lastSnap;
|
|
208
|
+
}
|
|
209
|
+
const failed = ids.find((id) => lastSnap?.services[id]?.state === "failed");
|
|
210
|
+
if (failed) {
|
|
211
|
+
const svc = lastSnap.services[failed];
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Service ${failed} failed during ensure.
|
|
214
|
+
Logs tail:
|
|
215
|
+
${(svc.logsTail ?? []).join("\n")}`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
await sleep(500);
|
|
219
|
+
}
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Timeout waiting for services to become alive: ${ids.join(", ")}.
|
|
222
|
+
Last known states: ${ids.map((id) => `${id}=${lastSnap?.services[id]?.state ?? "unknown"}`).join(", ")}`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
formatError(prefix, result) {
|
|
226
|
+
const lines = [`${prefix} failed`];
|
|
227
|
+
if (result.hint) {
|
|
228
|
+
lines.push(`Hint: ${result.hint}`);
|
|
229
|
+
}
|
|
230
|
+
for (const action of result.actions ?? []) {
|
|
231
|
+
if (action.action === "failed" || action.error) {
|
|
232
|
+
lines.push(` - ${action.service}: ${action.error ?? "failed"}`);
|
|
233
|
+
for (const log of action.logsTail ?? []) {
|
|
234
|
+
lines.push(` ${log}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const recent = this.ring.snapshot();
|
|
239
|
+
if (recent.length > 0) {
|
|
240
|
+
lines.push("Recent kb-dev output:");
|
|
241
|
+
for (const l of recent.slice(-40)) {
|
|
242
|
+
lines.push(` ${l}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return new Error(lines.join("\n"));
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Run a kb-dev subcommand and parse the stdout as JSON.
|
|
249
|
+
* kb-dev prints only the JSON object on stdout when `--json` is set;
|
|
250
|
+
* any logs go to stderr.
|
|
251
|
+
*/
|
|
252
|
+
async runJson(args, timeoutMs) {
|
|
253
|
+
const result = await this.run(args, timeoutMs);
|
|
254
|
+
if (result.code !== 0 && !result.stdout.trim()) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`kb-dev ${args.join(" ")} exited with code ${result.code}
|
|
257
|
+
stderr:
|
|
258
|
+
${result.stderr}`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
return JSON.parse(result.stdout);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Failed to parse JSON from \`kb-dev ${args.join(" ")}\`:
|
|
266
|
+
error: ${err.message}
|
|
267
|
+
stdout: ${result.stdout.slice(0, 2e3)}
|
|
268
|
+
stderr: ${result.stderr.slice(0, 2e3)}`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
run(args, timeoutMs) {
|
|
273
|
+
return new Promise((resolvePromise) => {
|
|
274
|
+
const child = spawn(this.kbDevBin, args, {
|
|
275
|
+
cwd: this.projectRoot,
|
|
276
|
+
env: this.env,
|
|
277
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
278
|
+
});
|
|
279
|
+
let stdout = "";
|
|
280
|
+
let stderr = "";
|
|
281
|
+
const onLine = (chunk, stream) => {
|
|
282
|
+
const text = chunk.toString();
|
|
283
|
+
if (stream === "stdout") {
|
|
284
|
+
stdout += text;
|
|
285
|
+
} else {
|
|
286
|
+
stderr += text;
|
|
287
|
+
}
|
|
288
|
+
for (const line of text.split("\n")) {
|
|
289
|
+
if (line) {
|
|
290
|
+
this.ring.push(`[${stream}] ${line}`);
|
|
291
|
+
this.logSink?.(`[kb-dev ${args[0]}] [${stream}] ${line}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
child.stdout.on("data", (d) => onLine(d, "stdout"));
|
|
296
|
+
child.stderr.on("data", (d) => onLine(d, "stderr"));
|
|
297
|
+
const timer = setTimeout(() => {
|
|
298
|
+
child.kill("SIGKILL");
|
|
299
|
+
}, timeoutMs);
|
|
300
|
+
child.on("close", (code) => {
|
|
301
|
+
clearTimeout(timer);
|
|
302
|
+
resolvePromise({ code: code ?? -1, stdout, stderr });
|
|
303
|
+
});
|
|
304
|
+
child.on("error", (err) => {
|
|
305
|
+
clearTimeout(timer);
|
|
306
|
+
resolvePromise({ code: -1, stdout, stderr: stderr + "\n" + err.message });
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
function sleep(ms) {
|
|
312
|
+
return new Promise((r) => {
|
|
313
|
+
setTimeout(r, ms);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/http-client.ts
|
|
318
|
+
var HttpClient = class {
|
|
319
|
+
constructor(baseUrl, defaults = {}) {
|
|
320
|
+
this.baseUrl = baseUrl;
|
|
321
|
+
this.defaults = defaults;
|
|
322
|
+
}
|
|
323
|
+
baseUrl;
|
|
324
|
+
defaults;
|
|
325
|
+
async get(path, opts = {}) {
|
|
326
|
+
return this.request("GET", path, void 0, opts);
|
|
327
|
+
}
|
|
328
|
+
async post(path, body, opts = {}) {
|
|
329
|
+
return this.request("POST", path, body, opts);
|
|
330
|
+
}
|
|
331
|
+
async delete(path, opts = {}) {
|
|
332
|
+
return this.request("DELETE", path, void 0, opts);
|
|
333
|
+
}
|
|
334
|
+
async put(path, body, opts = {}) {
|
|
335
|
+
return this.request("PUT", path, body, opts);
|
|
336
|
+
}
|
|
337
|
+
async options(path, opts = {}) {
|
|
338
|
+
return this.request("OPTIONS", path, void 0, opts);
|
|
339
|
+
}
|
|
340
|
+
/** Build an absolute URL for a path under this client's base. */
|
|
341
|
+
url(path) {
|
|
342
|
+
if (path.startsWith("http://") || path.startsWith("https://")) {
|
|
343
|
+
return path;
|
|
344
|
+
}
|
|
345
|
+
return `${this.baseUrl.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
346
|
+
}
|
|
347
|
+
async request(method, path, body, opts) {
|
|
348
|
+
const timeoutMs = opts.timeoutMs ?? this.defaults.timeoutMs ?? 15e3;
|
|
349
|
+
const controller = new AbortController();
|
|
350
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
351
|
+
const headers = {
|
|
352
|
+
...this.defaults.headers,
|
|
353
|
+
...opts.headers
|
|
354
|
+
};
|
|
355
|
+
if (body !== void 0 && !headers["content-type"] && !headers["Content-Type"]) {
|
|
356
|
+
headers["content-type"] = "application/json";
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
const res = await fetch(this.url(path), {
|
|
360
|
+
method,
|
|
361
|
+
headers,
|
|
362
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
363
|
+
signal: controller.signal
|
|
364
|
+
});
|
|
365
|
+
const text = await res.text();
|
|
366
|
+
let parsed;
|
|
367
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
368
|
+
if (text && contentType.includes("json")) {
|
|
369
|
+
try {
|
|
370
|
+
parsed = JSON.parse(text);
|
|
371
|
+
} catch {
|
|
372
|
+
parsed = void 0;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
status: res.status,
|
|
377
|
+
ok: res.ok,
|
|
378
|
+
headers: res.headers,
|
|
379
|
+
body: parsed,
|
|
380
|
+
text
|
|
381
|
+
};
|
|
382
|
+
} finally {
|
|
383
|
+
clearTimeout(timer);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
function httpClient(baseUrl, opts = {}) {
|
|
388
|
+
return new HttpClient(baseUrl, opts);
|
|
389
|
+
}
|
|
390
|
+
var openSockets = /* @__PURE__ */ new Set();
|
|
391
|
+
async function closeAllTrackedSockets(graceMs = 150) {
|
|
392
|
+
const toClose = [...openSockets];
|
|
393
|
+
for (const ws of toClose) {
|
|
394
|
+
if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) {
|
|
395
|
+
try {
|
|
396
|
+
ws.close(1e3);
|
|
397
|
+
} catch {
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
openSockets.clear();
|
|
402
|
+
if (toClose.length > 0) {
|
|
403
|
+
await new Promise((r) => {
|
|
404
|
+
setTimeout(r, graceMs);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function connectWs(url, opts = {}) {
|
|
409
|
+
const openTimeoutMs = opts.openTimeoutMs ?? 8e3;
|
|
410
|
+
return new Promise((resolvePromise, reject) => {
|
|
411
|
+
const ws = new WebSocket(url, {
|
|
412
|
+
headers: opts.headers
|
|
413
|
+
});
|
|
414
|
+
openSockets.add(ws);
|
|
415
|
+
ws.on("close", () => openSockets.delete(ws));
|
|
416
|
+
const timer = setTimeout(() => {
|
|
417
|
+
reject(new Error(`WebSocket open timeout after ${openTimeoutMs}ms: ${url}`));
|
|
418
|
+
try {
|
|
419
|
+
ws.close();
|
|
420
|
+
} catch {
|
|
421
|
+
}
|
|
422
|
+
}, openTimeoutMs);
|
|
423
|
+
ws.once("open", () => {
|
|
424
|
+
clearTimeout(timer);
|
|
425
|
+
resolvePromise(wrapHandle(ws));
|
|
426
|
+
});
|
|
427
|
+
ws.once("error", (err) => {
|
|
428
|
+
clearTimeout(timer);
|
|
429
|
+
reject(err);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
function wrapHandle(ws) {
|
|
434
|
+
return {
|
|
435
|
+
socket: ws,
|
|
436
|
+
send(data) {
|
|
437
|
+
ws.send(typeof data === "string" ? data : JSON.stringify(data));
|
|
438
|
+
},
|
|
439
|
+
waitForMessage(opts = {}) {
|
|
440
|
+
const timeoutMs = opts.timeoutMs ?? 5e3;
|
|
441
|
+
return new Promise((resolvePromise, reject) => {
|
|
442
|
+
const timer = setTimeout(() => {
|
|
443
|
+
ws.off("message", onMessage);
|
|
444
|
+
reject(new Error(`WebSocket message timeout after ${timeoutMs}ms`));
|
|
445
|
+
}, timeoutMs);
|
|
446
|
+
const onMessage = (raw) => {
|
|
447
|
+
let msg;
|
|
448
|
+
try {
|
|
449
|
+
msg = JSON.parse(raw.toString());
|
|
450
|
+
} catch (err) {
|
|
451
|
+
clearTimeout(timer);
|
|
452
|
+
ws.off("message", onMessage);
|
|
453
|
+
reject(new Error(`Non-JSON WebSocket message: ${err.message}`));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (!opts.predicate || opts.predicate(msg)) {
|
|
457
|
+
clearTimeout(timer);
|
|
458
|
+
ws.off("message", onMessage);
|
|
459
|
+
resolvePromise(msg);
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
ws.on("message", onMessage);
|
|
463
|
+
});
|
|
464
|
+
},
|
|
465
|
+
collect(count, timeoutMs = 5e3) {
|
|
466
|
+
return new Promise((resolvePromise, reject) => {
|
|
467
|
+
const msgs = [];
|
|
468
|
+
const timer = setTimeout(() => {
|
|
469
|
+
ws.off("message", onMessage);
|
|
470
|
+
reject(
|
|
471
|
+
new Error(
|
|
472
|
+
`Timeout: expected ${count} WS messages, got ${msgs.length}: ${JSON.stringify(msgs)}`
|
|
473
|
+
)
|
|
474
|
+
);
|
|
475
|
+
}, timeoutMs);
|
|
476
|
+
const onMessage = (raw) => {
|
|
477
|
+
msgs.push(JSON.parse(raw.toString()));
|
|
478
|
+
if (msgs.length >= count) {
|
|
479
|
+
clearTimeout(timer);
|
|
480
|
+
ws.off("message", onMessage);
|
|
481
|
+
resolvePromise(msgs);
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
ws.on("message", onMessage);
|
|
485
|
+
});
|
|
486
|
+
},
|
|
487
|
+
close(code = 1e3) {
|
|
488
|
+
try {
|
|
489
|
+
ws.close(code);
|
|
490
|
+
} catch {
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// src/sse-reader.ts
|
|
497
|
+
async function* readSse(url, opts = {}) {
|
|
498
|
+
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
499
|
+
const controller = new AbortController();
|
|
500
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
501
|
+
let res;
|
|
502
|
+
try {
|
|
503
|
+
res = await fetch(url, {
|
|
504
|
+
headers: { accept: "text/event-stream", ...opts.headers },
|
|
505
|
+
signal: controller.signal
|
|
506
|
+
});
|
|
507
|
+
} catch (err) {
|
|
508
|
+
clearTimeout(timer);
|
|
509
|
+
throw new Error(`SSE fetch failed: ${err.message}`);
|
|
510
|
+
}
|
|
511
|
+
if (!res.ok || !res.body) {
|
|
512
|
+
clearTimeout(timer);
|
|
513
|
+
throw new Error(`SSE response not ok: ${res.status} ${res.statusText}`);
|
|
514
|
+
}
|
|
515
|
+
const reader = res.body.getReader();
|
|
516
|
+
const decoder = new TextDecoder();
|
|
517
|
+
let buffer = "";
|
|
518
|
+
try {
|
|
519
|
+
while (true) {
|
|
520
|
+
const { value, done } = await reader.read();
|
|
521
|
+
if (done) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
buffer += decoder.decode(value, { stream: true });
|
|
525
|
+
let sepIdx;
|
|
526
|
+
while ((sepIdx = buffer.indexOf("\n\n")) !== -1) {
|
|
527
|
+
const rawEvent = buffer.slice(0, sepIdx);
|
|
528
|
+
buffer = buffer.slice(sepIdx + 2);
|
|
529
|
+
const parsed = parseEvent(rawEvent);
|
|
530
|
+
if (!parsed) {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
yield parsed;
|
|
534
|
+
if (opts.untilEvent && parsed.event === opts.untilEvent) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
} finally {
|
|
540
|
+
clearTimeout(timer);
|
|
541
|
+
try {
|
|
542
|
+
reader.cancel();
|
|
543
|
+
} catch {
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
function parseEvent(raw) {
|
|
548
|
+
if (!raw.trim()) {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
let eventName = "";
|
|
552
|
+
const dataLines = [];
|
|
553
|
+
let id;
|
|
554
|
+
for (const line of raw.split("\n")) {
|
|
555
|
+
if (!line || line.startsWith(":")) {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
const colonIdx = line.indexOf(":");
|
|
559
|
+
if (colonIdx === -1) {
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const field = line.slice(0, colonIdx);
|
|
563
|
+
const value = line.slice(colonIdx + 1).replace(/^ /, "");
|
|
564
|
+
if (field === "event") {
|
|
565
|
+
eventName = value;
|
|
566
|
+
} else if (field === "data") {
|
|
567
|
+
dataLines.push(value);
|
|
568
|
+
} else if (field === "id") {
|
|
569
|
+
id = value;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (eventName === "" && dataLines.length === 0) {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
const data = dataLines.join("\n");
|
|
576
|
+
let json;
|
|
577
|
+
if (data) {
|
|
578
|
+
try {
|
|
579
|
+
json = JSON.parse(data);
|
|
580
|
+
} catch {
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return { event: eventName, data, id, json };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// src/jwt-helpers.ts
|
|
587
|
+
async function registerAgent(client, opts = { namespaceId: "e2e-default" }) {
|
|
588
|
+
const name = opts.name ?? "e2e-agent";
|
|
589
|
+
const regRes = await client.post(
|
|
590
|
+
"/auth/register",
|
|
591
|
+
{ name, namespaceId: opts.namespaceId }
|
|
592
|
+
);
|
|
593
|
+
if (!regRes.ok || !regRes.body) {
|
|
594
|
+
throw new Error(`/auth/register failed: ${regRes.status} ${regRes.text}`);
|
|
595
|
+
}
|
|
596
|
+
const { clientId, clientSecret, hostId } = regRes.body;
|
|
597
|
+
const tokenRes = await client.post(
|
|
598
|
+
"/auth/token",
|
|
599
|
+
{ clientId, clientSecret }
|
|
600
|
+
);
|
|
601
|
+
if (!tokenRes.ok || !tokenRes.body) {
|
|
602
|
+
throw new Error(`/auth/token failed: ${tokenRes.status} ${tokenRes.text}`);
|
|
603
|
+
}
|
|
604
|
+
return { accessToken: tokenRes.body.accessToken, clientId, hostId };
|
|
605
|
+
}
|
|
606
|
+
async function registerHost(client, opts) {
|
|
607
|
+
const res = await client.post("/hosts/register", {
|
|
608
|
+
name: opts.name ?? "e2e-host",
|
|
609
|
+
namespaceId: opts.namespaceId,
|
|
610
|
+
capabilities: opts.capabilities ?? ["filesystem"],
|
|
611
|
+
workspacePaths: opts.workspacePaths ?? []
|
|
612
|
+
});
|
|
613
|
+
if (!res.ok || !res.body) {
|
|
614
|
+
throw new Error(`/hosts/register failed: ${res.status} ${res.text}`);
|
|
615
|
+
}
|
|
616
|
+
return res.body;
|
|
617
|
+
}
|
|
618
|
+
var MINIMAL_DEV_CONFIG = `name: KB Labs E2E Isolated Root
|
|
619
|
+
|
|
620
|
+
groups:
|
|
621
|
+
infra: [state-daemon]
|
|
622
|
+
|
|
623
|
+
services:
|
|
624
|
+
state-daemon:
|
|
625
|
+
name: State Daemon
|
|
626
|
+
description: Distributed state management
|
|
627
|
+
group: infra
|
|
628
|
+
type: node
|
|
629
|
+
command: node ./plugins/state/daemon/core-state-daemon/dist/bin.cjs
|
|
630
|
+
healthCheck: http://localhost:7777/health
|
|
631
|
+
port: 7777
|
|
632
|
+
url: http://localhost:7777
|
|
633
|
+
env:
|
|
634
|
+
KB_STATE_DAEMON_PORT: "7777"
|
|
635
|
+
KB_STATE_DAEMON_HOST: localhost
|
|
636
|
+
|
|
637
|
+
settings:
|
|
638
|
+
logsDir: .kb/logs/tmp
|
|
639
|
+
pidDir: .kb/tmp
|
|
640
|
+
startTimeout: 30000
|
|
641
|
+
healthCheckInterval: 1000
|
|
642
|
+
`;
|
|
643
|
+
async function createIsolatedProjectRoot(opts = {}) {
|
|
644
|
+
const prefix = opts.prefix ?? "kb-e2e-";
|
|
645
|
+
const root = await mkdtemp(join(tmpdir(), prefix));
|
|
646
|
+
const kbDir = join(root, ".kb");
|
|
647
|
+
await mkdir(kbDir, { recursive: true });
|
|
648
|
+
await mkdir(join(kbDir, "tmp"), { recursive: true });
|
|
649
|
+
await mkdir(join(kbDir, "logs", "tmp"), { recursive: true });
|
|
650
|
+
const devConfigPath = join(kbDir, "devservices.yaml");
|
|
651
|
+
if (opts.copyDevConfigFrom) {
|
|
652
|
+
const source = resolve(opts.copyDevConfigFrom, ".kb/devservices.yaml");
|
|
653
|
+
if (!existsSync(source)) {
|
|
654
|
+
throw new Error(`copyDevConfigFrom: no devservices.yaml at ${source}`);
|
|
655
|
+
}
|
|
656
|
+
await readFile(source, "utf8");
|
|
657
|
+
await copyFile(source, devConfigPath);
|
|
658
|
+
} else {
|
|
659
|
+
await writeFile(devConfigPath, MINIMAL_DEV_CONFIG, "utf8");
|
|
660
|
+
}
|
|
661
|
+
let disposed = false;
|
|
662
|
+
const cleanup = async () => {
|
|
663
|
+
if (disposed) {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
disposed = true;
|
|
667
|
+
try {
|
|
668
|
+
await rm(root, { recursive: true, force: true });
|
|
669
|
+
} catch {
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
return { root, devConfigPath, cleanup };
|
|
673
|
+
}
|
|
674
|
+
function makeTestNamespace(fileUrlOrPath, prefix = "e2e") {
|
|
675
|
+
const hash = createHash("sha1").update(fileUrlOrPath).digest("hex").slice(0, 6);
|
|
676
|
+
return `${prefix}-${hash}-${nanoid(10)}`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export { HttpClient, KbDevController, closeAllTrackedSockets, connectWs, createIsolatedProjectRoot, findWorkspaceRoot, httpClient, makeTestNamespace, readSse, registerAgent, registerHost, resolveWorkspaceRoot };
|
|
680
|
+
//# sourceMappingURL=index.js.map
|
|
681
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/workspace-root.ts","../src/kb-dev-controller.ts","../src/http-client.ts","../src/ws-client.ts","../src/sse-reader.ts","../src/jwt-helpers.ts","../src/isolated-project-root.ts","../src/namespace.ts"],"names":["resolve","existsSync"],"mappings":"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","file":"index.js","sourcesContent":["import { existsSync } from 'node:fs';\nimport { dirname, resolve } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\n/**\n * Walk up from a starting directory until we find `.kb/devservices.yaml`.\n * Returns the absolute path of the project root, or `null` if not found.\n */\nexport function findWorkspaceRoot(startDir?: string): string | null {\n let dir = startDir ?? process.cwd();\n // Safety: bail after 20 levels so we never climb past `/`.\n for (let i = 0; i < 20; i++) {\n if (existsSync(resolve(dir, '.kb/devservices.yaml'))) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n return null;\n }\n dir = parent;\n }\n return null;\n}\n\n/**\n * Resolve the workspace root for the current package, honoring env overrides.\n * Precedence:\n * 1. `KB_PROJECT_ROOT` env var (matches kb-dev itself).\n * 2. Walk up from `process.cwd()`.\n * 3. Walk up from this source file's directory (handles running tests\n * from deeply nested package dirs).\n *\n * Throws if no root can be located.\n */\nexport function resolveWorkspaceRoot(): string {\n const fromEnv = process.env.KB_PROJECT_ROOT;\n if (fromEnv && existsSync(resolve(fromEnv, '.kb/devservices.yaml'))) {\n return fromEnv;\n }\n const fromCwd = findWorkspaceRoot(process.cwd());\n if (fromCwd) {\n return fromCwd;\n }\n const here = dirname(fileURLToPath(import.meta.url));\n const fromHere = findWorkspaceRoot(here);\n if (fromHere) {\n return fromHere;\n }\n throw new Error(\n 'Could not locate KB Labs workspace root (.kb/devservices.yaml). ' +\n 'Set KB_PROJECT_ROOT or run from inside the workspace.',\n );\n}\n","import { spawn } from 'node:child_process';\nimport { existsSync } from 'node:fs';\nimport { resolve } from 'node:path';\n\nimport type {\n EnsureOptions,\n KbDevControllerOptions,\n KbDevResult,\n ServiceId,\n ServiceStatus,\n StatusSnapshot,\n} from './types.js';\nimport { resolveWorkspaceRoot } from './workspace-root.js';\n\n/**\n * Ring buffer for the last N lines of subprocess output.\n * Used so that on failure the harness can dump recent kb-dev noise\n * into the test output without swamping it with tens of thousands of lines.\n */\nclass RingBuffer {\n private buf: string[] = [];\n constructor(private readonly capacity: number) {}\n push(line: string): void {\n this.buf.push(line);\n if (this.buf.length > this.capacity) {\n this.buf.splice(0, this.buf.length - this.capacity);\n }\n }\n snapshot(): string[] {\n return [...this.buf];\n }\n}\n\ninterface SubprocessResult {\n code: number;\n stdout: string;\n stderr: string;\n}\n\n/**\n * Controller that drives kb-dev as a subprocess and exposes its JSON\n * agent protocol as a typed TypeScript API for e2e tests.\n *\n * Lifecycle: one controller per test file (beforeAll/afterAll).\n * Services booted through this controller are shared across every\n * `describe`/`it` in that file.\n */\nexport class KbDevController {\n readonly projectRoot: string;\n readonly kbDevBin: string;\n private readonly env: Record<string, string>;\n private readonly logSink?: (line: string) => void;\n private readonly ring = new RingBuffer(400);\n /** Services this controller started (and therefore should clean up). */\n private readonly startedByUs = new Set<ServiceId>();\n /** Cached last status snapshot (invalidated on any mutating call). */\n private lastStatus?: StatusSnapshot;\n\n constructor(opts: KbDevControllerOptions = {}) {\n this.projectRoot = opts.projectRoot ?? resolveWorkspaceRoot();\n this.kbDevBin = opts.kbDevBin ?? resolve(this.projectRoot, 'tools/kb-dev/kb-dev');\n if (!existsSync(this.kbDevBin)) {\n throw new Error(\n `kb-dev binary not found at ${this.kbDevBin}. ` +\n 'Build it with: cd tools/kb-dev && make build',\n );\n }\n this.env = {\n ...process.env as Record<string, string>,\n ...(opts.env ?? {}),\n KB_PROJECT_ROOT: this.projectRoot,\n };\n this.logSink = opts.logSink;\n }\n\n // ── Public API ────────────────────────────────────────────────────────────\n\n /**\n * Ensure every listed service is `alive`. Idempotent — if a service is\n * already running, kb-dev reports `skipped` and we don't respawn.\n *\n * Throws with a formatted hint + recent log tail on failure.\n */\n async ensureServices(\n ids: ServiceId[],\n opts: EnsureOptions = {},\n ): Promise<StatusSnapshot> {\n const timeoutMs = opts.timeoutMs ?? 60_000;\n const args = ['ensure', ...ids, '--json'];\n if (opts.force) {args.push('--force');}\n\n const result = await this.runJson<KbDevResult>(args, timeoutMs);\n if (!result.ok) {\n throw this.formatError(`kb-dev ensure ${ids.join(' ')}`, result);\n }\n for (const action of result.actions ?? []) {\n if (action.action === 'started') {\n this.startedByUs.add(action.service as ServiceId);\n }\n }\n // Poll status until every requested service is alive (ensure returns\n // when spawn completes, but health may still be warming up).\n const snapshot = await this.waitUntilAlive(ids, timeoutMs);\n this.lastStatus = snapshot;\n return snapshot;\n }\n\n /**\n * Block until a single service reports `alive` or the timeout expires.\n */\n async ready(id: ServiceId, timeoutMs = 60_000): Promise<void> {\n const deadline = Date.now() + timeoutMs;\n while (Date.now() < deadline) {\n const snap = await this.status();\n const svc = snap.services[id];\n if (svc?.state === 'alive') {return;}\n if (svc?.state === 'failed') {\n throw new Error(\n `Service ${id} entered 'failed' state.\\n` +\n `Logs tail:\\n${(svc.logsTail ?? []).join('\\n')}`,\n );\n }\n await sleep(500);\n }\n throw new Error(`Timeout waiting for ${id} to become alive (${timeoutMs}ms)`);\n }\n\n /** Query current status for all services. */\n async status(): Promise<StatusSnapshot> {\n const result = await this.runJson<StatusSnapshot>(['status', '--json'], 10_000);\n this.lastStatus = result;\n return result;\n }\n\n /**\n * Stop the given services (or everything this controller started, if none given).\n * Always best-effort — errors are logged but never thrown.\n */\n async stopServices(ids?: ServiceId[]): Promise<void> {\n const toStop = ids ?? Array.from(this.startedByUs);\n if (toStop.length === 0) {return;}\n try {\n await this.runJson<KbDevResult>(['stop', ...toStop, '--json'], 30_000);\n } catch (err) {\n // Stop failures should never mask the real test failure.\n this.logSink?.(`[kb-dev] stop failed (ignored): ${(err as Error).message}`);\n }\n for (const id of toStop) {this.startedByUs.delete(id);}\n this.lastStatus = undefined;\n }\n\n /** Disposes the controller — stops every service it started. */\n async dispose(): Promise<void> {\n await this.stopServices();\n }\n\n /**\n * Return the HTTP base URL for a service, e.g. `http://localhost:4000`.\n * Throws if the service is not in the last known status snapshot or has no URL.\n */\n getServiceUrl(id: ServiceId): string {\n const svc = this.requireService(id);\n if (!svc.url) {\n throw new Error(`Service ${id} has no URL in its status (state=${svc.state})`);\n }\n return svc.url;\n }\n\n /** Return the port for a service, or throw if unknown. */\n getServicePort(id: ServiceId): number {\n const svc = this.requireService(id);\n if (!svc.port) {\n throw new Error(`Service ${id} has no port in its status (state=${svc.state})`);\n }\n return svc.port;\n }\n\n /** Snapshot of recent subprocess log lines, newest last. */\n dumpRecentLogs(): string[] {\n return this.ring.snapshot();\n }\n\n // ── Internals ─────────────────────────────────────────────────────────────\n\n private requireService(id: ServiceId): ServiceStatus {\n if (!this.lastStatus) {\n throw new Error(\n `No status snapshot yet for ${id}. Call ensureServices() or status() first.`,\n );\n }\n const svc = this.lastStatus.services[id];\n if (!svc) {\n throw new Error(`Service ${id} not found in status snapshot`);\n }\n return svc;\n }\n\n private async waitUntilAlive(\n ids: ServiceId[],\n timeoutMs: number,\n ): Promise<StatusSnapshot> {\n const deadline = Date.now() + timeoutMs;\n let lastSnap: StatusSnapshot | undefined;\n while (Date.now() < deadline) {\n lastSnap = await this.status();\n const allAlive = ids.every((id) => lastSnap?.services[id]?.state === 'alive');\n if (allAlive) {return lastSnap;}\n // Fail fast on hard failures — no point waiting for a service already marked `failed`.\n const failed = ids.find((id) => lastSnap?.services[id]?.state === 'failed');\n if (failed) {\n const svc = lastSnap!.services[failed]!;\n throw new Error(\n `Service ${failed} failed during ensure.\\n` +\n `Logs tail:\\n${(svc.logsTail ?? []).join('\\n')}`,\n );\n }\n await sleep(500);\n }\n throw new Error(\n `Timeout waiting for services to become alive: ${ids.join(', ')}.\\n` +\n `Last known states: ${ids\n .map((id) => `${id}=${lastSnap?.services[id]?.state ?? 'unknown'}`)\n .join(', ')}`,\n );\n }\n\n private formatError(prefix: string, result: KbDevResult): Error {\n const lines: string[] = [`${prefix} failed`];\n if (result.hint) {lines.push(`Hint: ${result.hint}`);}\n for (const action of result.actions ?? []) {\n if (action.action === 'failed' || action.error) {\n lines.push(` - ${action.service}: ${action.error ?? 'failed'}`);\n for (const log of action.logsTail ?? []) {\n lines.push(` ${log}`);\n }\n }\n }\n const recent = this.ring.snapshot();\n if (recent.length > 0) {\n lines.push('Recent kb-dev output:');\n for (const l of recent.slice(-40)) {lines.push(` ${l}`);}\n }\n return new Error(lines.join('\\n'));\n }\n\n /**\n * Run a kb-dev subcommand and parse the stdout as JSON.\n * kb-dev prints only the JSON object on stdout when `--json` is set;\n * any logs go to stderr.\n */\n private async runJson<T>(args: string[], timeoutMs: number): Promise<T> {\n const result = await this.run(args, timeoutMs);\n if (result.code !== 0 && !result.stdout.trim()) {\n throw new Error(\n `kb-dev ${args.join(' ')} exited with code ${result.code}\\n` +\n `stderr:\\n${result.stderr}`,\n );\n }\n try {\n return JSON.parse(result.stdout) as T;\n } catch (err) {\n throw new Error(\n `Failed to parse JSON from \\`kb-dev ${args.join(' ')}\\`:\\n` +\n ` error: ${(err as Error).message}\\n` +\n ` stdout: ${result.stdout.slice(0, 2000)}\\n` +\n ` stderr: ${result.stderr.slice(0, 2000)}`,\n );\n }\n }\n\n private run(args: string[], timeoutMs: number): Promise<SubprocessResult> {\n return new Promise((resolvePromise) => {\n const child = spawn(this.kbDevBin, args, {\n cwd: this.projectRoot,\n env: this.env,\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n\n let stdout = '';\n let stderr = '';\n\n const onLine = (chunk: Buffer, stream: 'stdout' | 'stderr') => {\n const text = chunk.toString();\n if (stream === 'stdout') {stdout += text;}\n else {stderr += text;}\n for (const line of text.split('\\n')) {\n if (line) {\n this.ring.push(`[${stream}] ${line}`);\n this.logSink?.(`[kb-dev ${args[0]}] [${stream}] ${line}`);\n }\n }\n };\n\n child.stdout.on('data', (d: Buffer) => onLine(d, 'stdout'));\n child.stderr.on('data', (d: Buffer) => onLine(d, 'stderr'));\n\n const timer = setTimeout(() => {\n child.kill('SIGKILL');\n }, timeoutMs);\n\n child.on('close', (code) => {\n clearTimeout(timer);\n resolvePromise({ code: code ?? -1, stdout, stderr });\n });\n child.on('error', (err) => {\n clearTimeout(timer);\n resolvePromise({ code: -1, stdout, stderr: stderr + '\\n' + err.message });\n });\n });\n }\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((r) => { setTimeout(r, ms); });\n}\n","/**\n * Minimal HTTP client wrapper used by e2e tests.\n *\n * Intentionally thin — tests should be able to read one line and know\n * exactly what request went out. No retries, no interceptors, no magic.\n */\n\nexport interface HttpClientOptions {\n /** Extra headers included on every request. */\n headers?: Record<string, string>;\n /** Per-request timeout in ms. Default 15_000. */\n timeoutMs?: number;\n}\n\nexport interface HttpResponse<T = unknown> {\n status: number;\n ok: boolean;\n headers: Headers;\n /** Parsed JSON body, or `undefined` if the response had no body / non-JSON content. */\n body: T | undefined;\n /** Raw text body, always populated. */\n text: string;\n}\n\nexport class HttpClient {\n constructor(\n private readonly baseUrl: string,\n private readonly defaults: HttpClientOptions = {},\n ) {}\n\n async get<T = unknown>(path: string, opts: HttpClientOptions = {}): Promise<HttpResponse<T>> {\n return this.request<T>('GET', path, undefined, opts);\n }\n\n async post<T = unknown>(\n path: string,\n body: unknown,\n opts: HttpClientOptions = {},\n ): Promise<HttpResponse<T>> {\n return this.request<T>('POST', path, body, opts);\n }\n\n async delete<T = unknown>(\n path: string,\n opts: HttpClientOptions = {},\n ): Promise<HttpResponse<T>> {\n return this.request<T>('DELETE', path, undefined, opts);\n }\n\n async put<T = unknown>(\n path: string,\n body: unknown,\n opts: HttpClientOptions = {},\n ): Promise<HttpResponse<T>> {\n return this.request<T>('PUT', path, body, opts);\n }\n\n async options<T = unknown>(\n path: string,\n opts: HttpClientOptions = {},\n ): Promise<HttpResponse<T>> {\n return this.request<T>('OPTIONS', path, undefined, opts);\n }\n\n /** Build an absolute URL for a path under this client's base. */\n url(path: string): string {\n if (path.startsWith('http://') || path.startsWith('https://')) {return path;}\n return `${this.baseUrl.replace(/\\/$/, '')}${path.startsWith('/') ? path : `/${path}`}`;\n }\n\n private async request<T>(\n method: string,\n path: string,\n body: unknown,\n opts: HttpClientOptions,\n ): Promise<HttpResponse<T>> {\n const timeoutMs = opts.timeoutMs ?? this.defaults.timeoutMs ?? 15_000;\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n const headers: Record<string, string> = {\n ...this.defaults.headers,\n ...opts.headers,\n };\n if (body !== undefined && !headers['content-type'] && !headers['Content-Type']) {\n headers['content-type'] = 'application/json';\n }\n\n try {\n const res = await fetch(this.url(path), {\n method,\n headers,\n body: body === undefined ? undefined : JSON.stringify(body),\n signal: controller.signal,\n });\n const text = await res.text();\n let parsed: T | undefined;\n const contentType = res.headers.get('content-type') ?? '';\n if (text && contentType.includes('json')) {\n try {\n parsed = JSON.parse(text) as T;\n } catch {\n parsed = undefined;\n }\n }\n return {\n status: res.status,\n ok: res.ok,\n headers: res.headers,\n body: parsed,\n text,\n };\n } finally {\n clearTimeout(timer);\n }\n }\n}\n\nexport function httpClient(baseUrl: string, opts: HttpClientOptions = {}): HttpClient {\n return new HttpClient(baseUrl, opts);\n}\n","import { WebSocket, type RawData } from 'ws';\n\n/**\n * Test-friendly WebSocket wrapper.\n *\n * Adapted from `infra/kb-labs-gateway/apps/gateway-app/src/__tests__/live-gateway.e2e.test.ts`\n * — same tracking semantics so the harness can clean up leaked sockets\n * between tests even when an assertion fails mid-flight.\n */\n\nexport interface WsOptions {\n headers?: Record<string, string>;\n /** ms. Default 8000. */\n openTimeoutMs?: number;\n}\n\n/** Opaque handle returned from `connectWs`. */\nexport interface WsHandle {\n readonly socket: WebSocket;\n /** Send a JSON-stringified message. */\n send(data: unknown): void;\n /** Wait for the next message, optionally satisfying a predicate. */\n waitForMessage<T = unknown>(\n opts?: { timeoutMs?: number; predicate?: (msg: T) => boolean },\n ): Promise<T>;\n /** Collect the next N messages (parsed as JSON). */\n collect<T = unknown>(count: number, timeoutMs?: number): Promise<T[]>;\n close(code?: number): void;\n}\n\n/**\n * Shared registry of open sockets. Call `closeAllTrackedSockets()` from an\n * `afterEach` to guarantee cleanup.\n */\nconst openSockets = new Set<WebSocket>();\n\nexport async function closeAllTrackedSockets(graceMs = 150): Promise<void> {\n const toClose = [...openSockets];\n for (const ws of toClose) {\n if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) {\n try {\n ws.close(1000);\n } catch {\n // intentionally empty: best-effort cleanup, socket may already be dead\n }\n }\n }\n openSockets.clear();\n if (toClose.length > 0) {\n await new Promise((r) => { setTimeout(r, graceMs); });\n }\n}\n\nexport function connectWs(url: string, opts: WsOptions = {}): Promise<WsHandle> {\n const openTimeoutMs = opts.openTimeoutMs ?? 8000;\n return new Promise((resolvePromise, reject) => {\n const ws = new WebSocket(url, {\n headers: opts.headers,\n });\n openSockets.add(ws);\n ws.on('close', () => openSockets.delete(ws));\n\n const timer = setTimeout(() => {\n reject(new Error(`WebSocket open timeout after ${openTimeoutMs}ms: ${url}`));\n try { ws.close(); } catch {\n // intentionally empty: best-effort cleanup on timeout\n }\n }, openTimeoutMs);\n\n ws.once('open', () => {\n clearTimeout(timer);\n resolvePromise(wrapHandle(ws));\n });\n ws.once('error', (err) => {\n clearTimeout(timer);\n reject(err);\n });\n });\n}\n\nfunction wrapHandle(ws: WebSocket): WsHandle {\n return {\n socket: ws,\n send(data: unknown) {\n ws.send(typeof data === 'string' ? data : JSON.stringify(data));\n },\n waitForMessage<T = unknown>(\n opts: { timeoutMs?: number; predicate?: (msg: T) => boolean } = {},\n ): Promise<T> {\n const timeoutMs = opts.timeoutMs ?? 5000;\n return new Promise((resolvePromise, reject) => {\n const timer = setTimeout(() => {\n ws.off('message', onMessage);\n reject(new Error(`WebSocket message timeout after ${timeoutMs}ms`));\n }, timeoutMs);\n const onMessage = (raw: RawData) => {\n let msg: T;\n try {\n msg = JSON.parse(raw.toString()) as T;\n } catch (err) {\n clearTimeout(timer);\n ws.off('message', onMessage);\n reject(new Error(`Non-JSON WebSocket message: ${(err as Error).message}`));\n return;\n }\n if (!opts.predicate || opts.predicate(msg)) {\n clearTimeout(timer);\n ws.off('message', onMessage);\n resolvePromise(msg);\n }\n };\n ws.on('message', onMessage);\n });\n },\n collect<T = unknown>(count: number, timeoutMs = 5000): Promise<T[]> {\n return new Promise((resolvePromise, reject) => {\n const msgs: T[] = [];\n const timer = setTimeout(() => {\n ws.off('message', onMessage);\n reject(\n new Error(\n `Timeout: expected ${count} WS messages, got ${msgs.length}: ${JSON.stringify(msgs)}`,\n ),\n );\n }, timeoutMs);\n const onMessage = (raw: RawData) => {\n msgs.push(JSON.parse(raw.toString()) as T);\n if (msgs.length >= count) {\n clearTimeout(timer);\n ws.off('message', onMessage);\n resolvePromise(msgs);\n }\n };\n ws.on('message', onMessage);\n });\n },\n close(code = 1000) {\n try {\n ws.close(code);\n } catch {\n // intentionally empty: best-effort cleanup\n }\n },\n };\n}\n","/**\n * Minimal Server-Sent Events reader.\n *\n * Consumes `text/event-stream` responses and yields one `SseEvent` per\n * `event:`/`data:` pair. Terminates on close, explicit `untilEvent`, or timeout.\n */\n\nexport interface SseEvent {\n /** SSE `event:` field. Empty string if absent. */\n event: string;\n /** SSE `data:` field, concatenated with newlines if multi-line. */\n data: string;\n /** Optional `id:` field. */\n id?: string;\n /** Parsed JSON body if `data` is valid JSON, else undefined. */\n json?: unknown;\n}\n\nexport interface SseOptions {\n headers?: Record<string, string>;\n /** If set, the iterator terminates after the first event with this name. */\n untilEvent?: string;\n /** Wall-clock timeout in ms. Default 30_000. */\n timeoutMs?: number;\n}\n\n/**\n * Async iterator over an SSE stream. Caller is responsible for iterating\n * (e.g. `for await (const event of readSse(url)) { ... }`).\n */\nexport async function* readSse(url: string, opts: SseOptions = {}): AsyncGenerator<SseEvent> {\n const timeoutMs = opts.timeoutMs ?? 30_000;\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let res: Response;\n try {\n res = await fetch(url, {\n headers: { accept: 'text/event-stream', ...opts.headers },\n signal: controller.signal,\n });\n } catch (err) {\n clearTimeout(timer);\n throw new Error(`SSE fetch failed: ${(err as Error).message}`);\n }\n\n if (!res.ok || !res.body) {\n clearTimeout(timer);\n throw new Error(`SSE response not ok: ${res.status} ${res.statusText}`);\n }\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n\n try {\n while (true) {\n const { value, done } = await reader.read();\n if (done) {return;}\n buffer += decoder.decode(value, { stream: true });\n\n // SSE events are separated by a blank line (\\n\\n).\n let sepIdx: number;\n while ((sepIdx = buffer.indexOf('\\n\\n')) !== -1) {\n const rawEvent = buffer.slice(0, sepIdx);\n buffer = buffer.slice(sepIdx + 2);\n const parsed = parseEvent(rawEvent);\n if (!parsed) {continue;}\n yield parsed;\n if (opts.untilEvent && parsed.event === opts.untilEvent) {\n return;\n }\n }\n }\n } finally {\n clearTimeout(timer);\n try { reader.cancel(); } catch {\n // intentionally empty: reader may already be closed\n }\n }\n}\n\nfunction parseEvent(raw: string): SseEvent | null {\n if (!raw.trim()) {return null;}\n let eventName = '';\n const dataLines: string[] = [];\n let id: string | undefined;\n\n for (const line of raw.split('\\n')) {\n if (!line || line.startsWith(':')) {continue;} // comment / keep-alive\n const colonIdx = line.indexOf(':');\n if (colonIdx === -1) {continue;}\n const field = line.slice(0, colonIdx);\n // Per spec: strip a single leading space from value.\n const value = line.slice(colonIdx + 1).replace(/^ /, '');\n if (field === 'event') {eventName = value;}\n else if (field === 'data') {dataLines.push(value);}\n else if (field === 'id') {id = value;}\n }\n\n if (eventName === '' && dataLines.length === 0) {return null;}\n const data = dataLines.join('\\n');\n let json: unknown;\n if (data) {\n try { json = JSON.parse(data); } catch { /* not JSON, leave undefined */ }\n }\n return { event: eventName, data, id, json };\n}\n","/**\n * Gateway auth helpers.\n *\n * Extracted from `infra/kb-labs-gateway/apps/gateway-app/src/__tests__/live-gateway.e2e.test.ts`\n * (`getJwtToken`, `registerHost`) so every e2e test can reuse the same flow.\n *\n * Flow:\n * 1. POST /auth/register { name, namespaceId } -> { clientId, clientSecret, hostId }\n * 2. POST /auth/token { clientId, clientSecret } -> { accessToken }\n */\n\nimport type { HttpClient } from './http-client.js';\n\nexport interface AgentCredentials {\n accessToken: string;\n clientId: string;\n hostId: string;\n}\n\nexport interface HostCredentials {\n hostId: string;\n machineToken: string;\n}\n\nexport async function registerAgent(\n client: HttpClient,\n opts: { name?: string; namespaceId: string } = { namespaceId: 'e2e-default' },\n): Promise<AgentCredentials> {\n const name = opts.name ?? 'e2e-agent';\n const regRes = await client.post<{ clientId: string; clientSecret: string; hostId: string }>(\n '/auth/register',\n { name, namespaceId: opts.namespaceId },\n );\n if (!regRes.ok || !regRes.body) {\n throw new Error(`/auth/register failed: ${regRes.status} ${regRes.text}`);\n }\n const { clientId, clientSecret, hostId } = regRes.body;\n\n const tokenRes = await client.post<{ accessToken: string }>(\n '/auth/token',\n { clientId, clientSecret },\n );\n if (!tokenRes.ok || !tokenRes.body) {\n throw new Error(`/auth/token failed: ${tokenRes.status} ${tokenRes.text}`);\n }\n\n return { accessToken: tokenRes.body.accessToken, clientId, hostId };\n}\n\nexport async function registerHost(\n client: HttpClient,\n opts: {\n name?: string;\n namespaceId: string;\n capabilities?: string[];\n workspacePaths?: string[];\n },\n): Promise<HostCredentials> {\n const res = await client.post<HostCredentials>('/hosts/register', {\n name: opts.name ?? 'e2e-host',\n namespaceId: opts.namespaceId,\n capabilities: opts.capabilities ?? ['filesystem'],\n workspacePaths: opts.workspacePaths ?? [],\n });\n if (!res.ok || !res.body) {\n throw new Error(`/hosts/register failed: ${res.status} ${res.text}`);\n }\n return res.body;\n}\n","import { mkdir, mkdtemp, rm, writeFile, copyFile, readFile } from 'node:fs/promises';\nimport { existsSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join, resolve } from 'node:path';\n\n/**\n * An isolated temporary project root with its own `.kb/` directory.\n *\n * Used by marketplace/plugin e2e tests so real workspace `.kb/marketplace.lock`\n * and `.kb/plugins.json` are never mutated.\n *\n * Pass the returned `root` via `KB_PROJECT_ROOT` env when starting kb-dev.\n */\nexport interface IsolatedProjectRoot {\n /** Absolute path to the temp project root. */\n root: string;\n /** Absolute path to `<root>/.kb/devservices.yaml`. */\n devConfigPath: string;\n /** Delete the temp dir. Safe to call multiple times. */\n cleanup(): Promise<void>;\n}\n\nexport interface IsolatedProjectRootOptions {\n /**\n * If given, copies the devservices.yaml from this existing workspace root.\n * If omitted, a minimal config with only `state-daemon` is written.\n */\n copyDevConfigFrom?: string;\n /** Prefix for the temp dir name (for human diagnosis). */\n prefix?: string;\n}\n\nconst MINIMAL_DEV_CONFIG = `name: KB Labs E2E Isolated Root\n\ngroups:\n infra: [state-daemon]\n\nservices:\n state-daemon:\n name: State Daemon\n description: Distributed state management\n group: infra\n type: node\n command: node ./plugins/state/daemon/core-state-daemon/dist/bin.cjs\n healthCheck: http://localhost:7777/health\n port: 7777\n url: http://localhost:7777\n env:\n KB_STATE_DAEMON_PORT: \"7777\"\n KB_STATE_DAEMON_HOST: localhost\n\nsettings:\n logsDir: .kb/logs/tmp\n pidDir: .kb/tmp\n startTimeout: 30000\n healthCheckInterval: 1000\n`;\n\nexport async function createIsolatedProjectRoot(\n opts: IsolatedProjectRootOptions = {},\n): Promise<IsolatedProjectRoot> {\n const prefix = opts.prefix ?? 'kb-e2e-';\n const root = await mkdtemp(join(tmpdir(), prefix));\n const kbDir = join(root, '.kb');\n await mkdir(kbDir, { recursive: true });\n await mkdir(join(kbDir, 'tmp'), { recursive: true });\n await mkdir(join(kbDir, 'logs', 'tmp'), { recursive: true });\n\n const devConfigPath = join(kbDir, 'devservices.yaml');\n\n if (opts.copyDevConfigFrom) {\n const source = resolve(opts.copyDevConfigFrom, '.kb/devservices.yaml');\n if (!existsSync(source)) {\n throw new Error(`copyDevConfigFrom: no devservices.yaml at ${source}`);\n }\n // Read to validate it exists and is readable.\n await readFile(source, 'utf8');\n await copyFile(source, devConfigPath);\n } else {\n await writeFile(devConfigPath, MINIMAL_DEV_CONFIG, 'utf8');\n }\n\n let disposed = false;\n const cleanup = async () => {\n if (disposed) {return;}\n disposed = true;\n try {\n await rm(root, { recursive: true, force: true });\n } catch {\n // intentionally empty: best-effort cleanup, tmpdir will be reaped eventually\n }\n };\n\n return { root, devConfigPath, cleanup };\n}\n","import { createHash } from 'node:crypto';\nimport { nanoid } from 'nanoid';\n\n/**\n * Build a deterministic-yet-unique namespace prefix for e2e test resources.\n *\n * Shape: `e2e-<fileHash>-<nanoid>`\n *\n * - `fileHash` is 6 hex chars of sha1(filePath) — lets you grep leaked resources\n * back to the test file that created them.\n * - `nanoid` is a 10-char random suffix — lets parallel runs never collide.\n *\n * Pass `import.meta.url` from the test file.\n */\nexport function makeTestNamespace(fileUrlOrPath: string, prefix = 'e2e'): string {\n const hash = createHash('sha1').update(fileUrlOrPath).digest('hex').slice(0, 6);\n return `${prefix}-${hash}-${nanoid(10)}`;\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kb-labs/shared-testing-e2e",
|
|
3
|
+
"version": "2.6.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "E2E test harness for KB Labs platform — kb-dev controller, HTTP/WS/SSE helpers, isolated project roots",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"fixtures",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"scripts": {
|
|
22
|
+
"clean": "rimraf dist",
|
|
23
|
+
"build": "tsup --config tsup.config.ts",
|
|
24
|
+
"dev": "tsup --config tsup.config.ts --watch",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:watch": "vitest",
|
|
27
|
+
"type-check": "tsc --noEmit",
|
|
28
|
+
"lint": "eslint src",
|
|
29
|
+
"lint:fix": "eslint src --fix"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"nanoid": "^5.0.0",
|
|
33
|
+
"ws": "^8.18.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@kb-labs/devkit": "workspace:*",
|
|
37
|
+
"@types/node": "^24.3.3",
|
|
38
|
+
"@types/ws": "^8.5.12",
|
|
39
|
+
"rimraf": "^6.0.1",
|
|
40
|
+
"tsup": "^8.5.0",
|
|
41
|
+
"typescript": "^5.6.3",
|
|
42
|
+
"vitest": "^3.2.4"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20.0.0",
|
|
46
|
+
"pnpm": ">=9.0.0"
|
|
47
|
+
},
|
|
48
|
+
"packageManager": "pnpm@9.11.0",
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
|
+
}
|