@jterrazz/test 7.1.0 → 9.0.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.
Files changed (48) hide show
  1. package/README.md +209 -230
  2. package/dist/checker.d.ts +1 -0
  3. package/dist/checker.js +741 -0
  4. package/dist/index.d.ts +1296 -529
  5. package/dist/index.js +3303 -1261
  6. package/dist/intercept.js +129 -290
  7. package/dist/match.js +153 -0
  8. package/dist/oxlint.cjs +2931 -0
  9. package/dist/oxlint.d.cts +150 -0
  10. package/dist/oxlint.d.ts +150 -0
  11. package/dist/oxlint.js +2925 -0
  12. package/package.json +47 -41
  13. package/dist/chunk.cjs +0 -28
  14. package/dist/index.cjs +0 -1814
  15. package/dist/index.cjs.map +0 -1
  16. package/dist/index.d.cts +0 -734
  17. package/dist/index.js.map +0 -1
  18. package/dist/intercept.cjs +0 -313
  19. package/dist/intercept.cjs.map +0 -1
  20. package/dist/intercept.d.cts +0 -105
  21. package/dist/intercept.d.ts +0 -105
  22. package/dist/intercept.js.map +0 -1
  23. package/dist/intercept2.cjs +0 -81
  24. package/dist/intercept2.cjs.map +0 -1
  25. package/dist/intercept2.js +0 -81
  26. package/dist/intercept2.js.map +0 -1
  27. package/dist/mock-of.cjs +0 -32
  28. package/dist/mock-of.cjs.map +0 -1
  29. package/dist/mock-of.d.cts +0 -27
  30. package/dist/mock-of.d.ts +0 -27
  31. package/dist/mock-of.js +0 -19
  32. package/dist/mock-of.js.map +0 -1
  33. package/dist/mock.cjs +0 -4
  34. package/dist/mock.d.cts +0 -2
  35. package/dist/mock.d.ts +0 -2
  36. package/dist/mock.js +0 -2
  37. package/dist/services.cjs +0 -5
  38. package/dist/services.d.cts +0 -2
  39. package/dist/services.d.ts +0 -2
  40. package/dist/services.js +0 -2
  41. package/dist/sqlite.adapter.cjs +0 -350
  42. package/dist/sqlite.adapter.cjs.map +0 -1
  43. package/dist/sqlite.adapter.d.cts +0 -209
  44. package/dist/sqlite.adapter.d.ts +0 -209
  45. package/dist/sqlite.adapter.js +0 -331
  46. package/dist/sqlite.adapter.js.map +0 -1
  47. package/dist/types.d.cts +0 -42
  48. package/dist/types.d.ts +0 -42
package/dist/index.cjs DELETED
@@ -1,1814 +0,0 @@
1
- Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- require("./chunk.cjs");
3
- const require_sqlite_adapter = require("./sqlite.adapter.cjs");
4
- const require_mock_of = require("./mock-of.cjs");
5
- let node_child_process = require("node:child_process");
6
- let node_fs = require("node:fs");
7
- let node_os = require("node:os");
8
- let node_path = require("node:path");
9
- let node_fs_promises = require("node:fs/promises");
10
- let yaml = require("yaml");
11
- //#region src/builder/cli/adapters/exec.adapter.ts
12
- /**
13
- * Build a child-process env from the parent env plus user overrides.
14
- * `null` overrides delete keys (e.g. `INIT_CWD: null`).
15
- */
16
- function buildEnv(extra) {
17
- const env = {
18
- ...process.env,
19
- INIT_CWD: void 0
20
- };
21
- if (extra) for (const [key, value] of Object.entries(extra)) if (value === null) delete env[key];
22
- else env[key] = value;
23
- return env;
24
- }
25
- /**
26
- * Executes CLI commands via Node.js child_process.
27
- * Uses `execSync` for one-shot commands and `spawn` for long-running processes.
28
- * Used by the `cli()` specification runner.
29
- */
30
- var ExecAdapter = class {
31
- command;
32
- constructor(command) {
33
- this.command = command;
34
- }
35
- async exec(args, cwd, extraEnv) {
36
- const env = buildEnv(extraEnv);
37
- try {
38
- return {
39
- exitCode: 0,
40
- stdout: (0, node_child_process.execSync)(`${this.command} ${args}`, {
41
- cwd,
42
- encoding: "utf8",
43
- env,
44
- stdio: [
45
- "pipe",
46
- "pipe",
47
- "pipe"
48
- ]
49
- }),
50
- stderr: ""
51
- };
52
- } catch (error) {
53
- return {
54
- exitCode: error.status ?? 1,
55
- stdout: error.stdout?.toString() ?? "",
56
- stderr: error.stderr?.toString() ?? ""
57
- };
58
- }
59
- }
60
- async spawn(args, cwd, options, extraEnv) {
61
- const env = buildEnv(extraEnv);
62
- return new Promise((resolve) => {
63
- let stdout = "";
64
- let stderr = "";
65
- let resolved = false;
66
- const child = (0, node_child_process.spawn)(this.command, args.split(/\s+/).filter(Boolean), {
67
- cwd,
68
- env,
69
- stdio: [
70
- "pipe",
71
- "pipe",
72
- "pipe"
73
- ]
74
- });
75
- const finish = (exitCode) => {
76
- if (resolved) return;
77
- resolved = true;
78
- child.kill("SIGTERM");
79
- resolve({
80
- exitCode,
81
- stdout,
82
- stderr
83
- });
84
- };
85
- let patternMatched = false;
86
- const checkPattern = () => {
87
- if (!patternMatched && (stdout.includes(options.waitFor) || stderr.includes(options.waitFor))) {
88
- patternMatched = true;
89
- finish(0);
90
- }
91
- };
92
- child.stdout?.on("data", (data) => {
93
- stdout += data.toString();
94
- checkPattern();
95
- });
96
- child.stderr?.on("data", (data) => {
97
- stderr += data.toString();
98
- checkPattern();
99
- });
100
- child.on("exit", (code) => {
101
- if (!patternMatched) finish(code === 0 ? 1 : code ?? 1);
102
- });
103
- setTimeout(() => finish(124), options.timeout);
104
- });
105
- }
106
- };
107
- //#endregion
108
- //#region src/builder/http/adapters/fetch.adapter.ts
109
- /**
110
- * Server adapter that sends real HTTP requests via the Fetch API.
111
- * Used by the `e2e()` specification runner to hit a live server.
112
- */
113
- var FetchAdapter = class {
114
- baseUrl;
115
- constructor(url) {
116
- this.baseUrl = url.replace(/\/$/, "");
117
- }
118
- async request(method, path, body, headers) {
119
- const init = {
120
- method,
121
- headers: {
122
- "Content-Type": "application/json",
123
- ...headers
124
- }
125
- };
126
- if (body !== void 0) init.body = JSON.stringify(body);
127
- const response = await fetch(`${this.baseUrl}${path}`, init);
128
- const responseBody = await response.json().catch(() => null);
129
- const responseHeaders = {};
130
- response.headers.forEach((value, key) => {
131
- responseHeaders[key] = value;
132
- });
133
- return {
134
- status: response.status,
135
- body: responseBody,
136
- headers: responseHeaders
137
- };
138
- }
139
- };
140
- //#endregion
141
- //#region src/builder/http/adapters/hono.adapter.ts
142
- /**
143
- * Server adapter that dispatches requests in-process through a Hono app instance.
144
- * Used by the `integration()` specification runner -- no network overhead.
145
- */
146
- var HonoAdapter = class {
147
- app;
148
- constructor(app) {
149
- this.app = app;
150
- }
151
- async request(method, path, body, headers) {
152
- const init = {
153
- method,
154
- headers: {
155
- "Content-Type": "application/json",
156
- ...headers
157
- }
158
- };
159
- if (body !== void 0) init.body = JSON.stringify(body);
160
- const response = await this.app.request(path, init);
161
- const responseBody = await response.json().catch(() => null);
162
- const responseHeaders = {};
163
- response.headers.forEach((value, key) => {
164
- responseHeaders[key] = value;
165
- });
166
- return {
167
- status: response.status,
168
- body: responseBody,
169
- headers: responseHeaders
170
- };
171
- }
172
- };
173
- //#endregion
174
- //#region src/builder/common/directory.ts
175
- /**
176
- * Default ignore patterns — paths that should never appear in a tracked snapshot.
177
- * Each entry is matched against any path segment OR a path prefix.
178
- */
179
- const DEFAULT_IGNORES = [
180
- ".git",
181
- ".DS_Store",
182
- "node_modules",
183
- ".next",
184
- "dist",
185
- ".turbo",
186
- ".cache"
187
- ];
188
- /**
189
- * Recursively walk a directory, returning sorted relative paths of files only.
190
- * Ignored entries (default + caller-supplied) are skipped.
191
- */
192
- async function walkDirectory(root, options = {}) {
193
- const ignores = new Set([...DEFAULT_IGNORES, ...options.ignore ?? []]);
194
- const out = [];
195
- async function walk(current) {
196
- let entries;
197
- try {
198
- entries = await (0, node_fs_promises.readdir)(current);
199
- } catch {
200
- return;
201
- }
202
- for (const entry of entries) {
203
- if (ignores.has(entry)) continue;
204
- const abs = (0, node_path.resolve)(current, entry);
205
- const stat = (0, node_fs.statSync)(abs);
206
- if (stat.isDirectory()) await walk(abs);
207
- else if (stat.isFile()) out.push((0, node_path.relative)(root, abs).split(node_path.sep).join("/"));
208
- }
209
- }
210
- await walk(root);
211
- out.sort();
212
- return out;
213
- }
214
- /**
215
- * Compare two directory trees file-by-file.
216
- * Binary files are compared by byte equality but reported without inline diff.
217
- */
218
- async function diffDirectories(expectedRoot, actualRoot, options = {}) {
219
- const expectedFiles = await walkDirectory(expectedRoot, options);
220
- const actualFiles = await walkDirectory(actualRoot, options);
221
- const expectedSet = new Set(expectedFiles);
222
- const actualSet = new Set(actualFiles);
223
- const added = actualFiles.filter((f) => !expectedSet.has(f));
224
- const removed = expectedFiles.filter((f) => !actualSet.has(f));
225
- const changed = [];
226
- for (const file of expectedFiles) {
227
- if (!actualSet.has(file)) continue;
228
- const expected = (0, node_fs.readFileSync)((0, node_path.resolve)(expectedRoot, file), "utf8");
229
- const actual = (0, node_fs.readFileSync)((0, node_path.resolve)(actualRoot, file), "utf8");
230
- if (expected !== actual) changed.push({
231
- actual,
232
- expected,
233
- path: file
234
- });
235
- }
236
- return {
237
- added,
238
- changed,
239
- removed
240
- };
241
- }
242
- //#endregion
243
- //#region src/builder/common/reporter.ts
244
- const GREEN = "\x1B[32m";
245
- const RED = "\x1B[31m";
246
- const DIM = "\x1B[2m";
247
- const BOLD = "\x1B[1m";
248
- const RESET = "\x1B[0m";
249
- const BG_CYAN = "\x1B[46m";
250
- const BLACK = "\x1B[30m";
251
- const CHECK = "✓";
252
- const CROSS = "×";
253
- const ARROW = "→";
254
- function formatStartupReport(mode, services, app) {
255
- const lines = [];
256
- lines.push("");
257
- lines.push(`${BG_CYAN}${BLACK}${BOLD} INFRA ${RESET} Starting infrastructure...`);
258
- lines.push("");
259
- for (const service of services) if (service.error) {
260
- lines.push(` ${RED}${CROSS}${RESET} ${service.type} (${service.name}) ${RED}${service.error}${RESET} ${DIM}${service.durationMs}ms${RESET}`);
261
- if (service.logs) {
262
- const logLines = service.logs.trim().split("\n").slice(-10);
263
- for (const logLine of logLines) lines.push(` ${DIM}${logLine}${RESET}`);
264
- }
265
- } else {
266
- const conn = service.connectionString ? `${DIM}${service.connectionString}${RESET}` : "";
267
- lines.push(` ${GREEN}${CHECK}${RESET} ${service.type} (${service.name}) ${conn} ${DIM}${service.durationMs}ms${RESET}`);
268
- }
269
- if (app) {
270
- lines.push("");
271
- if (app.type === "in-process") lines.push(` ${DIM}${ARROW} app: in-process (Hono)${RESET}`);
272
- else lines.push(` ${DIM}${ARROW} app: ${app.url}${RESET}`);
273
- }
274
- lines.push("");
275
- return lines.join("\n");
276
- }
277
- function formatTableDiff(table, columns, expected, actual) {
278
- const lines = [];
279
- lines.push(`Table "${table}" mismatch`);
280
- lines.push(`${DIM} query: ${columns.join(", ")}${RESET}`);
281
- lines.push(`${DIM} expected: ${rowLabel(expected.length)}${RESET}`);
282
- lines.push(`${DIM} received: ${rowLabel(actual.length)}${RESET}`);
283
- lines.push("");
284
- lines.push(`${GREEN}- Expected${RESET}`);
285
- lines.push(`${RED}+ Received${RESET}`);
286
- lines.push("");
287
- const header = columns.join(" | ");
288
- lines.push(`${DIM} ${header}${RESET}`);
289
- const maxRows = Math.max(expected.length, actual.length);
290
- for (let i = 0; i < maxRows; i++) {
291
- const exp = expected[i];
292
- const act = actual[i];
293
- if (exp && !act) lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`);
294
- else if (!exp && act) lines.push(`${RED}+ ${formatRow(act)}${RESET}`);
295
- else if (exp && act) if (JSON.stringify(exp) === JSON.stringify(act)) lines.push(` ${formatRow(act)}`);
296
- else {
297
- lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`);
298
- lines.push(`${RED}+ ${formatRow(act)}${RESET}`);
299
- }
300
- }
301
- if (expected.length === 0 && actual.length === 0) lines.push(` (empty)`);
302
- return lines.join("\n");
303
- }
304
- function formatResponseDiff(file, expected, actual) {
305
- const lines = [];
306
- lines.push(`Response mismatch (${file})`);
307
- lines.push("");
308
- lines.push(`${GREEN}- Expected${RESET}`);
309
- lines.push(`${RED}+ Received${RESET}`);
310
- lines.push("");
311
- const expectedLines = JSON.stringify(expected, null, 2).split("\n");
312
- const actualLines = JSON.stringify(actual, null, 2).split("\n");
313
- const maxLines = Math.max(expectedLines.length, actualLines.length);
314
- for (let i = 0; i < maxLines; i++) {
315
- const exp = expectedLines[i];
316
- const act = actualLines[i];
317
- if (exp === act) lines.push(` ${exp}`);
318
- else {
319
- if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
320
- if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
321
- }
322
- }
323
- return lines.join("\n");
324
- }
325
- function formatDirectoryDiff(fixtureName, diff, hint) {
326
- const lines = [];
327
- const total = diff.added.length + diff.removed.length + diff.changed.length;
328
- lines.push(`Directory mismatch: ${BOLD}${fixtureName}${RESET}`);
329
- lines.push(`${DIM} ${total} difference${total === 1 ? "" : "s"}: ${diff.added.length} added, ${diff.removed.length} removed, ${diff.changed.length} changed${RESET}`);
330
- lines.push("");
331
- lines.push(`${GREEN}- Expected (fixture)${RESET}`);
332
- lines.push(`${RED}+ Received (generated)${RESET}`);
333
- lines.push("");
334
- for (const path of diff.added) lines.push(`${RED}+ added ${path}${RESET} ${DIM}(not in fixture)${RESET}`);
335
- for (const path of diff.removed) lines.push(`${GREEN}- removed ${path}${RESET} ${DIM}(in fixture, not generated)${RESET}`);
336
- for (const { path, expected, actual } of diff.changed) {
337
- const expectedLines = expected.split("\n");
338
- const actualLines = actual.split("\n");
339
- const changedCount = countLineDifferences(expectedLines, actualLines);
340
- lines.push(`${BOLD}~ changed ${path}${RESET} ${DIM}(${changedCount} line${changedCount === 1 ? "" : "s"} differ)${RESET}`);
341
- let shown = 0;
342
- const maxShown = 5;
343
- const maxLines = Math.max(expectedLines.length, actualLines.length);
344
- for (let i = 0; i < maxLines && shown < maxShown; i++) {
345
- const exp = expectedLines[i];
346
- const act = actualLines[i];
347
- if (exp !== act) {
348
- lines.push(`${DIM} line ${i + 1}:${RESET}`);
349
- if (exp !== void 0) lines.push(` ${GREEN}- ${exp}${RESET}`);
350
- if (act !== void 0) lines.push(` ${RED}+ ${act}${RESET}`);
351
- shown++;
352
- }
353
- }
354
- if (changedCount > maxShown) lines.push(` ${DIM}... ${changedCount - maxShown} more line(s)${RESET}`);
355
- }
356
- lines.push("");
357
- lines.push(`${DIM}${hint}${RESET}`);
358
- return lines.join("\n");
359
- }
360
- function countLineDifferences(expected, actual) {
361
- let count = 0;
362
- const max = Math.max(expected.length, actual.length);
363
- for (let i = 0; i < max; i++) if (expected[i] !== actual[i]) count++;
364
- return count;
365
- }
366
- function rowLabel(n) {
367
- return n === 1 ? "1 row" : `${n} rows`;
368
- }
369
- function formatRow(row) {
370
- return row.map((v) => String(v ?? "null")).join(" | ");
371
- }
372
- /** Strip ANSI escape codes from a string. */
373
- function stripAnsi(str) {
374
- return str.replace(/\x1b\[[0-9;]*m/g, "");
375
- }
376
- /** Strip ANSI codes and replace volatile tokens (ports, durations) with placeholders. */
377
- function normalizeOutput(str) {
378
- return stripAnsi(str).replace(/localhost:\d+/g, "localhost:PORT").replace(/\d+ms/g, "Xms").replace(/\d+\.\d+s/g, "X.Xs").trim();
379
- }
380
- //#endregion
381
- //#region src/builder/common/directory-accessor.ts
382
- /**
383
- * Detect whether the user wants to update snapshots — `true` for any of:
384
- * - vitest run with `-u` / `--update`
385
- * - JTERRAZZ_TEST_UPDATE=1
386
- * - UPDATE_SNAPSHOTS=1
387
- */
388
- function shouldUpdateSnapshots() {
389
- if (process.env.JTERRAZZ_TEST_UPDATE === "1") return true;
390
- if (process.env.UPDATE_SNAPSHOTS === "1") return true;
391
- if (process.argv.includes("-u") || process.argv.includes("--update")) return true;
392
- return false;
393
- }
394
- /**
395
- * Handle to a directory produced by a specification run.
396
- * Supports snapshot-based assertions via {@link toMatchFixture} and file listing via {@link files}.
397
- */
398
- var DirectoryAccessor = class {
399
- absPath;
400
- testDir;
401
- constructor(absPath, testDir) {
402
- this.absPath = absPath;
403
- this.testDir = testDir;
404
- }
405
- /**
406
- * Compare the directory tree against `expected/{name}/` (relative to the test file).
407
- * On mismatch, throws with a structured diff. With update mode enabled, the
408
- * fixture is overwritten with the current contents instead.
409
- */
410
- async toMatchFixture(name, options = {}) {
411
- const fixtureDir = (0, node_path.resolve)(this.testDir, "expected", name);
412
- if (options.update ?? shouldUpdateSnapshots()) {
413
- (0, node_fs.rmSync)(fixtureDir, {
414
- force: true,
415
- recursive: true
416
- });
417
- (0, node_fs.mkdirSync)(fixtureDir, { recursive: true });
418
- (0, node_fs.cpSync)(this.absPath, fixtureDir, { recursive: true });
419
- return;
420
- }
421
- if (!(0, node_fs.existsSync)(fixtureDir)) throw new Error(`Directory fixture "${name}" does not exist at ${fixtureDir}.\nRun with JTERRAZZ_TEST_UPDATE=1 (or vitest -u) to create it.`);
422
- const diff = await diffDirectories(fixtureDir, this.absPath, { ignore: options.ignore });
423
- if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) return;
424
- throw new Error(formatDirectoryDiff(name, diff, "Run with JTERRAZZ_TEST_UPDATE=1 to update the fixture."));
425
- }
426
- /**
427
- * List all files in the directory (recursive, sorted, ignoring defaults).
428
- * Useful for ad-hoc assertions when you don't want a full snapshot.
429
- */
430
- async files(options = {}) {
431
- return walkDirectory(this.absPath, options);
432
- }
433
- };
434
- //#endregion
435
- //#region src/builder/common/grep.ts
436
- /**
437
- * Extract text blocks from output that contain a pattern.
438
- * Splits by blank lines (how linter/compiler output is structured),
439
- * returns only blocks matching the pattern.
440
- *
441
- * @example
442
- * expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
443
- * expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
444
- */
445
- function grep(output, pattern) {
446
- return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
447
- }
448
- //#endregion
449
- //#region src/builder/common/response-accessor.ts
450
- /** Accessor for an HTTP response body with file-based assertion support. */
451
- var ResponseAccessor = class {
452
- body;
453
- testDir;
454
- constructor(body, testDir) {
455
- this.body = body;
456
- this.testDir = testDir;
457
- }
458
- /**
459
- * Assert that the response body matches the JSON in `responses/{file}`.
460
- *
461
- * @example
462
- * result.response.toMatchFile("expected-items.json");
463
- */
464
- toMatchFile(file) {
465
- const expected = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "responses", file), "utf8"));
466
- if (JSON.stringify(this.body) !== JSON.stringify(expected)) throw new Error(formatResponseDiff(file, expected, this.body));
467
- }
468
- };
469
- //#endregion
470
- //#region src/builder/common/table-assertion.ts
471
- /** Assertion helper for verifying database table contents after a specification run. */
472
- var TableAssertion = class {
473
- tableName;
474
- db;
475
- constructor(tableName, db) {
476
- this.tableName = tableName;
477
- this.db = db;
478
- }
479
- /**
480
- * Assert that the table contains exactly the expected rows for the given columns.
481
- *
482
- * @example
483
- * await result.table("users").toMatch({
484
- * columns: ["name", "email"],
485
- * rows: [["Alice", "alice@example.com"]],
486
- * });
487
- */
488
- async toMatch(expected) {
489
- const actual = await this.db.query(this.tableName, expected.columns);
490
- if (JSON.stringify(actual) !== JSON.stringify(expected.rows)) throw new Error(formatTableDiff(this.tableName, expected.columns, expected.rows, actual));
491
- }
492
- /** Assert that the table has zero rows. */
493
- async toBeEmpty() {
494
- const actual = await this.db.query(this.tableName, ["*"]);
495
- if (actual.length !== 0) throw new Error(`Expected table "${this.tableName}" to be empty, but it has ${actual.length} rows`);
496
- }
497
- };
498
- //#endregion
499
- //#region src/builder/common/result.ts
500
- /**
501
- * The outcome of a single specification run.
502
- * Provides accessors for CLI output, HTTP responses, files, directories, and database tables.
503
- */
504
- var SpecificationResult = class {
505
- commandResult;
506
- config;
507
- requestInfo;
508
- responseData;
509
- testDir;
510
- workDir;
511
- constructor(options) {
512
- this.responseData = options.response;
513
- this.commandResult = options.commandResult;
514
- this.config = options.config;
515
- this.testDir = options.testDir;
516
- this.requestInfo = options.requestInfo;
517
- this.workDir = options.workDir;
518
- }
519
- /** The process exit code. Only available after a CLI action. */
520
- get exitCode() {
521
- if (!this.commandResult) throw new Error(".exitCode requires a CLI action (.exec())");
522
- return this.commandResult.exitCode;
523
- }
524
- /** The HTTP response status code. Only available after an HTTP action. */
525
- get status() {
526
- if (!this.responseData) throw new Error(".status requires an HTTP action (.get(), .post(), etc.)");
527
- return this.responseData.status;
528
- }
529
- /** The captured standard output. Only available after a CLI action. */
530
- get stdout() {
531
- if (!this.commandResult) throw new Error(".stdout requires a CLI action (.exec())");
532
- return this.commandResult.stdout;
533
- }
534
- /** The captured standard error. Only available after a CLI action. */
535
- get stderr() {
536
- if (!this.commandResult) throw new Error(".stderr requires a CLI action (.exec())");
537
- return this.commandResult.stderr;
538
- }
539
- /**
540
- * Extract text blocks from stdout that contain a pattern.
541
- * Useful for parsing structured CLI output (linters, compilers).
542
- *
543
- * @example
544
- * expect(result.grep('error.ts')).toContain('no-unused-vars');
545
- */
546
- grep(pattern) {
547
- return grep(this.stdout, pattern);
548
- }
549
- /** Access the HTTP response body for assertions. Only available after an HTTP action. */
550
- get response() {
551
- if (!this.responseData) throw new Error(".response requires an HTTP action (.get(), .post(), etc.)");
552
- return new ResponseAccessor(this.responseData.body, this.testDir);
553
- }
554
- /** Access a directory (relative to the working directory) for snapshot assertions. */
555
- directory(path = ".") {
556
- return new DirectoryAccessor((0, node_path.resolve)(this.workDir ?? this.testDir, path), this.testDir);
557
- }
558
- /** Access a single file (relative to the working directory) for content assertions. */
559
- file(path) {
560
- const resolvedPath = (0, node_path.resolve)(this.workDir ?? this.testDir, path);
561
- const exists = (0, node_fs.existsSync)(resolvedPath);
562
- return {
563
- get content() {
564
- if (!exists) throw new Error(`File not found: ${path}`);
565
- return (0, node_fs.readFileSync)(resolvedPath, "utf8");
566
- },
567
- exists
568
- };
569
- }
570
- /** Access a database table for row-level assertions. */
571
- table(tableName, options) {
572
- const db = this.resolveDatabase(options?.service);
573
- if (!db) throw new Error(options?.service ? `table("${tableName}") requires database "${options.service}" but it was not found` : `table("${tableName}") requires a database adapter`);
574
- return new TableAssertion(tableName, db);
575
- }
576
- resolveDatabase(serviceName) {
577
- if (serviceName && this.config.databases) return this.config.databases.get(serviceName);
578
- return this.config.database;
579
- }
580
- };
581
- //#endregion
582
- //#region src/builder/specification-builder.ts
583
- /**
584
- * Fluent builder for declaring a single test specification.
585
- *
586
- * Chain setup methods ({@link seed}, {@link fixture}, {@link env}), an action
587
- * ({@link get}, {@link post}, {@link exec}), then call {@link run} to execute
588
- * and receive a {@link SpecificationResult} for assertions.
589
- */
590
- var SpecificationBuilder = class {
591
- commandArgs = null;
592
- commandEnv = {};
593
- config;
594
- fixtures = [];
595
- intercepts = [];
596
- jobName = null;
597
- label;
598
- mocks = [];
599
- projectName = null;
600
- request = null;
601
- requestHeaders = {};
602
- seeds = [];
603
- spawnConfig = null;
604
- testDir;
605
- constructor(config, testDir, label) {
606
- this.config = config;
607
- this.testDir = testDir;
608
- this.label = label;
609
- }
610
- /**
611
- * Queue a SQL seed file to run before the action.
612
- *
613
- * @example
614
- * spec("creates user").seed("users.sql").exec("list-users").run();
615
- */
616
- seed(file, options) {
617
- this.seeds.push({
618
- file,
619
- service: options?.service
620
- });
621
- return this;
622
- }
623
- /** Copy a file or directory from `fixtures/` into the working directory before execution. */
624
- fixture(file) {
625
- this.fixtures.push({ file });
626
- return this;
627
- }
628
- /** Copy an entire project fixture from `fixturesRoot` into the working directory. */
629
- project(name) {
630
- this.projectName = name;
631
- return this;
632
- }
633
- /** Register an MSW mock definition file from `mock/` to intercept HTTP calls. */
634
- mock(file) {
635
- this.mocks.push({ file });
636
- return this;
637
- }
638
- /**
639
- * Set environment variables for the CLI process. Merged on top of process.env.
640
- * Use `null` to unset a variable. Multiple calls merge.
641
- *
642
- * The token `$WORKDIR` (in any value) is replaced with the actual working
643
- * directory at run-time — useful for tests that need a fully isolated `HOME`.
644
- *
645
- * @example
646
- * spec("...").env({ HOME: "$WORKDIR", TZ: "UTC" }).exec("status").run();
647
- */
648
- env(env) {
649
- this.commandEnv = {
650
- ...this.commandEnv,
651
- ...env
652
- };
653
- return this;
654
- }
655
- /**
656
- * Set HTTP headers for the request. Multiple calls merge.
657
- *
658
- * @example
659
- * spec("french").headers({ 'Accept-Language': 'fr' }).get("/articles").run();
660
- */
661
- headers(headers) {
662
- this.requestHeaders = {
663
- ...this.requestHeaders,
664
- ...headers
665
- };
666
- return this;
667
- }
668
- /**
669
- * Intercept an outgoing HTTP request and return a controlled response.
670
- * Uses MSW under the hood. Intercepts are queued — multiple calls with the
671
- * same trigger fire sequentially (first match consumed first).
672
- *
673
- * @param trigger - What to match (use openai.agent(), http.get(), etc.)
674
- * @param response - What to return: an InterceptResponse object, or a filename
675
- * from `intercepts/` (JSON loaded and auto-wrapped by the trigger).
676
- *
677
- * @example
678
- * // With inline response
679
- * .intercept(openai.agent({...}), openai.reply({ categories: ['TECH'] }))
680
- *
681
- * // With JSON fixture file (loaded from intercepts/ directory)
682
- * .intercept(openai.agent({...}), 'ingest-tech.json')
683
- * .intercept(http.get(url), 'world-news-tech.json')
684
- */
685
- intercept(trigger, response) {
686
- if (typeof response === "string") {
687
- const slashIndex = response.indexOf("/");
688
- if (slashIndex === -1) throw new Error(`.intercept(): file path must be 'adapter/filename.json' (e.g. 'openai/ingest-tech.json'), got '${response}'`);
689
- const adapterName = response.slice(0, slashIndex);
690
- if (adapterName !== trigger.adapter) throw new Error(`.intercept(): adapter mismatch - trigger uses '${trigger.adapter}' but file path starts with '${adapterName}/'`);
691
- const filePath = (0, node_path.resolve)(this.testDir, "intercepts", response);
692
- const data = JSON.parse((0, node_fs.readFileSync)(filePath, "utf8"));
693
- const resolved = trigger.wrap(data);
694
- this.intercepts.push({
695
- trigger,
696
- response: resolved
697
- });
698
- } else this.intercepts.push({
699
- trigger,
700
- response
701
- });
702
- return this;
703
- }
704
- /**
705
- * Send a GET request to the server adapter.
706
- *
707
- * @example
708
- * spec("list items").get("/api/items").run();
709
- */
710
- get(path) {
711
- this.request = {
712
- method: "GET",
713
- path
714
- };
715
- return this;
716
- }
717
- /**
718
- * Send a POST request to the server adapter.
719
- *
720
- * @param bodyFile - Optional JSON file from `requests/` to use as the request body.
721
- * @example
722
- * spec("create item").post("/api/items", "new-item.json").run();
723
- */
724
- post(path, bodyFile) {
725
- this.request = {
726
- bodyFile,
727
- method: "POST",
728
- path
729
- };
730
- return this;
731
- }
732
- /** Send a PUT request to the server adapter. */
733
- put(path, bodyFile) {
734
- this.request = {
735
- bodyFile,
736
- method: "PUT",
737
- path
738
- };
739
- return this;
740
- }
741
- /** Send a DELETE request to the server adapter. */
742
- delete(path) {
743
- this.request = {
744
- method: "DELETE",
745
- path
746
- };
747
- return this;
748
- }
749
- /**
750
- * Execute a CLI command (or a sequence of commands) in an isolated working directory.
751
- * When an array is passed, commands run sequentially and stop on the first non-zero exit code.
752
- *
753
- * @example
754
- * spec("init project").exec("init --name demo").run();
755
- * spec("multi-step").exec(["init", "build"]).run();
756
- */
757
- exec(args) {
758
- this.commandArgs = args;
759
- return this;
760
- }
761
- /** Spawn a long-running CLI process with custom spawn options (e.g. timeout, signal). */
762
- spawn(args, options) {
763
- this.spawnConfig = {
764
- args,
765
- options
766
- };
767
- return this;
768
- }
769
- /**
770
- * Execute a named job registered via the app() factory.
771
- *
772
- * @param name - The job name to trigger (must match a registered JobHandle.name).
773
- *
774
- * @example
775
- * spec('pipeline').intercept(openai.chat(), openai.response({...})).job('report-refresh').run();
776
- */
777
- job(name) {
778
- this.jobName = name;
779
- return this;
780
- }
781
- /**
782
- * Execute the specification: run seeds, copy fixtures, then perform the
783
- * configured action (HTTP or CLI).
784
- *
785
- * @returns The result object used for assertions.
786
- * @example
787
- * const result = await spec("test").exec("status").run();
788
- * expect(result.exitCode).toBe(0);
789
- */
790
- async run() {
791
- const hasHttpAction = this.request !== null;
792
- const hasCliAction = this.commandArgs !== null || this.spawnConfig !== null;
793
- const hasJobAction = this.jobName !== null;
794
- const actionCount = [
795
- hasHttpAction,
796
- hasCliAction,
797
- hasJobAction
798
- ].filter(Boolean).length;
799
- if (actionCount === 0) throw new Error(`Specification "${this.label}": no action defined. Call .get(), .post(), .exec(), .job(), etc. before .run()`);
800
- if (actionCount > 1) throw new Error(`Specification "${this.label}": cannot mix action types (.get/.post, .exec/.spawn, .job)`);
801
- let workDir = null;
802
- if (hasCliAction) workDir = this.prepareWorkDir();
803
- if (this.config.databases) for (const db of this.config.databases.values()) await db.reset();
804
- else if (this.config.database) await this.config.database.reset();
805
- for (const entry of this.seeds) {
806
- let db;
807
- if (entry.service && this.config.databases) {
808
- db = this.config.databases.get(entry.service);
809
- if (!db) throw new Error(`seed() targets database "${entry.service}" but it was not found. Available: ${[...this.config.databases.keys()].join(", ")}`);
810
- } else db = this.config.database;
811
- if (!db) throw new Error("seed() requires a database adapter");
812
- const sql = (0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "seeds", entry.file), "utf8");
813
- await db.seed(sql);
814
- }
815
- if (this.fixtures.length > 0 && workDir) for (const entry of this.fixtures) (0, node_fs.cpSync)((0, node_path.resolve)(this.testDir, "fixtures", entry.file), (0, node_path.resolve)(workDir, entry.file), { recursive: true });
816
- let cleanupIntercepts = null;
817
- if (this.intercepts.length > 0) {
818
- const { registerIntercepts } = await Promise.resolve().then(() => require("./intercept2.cjs"));
819
- cleanupIntercepts = await registerIntercepts(this.intercepts);
820
- }
821
- try {
822
- if (hasHttpAction) return await this.runHttpAction();
823
- if (hasJobAction) return await this.runJobAction();
824
- return await this.runCliAction(workDir);
825
- } finally {
826
- if (cleanupIntercepts) cleanupIntercepts();
827
- }
828
- }
829
- resolveEnv(workDir) {
830
- const keys = Object.keys(this.commandEnv);
831
- if (keys.length === 0) return;
832
- const resolved = {};
833
- for (const key of keys) {
834
- const value = this.commandEnv[key];
835
- resolved[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workDir) : value;
836
- }
837
- return resolved;
838
- }
839
- prepareWorkDir() {
840
- const tempDir = (0, node_fs.mkdtempSync)((0, node_path.resolve)((0, node_os.tmpdir)(), "spec-cli-"));
841
- if (this.projectName && this.config.fixturesRoot) {
842
- const projectDir = (0, node_path.resolve)(this.config.fixturesRoot, this.projectName);
843
- if (!(0, node_fs.existsSync)(projectDir)) throw new Error(`project("${this.projectName}"): fixture project not found at ${projectDir}`);
844
- (0, node_fs.cpSync)(projectDir, tempDir, { recursive: true });
845
- }
846
- return tempDir;
847
- }
848
- async runHttpAction() {
849
- if (!this.config.server) throw new Error("HTTP actions require a server adapter (use integration() or e2e())");
850
- let body;
851
- if (this.request.bodyFile) body = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "requests", this.request.bodyFile), "utf8"));
852
- const headers = Object.keys(this.requestHeaders).length > 0 ? this.requestHeaders : void 0;
853
- const response = await this.config.server.request(this.request.method, this.request.path, body, headers);
854
- return new SpecificationResult({
855
- config: this.config,
856
- requestInfo: {
857
- body,
858
- method: this.request.method,
859
- path: this.request.path
860
- },
861
- response,
862
- testDir: this.testDir
863
- });
864
- }
865
- async runJobAction() {
866
- if (!this.config.jobs?.length) throw new Error("Job actions require jobs registered via app(() => ({ server, jobs }))");
867
- const job = this.config.jobs.find((j) => j.name === this.jobName);
868
- if (!job) {
869
- const available = this.config.jobs.map((j) => j.name).join(", ");
870
- throw new Error(`job("${this.jobName}"): not found. Available: ${available}`);
871
- }
872
- await job.execute();
873
- return new SpecificationResult({
874
- config: this.config,
875
- testDir: this.testDir
876
- });
877
- }
878
- async runCliAction(workDir) {
879
- if (!this.config.command) throw new Error("CLI actions require a command adapter (use cli())");
880
- const env = this.resolveEnv(workDir);
881
- let commandResult;
882
- if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options, env);
883
- else if (Array.isArray(this.commandArgs)) {
884
- commandResult = {
885
- exitCode: 0,
886
- stderr: "",
887
- stdout: ""
888
- };
889
- for (const args of this.commandArgs) {
890
- commandResult = await this.config.command.exec(args, workDir, env);
891
- if (commandResult.exitCode !== 0) break;
892
- }
893
- } else commandResult = await this.config.command.exec(this.commandArgs, workDir, env);
894
- return new SpecificationResult({
895
- commandResult,
896
- config: this.config,
897
- testDir: this.testDir,
898
- workDir
899
- });
900
- }
901
- };
902
- function getCallerDir() {
903
- const stack = (/* @__PURE__ */ new Error("caller detection")).stack;
904
- if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
905
- const lines = stack.split("\n");
906
- for (const line of lines) {
907
- const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?([^:)]+):\d+:\d+/);
908
- if (!match) continue;
909
- const filePath = match[1];
910
- if (filePath.includes("node_modules")) continue;
911
- if (filePath.includes("/src/builder/") || filePath.includes("/src/spec/")) continue;
912
- return (0, node_path.resolve)(filePath, "..");
913
- }
914
- throw new Error("Cannot detect caller directory from stack trace");
915
- }
916
- /**
917
- * Create a {@link SpecificationRunner} bound to the given adapter configuration.
918
- * The test file directory is auto-detected from the call stack.
919
- */
920
- function createSpecificationRunner(config) {
921
- return (label) => {
922
- return new SpecificationBuilder(config, getCallerDir(), label);
923
- };
924
- }
925
- //#endregion
926
- //#region src/docker/docker-adapter.ts
927
- /**
928
- * Implements {@link DockerContainerPort} by shelling out to the Docker CLI.
929
- * Each instance is bound to a single container ID.
930
- */
931
- var DockerAdapter = class {
932
- containerId;
933
- constructor(containerId) {
934
- this.containerId = containerId;
935
- }
936
- async exec(cmd) {
937
- return (0, node_child_process.execSync)(`docker exec ${this.containerId} ${cmd.map((c) => `'${c}'`).join(" ")}`, {
938
- encoding: "utf8",
939
- timeout: 1e4
940
- }).trim();
941
- }
942
- async file(path) {
943
- try {
944
- return {
945
- exists: true,
946
- content: await this.exec(["cat", path])
947
- };
948
- } catch {
949
- return {
950
- exists: false,
951
- content: ""
952
- };
953
- }
954
- }
955
- async isRunning() {
956
- try {
957
- return (0, node_child_process.execSync)(`docker inspect --format='{{.State.Running}}' ${this.containerId}`, {
958
- encoding: "utf8",
959
- timeout: 5e3
960
- }).trim() === "true";
961
- } catch {
962
- return false;
963
- }
964
- }
965
- async logs(tail) {
966
- return (0, node_child_process.execSync)(`docker logs ${tail ? `--tail ${tail}` : ""} ${this.containerId}`, {
967
- encoding: "utf8",
968
- timeout: 1e4
969
- });
970
- }
971
- async inspect() {
972
- const raw = (0, node_child_process.execSync)(`docker inspect ${this.containerId}`, {
973
- encoding: "utf8",
974
- timeout: 5e3
975
- });
976
- const data = JSON.parse(raw)[0];
977
- return {
978
- id: data.Id,
979
- name: data.Name,
980
- state: {
981
- running: data.State.Running,
982
- exitCode: data.State.ExitCode,
983
- status: data.State.Status
984
- },
985
- config: {
986
- image: data.Config.Image,
987
- env: data.Config.Env || []
988
- },
989
- hostConfig: {
990
- memory: data.HostConfig.Memory || 0,
991
- cpuQuota: data.HostConfig.CpuQuota || 0,
992
- networkMode: data.HostConfig.NetworkMode || "",
993
- mounts: (data.Mounts || []).map((m) => ({
994
- source: m.Source,
995
- destination: m.Destination,
996
- type: m.Type
997
- }))
998
- },
999
- networkSettings: { networks: Object.fromEntries(Object.entries(data.NetworkSettings.Networks || {}).map(([name, net]) => [name, {
1000
- gateway: net.Gateway,
1001
- ipAddress: net.IPAddress
1002
- }])) }
1003
- };
1004
- }
1005
- async exists(path) {
1006
- try {
1007
- await this.exec([
1008
- "test",
1009
- "-e",
1010
- path
1011
- ]);
1012
- return true;
1013
- } catch {
1014
- return false;
1015
- }
1016
- }
1017
- };
1018
- /** Create a {@link DockerContainerPort} bound to an existing container by ID. */
1019
- function dockerContainer(containerId) {
1020
- return new DockerAdapter(containerId);
1021
- }
1022
- //#endregion
1023
- //#region src/docker/docker-assertion.ts
1024
- /**
1025
- * Fluent assertion builder for Docker containers.
1026
- * Chain async assertions like `toBeRunning()`, `toHaveFile()`, `toHaveMount()`.
1027
- */
1028
- var DockerAssertion = class {
1029
- container;
1030
- constructor(container) {
1031
- this.container = container;
1032
- }
1033
- /** Assert the container is running */
1034
- async toBeRunning() {
1035
- if (!await this.container.isRunning()) throw new Error("Expected container to be running");
1036
- return this;
1037
- }
1038
- /** Assert the container is NOT running / doesn't exist */
1039
- async toNotExist() {
1040
- if (await this.container.isRunning()) throw new Error("Expected container to not exist or not be running");
1041
- return this;
1042
- }
1043
- /** Assert a file exists inside the container */
1044
- async toHaveFile(path, opts) {
1045
- const file = await this.container.file(path);
1046
- if (!file.exists) throw new Error(`Expected file ${path} to exist in container`);
1047
- if (opts?.containing && !file.content.includes(opts.containing)) throw new Error(`Expected file ${path} to contain "${opts.containing}", got: ${file.content.slice(0, 200)}`);
1048
- return this;
1049
- }
1050
- /** Assert a file does NOT exist */
1051
- async toNotHaveFile(path) {
1052
- if (await this.container.exists(path)) throw new Error(`Expected ${path} to not exist in container`);
1053
- return this;
1054
- }
1055
- /** Assert a directory exists */
1056
- async toHaveDirectory(path) {
1057
- if (!await this.container.exists(path)) throw new Error(`Expected directory ${path} to exist in container`);
1058
- return this;
1059
- }
1060
- /** Assert a mount exists */
1061
- async toHaveMount(destination) {
1062
- const info = await this.container.inspect();
1063
- if (!info.hostConfig.mounts.find((m) => m.destination === destination)) {
1064
- const available = info.hostConfig.mounts.map((m) => m.destination).join(", ");
1065
- throw new Error(`Expected mount at ${destination}, found: [${available}]`);
1066
- }
1067
- return this;
1068
- }
1069
- /** Assert network mode */
1070
- async toHaveNetwork(mode) {
1071
- const info = await this.container.inspect();
1072
- if (!info.hostConfig.networkMode.includes(mode)) throw new Error(`Expected network mode "${mode}", got "${info.hostConfig.networkMode}"`);
1073
- return this;
1074
- }
1075
- /** Assert memory limit */
1076
- async toHaveMemoryLimit(bytes) {
1077
- const info = await this.container.inspect();
1078
- if (info.hostConfig.memory !== bytes) throw new Error(`Expected memory limit ${bytes}, got ${info.hostConfig.memory}`);
1079
- return this;
1080
- }
1081
- /** Assert CPU quota */
1082
- async toHaveCpuQuota(quota) {
1083
- const info = await this.container.inspect();
1084
- if (info.hostConfig.cpuQuota !== quota) throw new Error(`Expected CPU quota ${quota}, got ${info.hostConfig.cpuQuota}`);
1085
- return this;
1086
- }
1087
- /** Execute a command and return output for custom assertions */
1088
- async exec(cmd) {
1089
- return this.container.exec(cmd);
1090
- }
1091
- /** Read a file for custom assertions */
1092
- async readFile(path) {
1093
- const file = await this.container.file(path);
1094
- if (!file.exists) throw new Error(`File ${path} does not exist`);
1095
- return file.content;
1096
- }
1097
- /** Get logs for custom assertions */
1098
- async getLogs(tail) {
1099
- return this.container.logs(tail);
1100
- }
1101
- };
1102
- //#endregion
1103
- //#region src/infra/adapters/compose.adapter.ts
1104
- /**
1105
- * Start the full compose stack and stop it all on cleanup.
1106
- * Supports per-worker project names for parallel execution.
1107
- */
1108
- var ComposeStackAdapter = class {
1109
- composeFile;
1110
- projectName;
1111
- started = false;
1112
- constructor(composeFile, projectName) {
1113
- this.composeFile = composeFile;
1114
- this.projectName = projectName ?? null;
1115
- }
1116
- get projectFlag() {
1117
- return this.projectName ? `-p ${this.projectName}` : "";
1118
- }
1119
- run(command) {
1120
- try {
1121
- return (0, node_child_process.execSync)(command, {
1122
- cwd: (0, node_path.dirname)(this.composeFile),
1123
- encoding: "utf8",
1124
- timeout: 12e4
1125
- }).trim();
1126
- } catch (error) {
1127
- const stderr = error.stderr?.toString().trim() ?? error.message;
1128
- throw new Error(`docker compose failed: ${stderr}`, { cause: error });
1129
- }
1130
- }
1131
- async start() {
1132
- if (this.started) return;
1133
- this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} up -d --wait`);
1134
- this.started = true;
1135
- }
1136
- async stop() {
1137
- if (!this.started) return;
1138
- this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} down -v`);
1139
- this.started = false;
1140
- }
1141
- getMappedPort(serviceName, containerPort) {
1142
- const port = this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} port ${serviceName} ${containerPort}`).split(":").pop();
1143
- return Number(port);
1144
- }
1145
- getHost() {
1146
- return "localhost";
1147
- }
1148
- };
1149
- //#endregion
1150
- //#region src/infra/adapters/testcontainers.adapter.ts
1151
- /**
1152
- * Container adapter using testcontainers.
1153
- * Wraps a GenericContainer for programmatic container lifecycle.
1154
- */
1155
- var TestcontainersAdapter = class {
1156
- image;
1157
- containerPort;
1158
- env;
1159
- reuse;
1160
- container = null;
1161
- constructor(options) {
1162
- this.image = options.image;
1163
- this.containerPort = options.port;
1164
- this.env = options.env ?? {};
1165
- this.reuse = options.reuse ?? false;
1166
- }
1167
- async start() {
1168
- const { GenericContainer, Wait } = await import("testcontainers");
1169
- let builder = new GenericContainer(this.image).withExposedPorts(this.containerPort);
1170
- for (const [key, value] of Object.entries(this.env)) builder = builder.withEnvironment({ [key]: value });
1171
- if (this.image.startsWith("postgres")) builder = builder.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2));
1172
- if (this.reuse) builder = builder.withReuse();
1173
- this.container = await builder.start();
1174
- }
1175
- async stop() {
1176
- if (this.container && !this.reuse) {
1177
- await this.container.stop();
1178
- this.container = null;
1179
- }
1180
- }
1181
- getMappedPort(containerPort) {
1182
- if (!this.container) throw new Error("Container not started");
1183
- return this.container.getMappedPort(containerPort);
1184
- }
1185
- getHost() {
1186
- if (!this.container) throw new Error("Container not started");
1187
- return this.container.getHost();
1188
- }
1189
- getConnectionString() {
1190
- return `${this.getHost()}:${this.getMappedPort(this.containerPort)}`;
1191
- }
1192
- async getLogs() {
1193
- if (!this.container) return "";
1194
- const stream = await this.container.logs();
1195
- return new Promise((resolve) => {
1196
- let output = "";
1197
- stream.on("data", (chunk) => {
1198
- output += chunk.toString();
1199
- });
1200
- stream.on("end", () => {
1201
- resolve(output);
1202
- });
1203
- setTimeout(() => {
1204
- resolve(output);
1205
- }, 1e3);
1206
- });
1207
- }
1208
- };
1209
- //#endregion
1210
- //#region src/infra/compose-parser.ts
1211
- /**
1212
- * Detect the service type from the image name.
1213
- */
1214
- function detectServiceType(image) {
1215
- if (!image) return "app";
1216
- const lower = image.toLowerCase();
1217
- if (lower.startsWith("postgres")) return "postgres";
1218
- if (lower.startsWith("redis")) return "redis";
1219
- return "unknown";
1220
- }
1221
- /**
1222
- * Find the compose file in the project.
1223
- * Looks for docker/compose.test.yaml or docker-compose.test.yaml.
1224
- */
1225
- function findComposeFile(projectRoot) {
1226
- const candidates = [
1227
- (0, node_path.resolve)(projectRoot, "docker/compose.test.yaml"),
1228
- (0, node_path.resolve)(projectRoot, "docker/compose.test.yml"),
1229
- (0, node_path.resolve)(projectRoot, "docker-compose.test.yaml"),
1230
- (0, node_path.resolve)(projectRoot, "docker-compose.test.yml")
1231
- ];
1232
- for (const candidate of candidates) if ((0, node_fs.existsSync)(candidate)) return candidate;
1233
- return null;
1234
- }
1235
- /**
1236
- * Parse a docker-compose file and extract service definitions.
1237
- */
1238
- function parseComposeFile(filePath) {
1239
- const doc = (0, yaml.parse)((0, node_fs.readFileSync)(filePath, "utf8"));
1240
- if (!doc?.services) return {
1241
- services: [],
1242
- appService: null,
1243
- infraServices: []
1244
- };
1245
- const services = Object.entries(doc.services).map(([name, def]) => {
1246
- const ports = [];
1247
- if (def.ports) for (const port of def.ports) {
1248
- const str = String(port);
1249
- if (str.includes(":")) {
1250
- const [host, container] = str.split(":");
1251
- ports.push({
1252
- container: Number(container),
1253
- host: Number(host)
1254
- });
1255
- } else ports.push({ container: Number(str) });
1256
- }
1257
- const environment = {};
1258
- if (def.environment) if (Array.isArray(def.environment)) for (const env of def.environment) {
1259
- const [key, ...rest] = String(env).split("=");
1260
- environment[key] = rest.join("=");
1261
- }
1262
- else Object.assign(environment, def.environment);
1263
- const volumes = def.volumes ? def.volumes.map((v) => String(v)) : [];
1264
- let dependsOn = [];
1265
- if (def.depends_on) dependsOn = Array.isArray(def.depends_on) ? def.depends_on : Object.keys(def.depends_on);
1266
- return {
1267
- name,
1268
- image: def.image,
1269
- build: def.build,
1270
- ports,
1271
- environment,
1272
- volumes,
1273
- dependsOn
1274
- };
1275
- });
1276
- return {
1277
- services,
1278
- appService: services.find((s) => s.build !== void 0) ?? null,
1279
- infraServices: services.filter((s) => s.build === void 0)
1280
- };
1281
- }
1282
- //#endregion
1283
- //#region src/infra/orchestrator.ts
1284
- /**
1285
- * Orchestrator for test infrastructure.
1286
- * Integration: starts services via testcontainers.
1287
- * E2E: runs full docker compose up.
1288
- */
1289
- var Orchestrator = class {
1290
- services;
1291
- mode;
1292
- root;
1293
- projectName;
1294
- running = [];
1295
- composeStack = null;
1296
- composeHandles = [];
1297
- started = false;
1298
- constructor(options) {
1299
- this.services = options.services;
1300
- this.mode = options.mode;
1301
- this.root = options.root ?? process.cwd();
1302
- this.projectName = options.projectName;
1303
- }
1304
- /**
1305
- * Start declared services via testcontainers (integration mode).
1306
- * Phase 1: start all containers in parallel (the slow part).
1307
- * Phase 2: wire connections, healthcheck, and init sequentially (fast).
1308
- */
1309
- async start() {
1310
- if (this.started) return;
1311
- const composePath = findComposeFile(this.root);
1312
- const composeDir = composePath ? (0, node_path.dirname)(composePath) : this.root;
1313
- const composeConfig = composePath ? parseComposeFile(composePath) : null;
1314
- const containerServices = [];
1315
- const embeddedServices = [];
1316
- for (const handle of this.services) {
1317
- if (handle.defaultPort === 0) {
1318
- embeddedServices.push(handle);
1319
- continue;
1320
- }
1321
- let image = handle.defaultImage;
1322
- let env = { ...handle.environment };
1323
- if (handle.composeName && composeConfig) {
1324
- const composeService = composeConfig.services.find((s) => s.name === handle.composeName);
1325
- if (composeService) {
1326
- image = composeService.image ?? image;
1327
- env = {
1328
- ...env,
1329
- ...composeService.environment
1330
- };
1331
- Object.assign(handle.environment, composeService.environment);
1332
- }
1333
- }
1334
- const container = new TestcontainersAdapter({
1335
- image,
1336
- port: handle.defaultPort,
1337
- env
1338
- });
1339
- containerServices.push({
1340
- container,
1341
- handle
1342
- });
1343
- }
1344
- await Promise.all([...containerServices.map(({ container }) => container.start()), ...embeddedServices.map(async (handle) => {
1345
- await handle.initialize(composeDir);
1346
- handle.started = true;
1347
- this.running.push({
1348
- handle,
1349
- container: null
1350
- });
1351
- })]);
1352
- const reports = [];
1353
- for (const { container, handle } of containerServices) {
1354
- const serviceStartTime = Date.now();
1355
- try {
1356
- const host = container.getHost();
1357
- const port = container.getMappedPort(handle.defaultPort);
1358
- handle.connectionString = handle.buildConnectionString(host, port);
1359
- await handle.healthcheck();
1360
- await handle.initialize(composeDir);
1361
- handle.started = true;
1362
- reports.push({
1363
- name: handle.composeName ?? handle.type,
1364
- type: handle.type,
1365
- connectionString: handle.connectionString,
1366
- durationMs: Date.now() - serviceStartTime
1367
- });
1368
- this.running.push({
1369
- handle,
1370
- container
1371
- });
1372
- } catch (error) {
1373
- let logs = "";
1374
- try {
1375
- logs = await container.getLogs();
1376
- } catch {}
1377
- try {
1378
- await container.stop();
1379
- } catch {}
1380
- reports.push({
1381
- name: handle.composeName ?? handle.type,
1382
- type: handle.type,
1383
- durationMs: Date.now() - serviceStartTime,
1384
- error: error.message,
1385
- logs
1386
- });
1387
- const output = formatStartupReport("integration", reports, { type: "in-process" });
1388
- console.error(output);
1389
- throw error;
1390
- }
1391
- }
1392
- this.started = true;
1393
- const output = formatStartupReport("integration", reports, { type: "in-process" });
1394
- console.log(output);
1395
- }
1396
- /**
1397
- * Stop testcontainers (integration mode).
1398
- */
1399
- async stop() {
1400
- for (const { container } of this.running) if (container) await container.stop();
1401
- this.running = [];
1402
- this.started = false;
1403
- }
1404
- /**
1405
- * Start full docker compose stack (e2e mode).
1406
- * Auto-detects infra services and creates handles for them.
1407
- */
1408
- async startCompose() {
1409
- const composePath = findComposeFile(this.root);
1410
- if (!composePath) throw new Error(`E2E: no compose file found in ${this.root}`);
1411
- const startTime = Date.now();
1412
- const composeDir = (0, node_path.dirname)(composePath);
1413
- const composeConfig = parseComposeFile(composePath);
1414
- this.composeStack = new ComposeStackAdapter(composePath, this.projectName);
1415
- await this.composeStack.start();
1416
- for (const service of composeConfig.infraServices) {
1417
- const type = detectServiceType(service.image);
1418
- if (type === "postgres") {
1419
- const handle = require_sqlite_adapter.postgres({
1420
- compose: service.name,
1421
- env: service.environment
1422
- });
1423
- const port = this.composeStack.getMappedPort(service.name, 5432);
1424
- handle.connectionString = handle.buildConnectionString("localhost", port);
1425
- await handle.initialize(composeDir);
1426
- handle.started = true;
1427
- this.composeHandles.push(handle);
1428
- } else if (type === "redis") {
1429
- const handle = require_sqlite_adapter.redis({ compose: service.name });
1430
- const port = this.composeStack.getMappedPort(service.name, 6379);
1431
- handle.connectionString = handle.buildConnectionString("localhost", port);
1432
- handle.started = true;
1433
- this.composeHandles.push(handle);
1434
- }
1435
- }
1436
- const durationMs = Date.now() - startTime;
1437
- const output = formatStartupReport("e2e", this.composeHandles.map((h) => ({
1438
- name: h.composeName ?? h.type,
1439
- type: h.type,
1440
- connectionString: h.connectionString,
1441
- durationMs
1442
- })), {
1443
- type: "http",
1444
- url: this.getAppUrl() ?? void 0
1445
- });
1446
- console.log(output);
1447
- }
1448
- /**
1449
- * Stop docker compose stack (e2e mode).
1450
- */
1451
- async stopCompose() {
1452
- if (this.composeStack) {
1453
- await this.composeStack.stop();
1454
- this.composeStack = null;
1455
- }
1456
- this.composeHandles = [];
1457
- }
1458
- /**
1459
- * Get a database service by compose name, or the first one if no name given.
1460
- */
1461
- getDatabase(serviceName) {
1462
- for (const handle of [...this.services, ...this.composeHandles]) {
1463
- if (serviceName && handle.composeName !== serviceName) continue;
1464
- const adapter = handle.createDatabaseAdapter();
1465
- if (adapter) return adapter;
1466
- }
1467
- return null;
1468
- }
1469
- /**
1470
- * Get all database services keyed by compose name.
1471
- */
1472
- getDatabases() {
1473
- const map = /* @__PURE__ */ new Map();
1474
- for (const handle of [...this.services, ...this.composeHandles]) {
1475
- const adapter = handle.createDatabaseAdapter();
1476
- if (adapter && handle.composeName) map.set(handle.composeName, adapter);
1477
- }
1478
- return map;
1479
- }
1480
- /**
1481
- * Get app URL from compose (e2e mode).
1482
- */
1483
- getAppUrl() {
1484
- const composePath = findComposeFile(this.root);
1485
- if (!composePath || !this.composeStack) return null;
1486
- const appService = parseComposeFile(composePath).appService;
1487
- if (!appService || appService.ports.length === 0) return null;
1488
- return `http://localhost:${this.composeStack.getMappedPort(appService.name, appService.ports[0].container)}`;
1489
- }
1490
- };
1491
- //#endregion
1492
- //#region src/spec/resolve.ts
1493
- /**
1494
- * Resolve root — if relative, resolves from the caller's directory.
1495
- */
1496
- function resolveProjectRoot(root) {
1497
- if (!root) return process.cwd();
1498
- if ((0, node_path.isAbsolute)(root)) return root;
1499
- const stack = (/* @__PURE__ */ new Error("resolve root")).stack;
1500
- if (stack) {
1501
- const lines = stack.split("\n");
1502
- for (const line of lines) {
1503
- const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?([^:)]+):\d+:\d+/);
1504
- if (!match) continue;
1505
- const filePath = match[1];
1506
- if (filePath.includes("node_modules") || filePath.includes("/src/spec/")) continue;
1507
- return (0, node_path.resolve)(filePath, "..", root);
1508
- }
1509
- }
1510
- return (0, node_path.resolve)(process.cwd(), root);
1511
- }
1512
- /**
1513
- * Resolve a CLI command — checks node_modules/.bin, then treats as absolute/PATH.
1514
- */
1515
- function resolveCommand(command, root) {
1516
- if ((0, node_path.isAbsolute)(command)) return command;
1517
- const binPath = (0, node_path.resolve)(root, "node_modules/.bin", command);
1518
- if ((0, node_fs.existsSync)(binPath)) return binPath;
1519
- const cwdBinPath = (0, node_path.resolve)(process.cwd(), "node_modules/.bin", command);
1520
- if ((0, node_fs.existsSync)(cwdBinPath)) return cwdBinPath;
1521
- return command;
1522
- }
1523
- //#endregion
1524
- //#region src/spec/spec.ts
1525
- /**
1526
- * Create a specification runner for the given target.
1527
- *
1528
- * @param target - What to test against: {@link app}, {@link stack}, or {@link command}.
1529
- * @param options - Shared options: root directory, infrastructure services.
1530
- *
1531
- * @example
1532
- * // HTTP — in-process app with testcontainers
1533
- * const s = await spec(app(() => createApp(db)), { services: [db] });
1534
- *
1535
- * // HTTP — full docker compose stack
1536
- * const s = await spec(stack('../../'));
1537
- *
1538
- * // CLI — command binary
1539
- * const s = await spec(command('my-cli'), { root: '../fixtures' });
1540
- */
1541
- async function spec(target, options = {}) {
1542
- switch (target.kind) {
1543
- case "app": return startApp(target, options);
1544
- case "stack": return startStack(target, options);
1545
- case "command": return startCommand(target, options);
1546
- }
1547
- }
1548
- function getWorkerId() {
1549
- return process.env.VITEST_POOL_ID ?? "0";
1550
- }
1551
- async function acquireIsolation(services) {
1552
- const workerId = getWorkerId();
1553
- for (const service of services) await service.isolation().acquire(workerId);
1554
- }
1555
- async function releaseIsolation(services) {
1556
- for (const service of services) await service.isolation().release();
1557
- }
1558
- async function startApp(target, options) {
1559
- const services = options.services ?? [];
1560
- const orchestrator = new Orchestrator({
1561
- mode: "integration",
1562
- root: resolveProjectRoot(options.root),
1563
- services
1564
- });
1565
- await orchestrator.start();
1566
- await acquireIsolation(services);
1567
- const servicesMap = {};
1568
- for (const svc of services) {
1569
- const key = svc.composeName ?? svc.type;
1570
- servicesMap[key] = svc;
1571
- }
1572
- const factoryResult = target.factory(servicesMap);
1573
- const isComposite = Array.isArray(factoryResult.jobs);
1574
- const honoApp = isComposite ? factoryResult.server : factoryResult;
1575
- const jobs = isComposite ? factoryResult.jobs : void 0;
1576
- const database = orchestrator.getDatabase() ?? void 0;
1577
- const databases = orchestrator.getDatabases();
1578
- const runner = createSpecificationRunner({
1579
- database,
1580
- databases: databases.size > 0 ? databases : void 0,
1581
- jobs,
1582
- server: new HonoAdapter(honoApp)
1583
- });
1584
- runner.cleanup = async () => {
1585
- await releaseIsolation(services);
1586
- await orchestrator.stop();
1587
- };
1588
- runner.docker = (id) => new DockerAssertion(dockerContainer(id));
1589
- runner.orchestrator = orchestrator;
1590
- return runner;
1591
- }
1592
- async function startStack(target, options) {
1593
- const root = resolveProjectRoot(target.root ?? options.root);
1594
- const projectName = `test-worker-${getWorkerId()}`;
1595
- const orchestrator = new Orchestrator({
1596
- mode: "e2e",
1597
- root,
1598
- services: options.services ?? [],
1599
- projectName
1600
- });
1601
- await orchestrator.startCompose();
1602
- const appUrl = orchestrator.getAppUrl();
1603
- if (!appUrl) throw new Error("stack(): could not detect app URL from compose. Ensure an app service with ports is defined.");
1604
- const database = orchestrator.getDatabase() ?? void 0;
1605
- const databases = orchestrator.getDatabases();
1606
- const runner = createSpecificationRunner({
1607
- database,
1608
- databases: databases.size > 0 ? databases : void 0,
1609
- server: new FetchAdapter(appUrl)
1610
- });
1611
- runner.cleanup = () => orchestrator.stopCompose();
1612
- runner.docker = (id) => new DockerAssertion(dockerContainer(id));
1613
- runner.orchestrator = orchestrator;
1614
- return runner;
1615
- }
1616
- async function startCommand(target, options) {
1617
- const root = resolveProjectRoot(options.root);
1618
- const bin = resolveCommand(target.bin, root);
1619
- const services = options.services ?? [];
1620
- let orchestrator = null;
1621
- let database;
1622
- let databases;
1623
- if (services.length) {
1624
- orchestrator = new Orchestrator({
1625
- mode: "integration",
1626
- root,
1627
- services
1628
- });
1629
- await orchestrator.start();
1630
- await acquireIsolation(services);
1631
- database = orchestrator.getDatabase() ?? void 0;
1632
- const dbMap = orchestrator.getDatabases();
1633
- databases = dbMap.size > 0 ? dbMap : void 0;
1634
- }
1635
- const runner = createSpecificationRunner({
1636
- command: new ExecAdapter(bin),
1637
- database,
1638
- databases,
1639
- fixturesRoot: root
1640
- });
1641
- runner.cleanup = async () => {
1642
- await releaseIsolation(services);
1643
- if (orchestrator) await orchestrator.stop();
1644
- };
1645
- runner.docker = (id) => new DockerAssertion(dockerContainer(id));
1646
- runner.orchestrator = orchestrator;
1647
- return runner;
1648
- }
1649
- //#endregion
1650
- //#region src/spec/targets.ts
1651
- /**
1652
- * Test against an in-process Hono app. The factory receives started services
1653
- * so you can wire connection strings into your app/DI container.
1654
- *
1655
- * @param factory - Function that receives services and returns a Hono app instance.
1656
- *
1657
- * @example
1658
- * const db = postgres({ compose: 'db' });
1659
- * await spec(
1660
- * app((services) => createApp({ databaseUrl: services.db.connectionString })),
1661
- * { services: [db] },
1662
- * );
1663
- */
1664
- function app(factory) {
1665
- return {
1666
- kind: "app",
1667
- factory
1668
- };
1669
- }
1670
- /**
1671
- * Test against a full docker compose stack. The stack is started with
1672
- * `docker compose up` and real HTTP requests are sent to the app service.
1673
- *
1674
- * @param root - Project root containing `docker/compose.test.yaml`.
1675
- *
1676
- * @example
1677
- * await spec(stack('../../'));
1678
- */
1679
- function stack(root) {
1680
- return {
1681
- kind: "stack",
1682
- root
1683
- };
1684
- }
1685
- /**
1686
- * Test a CLI binary. Each spec runs in a fresh temp directory.
1687
- *
1688
- * @param bin - Path to the CLI binary or command name (resolved from node_modules/.bin or PATH).
1689
- *
1690
- * @example
1691
- * await spec(command('my-cli'), { root: '../fixtures' });
1692
- */
1693
- function command(bin) {
1694
- return {
1695
- kind: "command",
1696
- bin
1697
- };
1698
- }
1699
- //#endregion
1700
- //#region src/spec/legacy-cli.ts
1701
- /**
1702
- * Create a CLI specification runner.
1703
- * Runs CLI commands against fixture projects. Optionally starts infrastructure.
1704
- */
1705
- async function cli(options) {
1706
- const root = resolveProjectRoot(options.root);
1707
- const command = resolveCommand(options.command, root);
1708
- let orchestrator = null;
1709
- let database;
1710
- let databases;
1711
- if (options.services?.length) {
1712
- orchestrator = new Orchestrator({
1713
- mode: "integration",
1714
- root,
1715
- services: options.services
1716
- });
1717
- await orchestrator.start();
1718
- database = orchestrator.getDatabase() ?? void 0;
1719
- const dbMap = orchestrator.getDatabases();
1720
- databases = dbMap.size > 0 ? dbMap : void 0;
1721
- }
1722
- const runner = createSpecificationRunner({
1723
- command: new ExecAdapter(command),
1724
- database,
1725
- databases,
1726
- fixturesRoot: root
1727
- });
1728
- runner.cleanup = async () => {
1729
- if (orchestrator) await orchestrator.stop();
1730
- };
1731
- runner.orchestrator = orchestrator;
1732
- return runner;
1733
- }
1734
- //#endregion
1735
- //#region src/spec/legacy-e2e.ts
1736
- /**
1737
- * Create an E2E specification runner.
1738
- * Starts full docker compose stack. App URL and database auto-detected.
1739
- */
1740
- async function e2e(options = {}) {
1741
- const orchestrator = new Orchestrator({
1742
- mode: "e2e",
1743
- root: resolveProjectRoot(options.root),
1744
- services: []
1745
- });
1746
- await orchestrator.startCompose();
1747
- const appUrl = orchestrator.getAppUrl();
1748
- if (!appUrl) throw new Error("E2E: could not detect app URL from compose. Ensure an app service with ports is defined.");
1749
- const database = orchestrator.getDatabase() ?? void 0;
1750
- const databases = orchestrator.getDatabases();
1751
- const runner = createSpecificationRunner({
1752
- database,
1753
- databases: databases.size > 0 ? databases : void 0,
1754
- server: new FetchAdapter(appUrl)
1755
- });
1756
- runner.cleanup = () => orchestrator.stopCompose();
1757
- runner.orchestrator = orchestrator;
1758
- return runner;
1759
- }
1760
- //#endregion
1761
- //#region src/spec/legacy-integration.ts
1762
- /**
1763
- * Create an integration specification runner.
1764
- * Starts infra containers via testcontainers, app runs in-process.
1765
- */
1766
- async function integration(options) {
1767
- const orchestrator = new Orchestrator({
1768
- mode: "integration",
1769
- root: resolveProjectRoot(options.root),
1770
- services: options.services
1771
- });
1772
- await orchestrator.start();
1773
- const app = options.app();
1774
- const database = orchestrator.getDatabase() ?? void 0;
1775
- const databases = orchestrator.getDatabases();
1776
- const runner = createSpecificationRunner({
1777
- database,
1778
- databases: databases.size > 0 ? databases : void 0,
1779
- server: new HonoAdapter(app)
1780
- });
1781
- runner.cleanup = () => orchestrator.stop();
1782
- runner.orchestrator = orchestrator;
1783
- return runner;
1784
- }
1785
- //#endregion
1786
- exports.DirectoryAccessor = DirectoryAccessor;
1787
- exports.DockerAssertion = DockerAssertion;
1788
- exports.ExecAdapter = ExecAdapter;
1789
- exports.FetchAdapter = FetchAdapter;
1790
- exports.HonoAdapter = HonoAdapter;
1791
- exports.Orchestrator = Orchestrator;
1792
- exports.ResponseAccessor = ResponseAccessor;
1793
- exports.SpecificationBuilder = SpecificationBuilder;
1794
- exports.SpecificationResult = SpecificationResult;
1795
- exports.TableAssertion = TableAssertion;
1796
- exports.app = app;
1797
- exports.cli = cli;
1798
- exports.command = command;
1799
- exports.createSpecificationRunner = createSpecificationRunner;
1800
- exports.dockerContainer = dockerContainer;
1801
- exports.e2e = e2e;
1802
- exports.grep = grep;
1803
- exports.integration = integration;
1804
- exports.mockOf = require_mock_of.mockOf;
1805
- exports.mockOfDate = require_mock_of.mockOfDate;
1806
- exports.normalizeOutput = normalizeOutput;
1807
- exports.postgres = require_sqlite_adapter.postgres;
1808
- exports.redis = require_sqlite_adapter.redis;
1809
- exports.spec = spec;
1810
- exports.sqlite = require_sqlite_adapter.sqlite;
1811
- exports.stack = stack;
1812
- exports.stripAnsi = stripAnsi;
1813
-
1814
- //# sourceMappingURL=index.cjs.map