@jterrazz/test 4.0.0 → 5.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.
package/README.md CHANGED
@@ -39,7 +39,7 @@ test("creates a user", async () => {
39
39
  .run();
40
40
 
41
41
  // Then — user created
42
- result.status.toBe(201);
42
+ expect(result.status).toBe(201);
43
43
  await result.table("users").toMatch({
44
44
  columns: ["name"],
45
45
  rows: [["Alice"], ["Bob"]],
@@ -69,11 +69,11 @@ test("builds the project", async () => {
69
69
  const result = await spec("build").project("sample-app").exec("build").run();
70
70
 
71
71
  // Then — ESM output with source maps
72
- result.exitCode.toBe(0);
73
- result.stdout.toContain("Build completed");
74
- result.file("dist/index.js").toExist();
75
- result.file("dist/index.cjs").not.toExist();
76
- result.file("dist/index.js").toContain("Hello");
72
+ expect(result.exitCode).toBe(0);
73
+ expect(result.stdout).toContain("Build completed");
74
+ expect(result.file("dist/index.js").exists).toBe(true);
75
+ expect(result.file("dist/index.cjs").exists).toBe(false);
76
+ expect(result.file("dist/index.js").content).toContain("Hello");
77
77
  });
78
78
  ```
79
79
 
@@ -165,42 +165,50 @@ Every test follows the same pattern: `spec("label") → setup → action → ass
165
165
 
166
166
  ### Assertions
167
167
 
168
- Assertions use a scoped API: `result.{scope}.{assertion}`. Database assertions (`result.table()`) are async.
169
-
170
- **HTTP-specific:**
171
-
172
- | Method | Description |
173
- | ------------------------------------------ | -------------------------------------------------- |
174
- | `result.status.toBe(code)` | Assert HTTP status code |
175
- | `result.response.toMatchFile("file.json")` | Assert response body matches `responses/file.json` |
176
-
177
- **CLI-specific:**
178
-
179
- | Method | Description |
180
- | --------------------------------------------------- | -------------------------------------------------- |
181
- | `result.exitCode.toBe(code)` | Assert process exit code |
182
- | `result.stdout.toContain(str)` | Assert stdout contains string |
183
- | `result.stdout.not.toContain(str)` | Assert stdout does not contain string |
184
- | `result.stdout.toContain(str, { near: "ctx" })` | Assert stdout contains string near context |
185
- | `result.stderr.toContain(str)` | Assert stderr contains string |
186
- | `result.stderr.not.toContain(str)` | Assert stderr does not contain string |
187
- | `result.stderr.not.toContain(str, { near: "ctx" })` | Assert stderr does not contain string near context |
188
- | `result.stdout.toMatch(/regex/)` | Assert stdout matches regex |
189
- | `result.stdout.toMatchFile("file.txt")` | Assert stdout matches `expected/file.txt` |
190
- | `result.stderr.toMatchFile("file.txt")` | Assert stderr matches `expected/file.txt` |
191
- | `result.stdout.toBeEmpty()` | Assert stdout is empty |
192
-
193
- **Cross-mode:**
194
-
195
- | Method | Description |
196
- | ------------------------------------------------------------------ | --------------------------------------- |
197
- | `await result.table(name).toMatch({ columns, rows })` | Assert database table contents |
198
- | `await result.table(name, { service }).toMatch({ columns, rows })` | Assert on a specific database |
199
- | `await result.table(name).toBeEmpty()` | Assert database table is empty |
200
- | `result.file(path).toExist()` | Assert file exists in working directory |
201
- | `result.file(path).not.toExist()` | Assert file does not exist |
202
- | `result.file(path).toContain(content)` | Assert file contains string |
203
- | `result.file(path).toMatch(/regex/)` | Assert file content matches regex |
168
+ Result properties are raw values use vitest `expect()` for assertions. Database and response file assertions use custom async methods.
169
+
170
+ **Raw values (vitest expect):**
171
+
172
+ | Expression | Description |
173
+ | -------------------------------------------- | --------------------------- |
174
+ | `expect(result.exitCode).toBe(0)` | CLI exit code |
175
+ | `expect(result.status).toBe(201)` | HTTP status code |
176
+ | `expect(result.stdout).toContain("hello")` | CLI stdout contains string |
177
+ | `expect(result.stderr).not.toContain("err")` | CLI stderr does not contain |
178
+
179
+ **Files (result.file returns {exists, content}):**
180
+
181
+ | Expression | Description |
182
+ | ----------------------------------------------------------------- | --------------------------- |
183
+ | `expect(result.file("dist/index.js").exists).toBe(true)` | Assert file exists |
184
+ | `expect(result.file("dist/index.js").content).toContain("Hello")` | Assert file contains string |
185
+ | `expect(result.file("dist/index.cjs").exists).toBe(false)` | Assert file does not exist |
186
+
187
+ **Grep (scoped text matching):**
188
+
189
+ ```typescript
190
+ import { grep } from "@jterrazz/test";
191
+
192
+ expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars");
193
+ expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports");
194
+ ```
195
+
196
+ `grep(output, pattern)` filters multi-line output to the block matching `pattern`, returning a string for vitest assertions.
197
+
198
+ **Response (HTTP body):**
199
+
200
+ | Expression | Description |
201
+ | ----------------------------------------------- | --------------------------------------------------------------------------- |
202
+ | `result.response.toMatchFile("expected.json")` | Custom compares body to `responses/expected.json`, shows diff on mismatch |
203
+ | `expect(result.response.body).toEqual({ ... })` | Raw body object for vitest assertions |
204
+
205
+ **Tables (custom async — database queries):**
206
+
207
+ | Expression | Description |
208
+ | ------------------------------------------------------------------------------- | ------------------------------ |
209
+ | `await result.table("users").toMatch({ columns: ["name"], rows: [["Alice"]] })` | Assert database table contents |
210
+ | `await result.table("events", { service: "analytics-db" }).toMatch({ ... })` | Assert on a specific database |
211
+ | `await result.table("users").toBeEmpty()` | Assert database table is empty |
204
212
 
205
213
  ## Multi-database support
206
214
 
@@ -221,6 +229,7 @@ const result = await spec("cross-db")
221
229
  .post("/users", "request.json")
222
230
  .run();
223
231
 
232
+ expect(result.status).toBe(201);
224
233
  await result.table("users").toMatch({ columns: ["name"], rows: [["Alice"]] });
225
234
  await result.table("events", { service: "analytics-db" }).toMatch({
226
235
  columns: ["type"],
@@ -306,7 +315,7 @@ test("creates a user and returns 201", async () => {
306
315
  .run();
307
316
 
308
317
  // Then — user created with all three in table
309
- result.status.toBe(201);
318
+ expect(result.status).toBe(201);
310
319
  await result.table("users").toMatch({
311
320
  columns: ["name"],
312
321
  rows: [["Alice"], ["Bob"], ["Charlie"]],
package/dist/index.cjs CHANGED
@@ -244,20 +244,6 @@ function formatStartupReport(mode, services, app) {
244
244
  lines.push("");
245
245
  return lines.join("\n");
246
246
  }
247
- function formatStatusError(expectedStatus, receivedStatus, request, responseBody) {
248
- const lines = [];
249
- lines.push(`Expected status: ${GREEN}${expectedStatus}${RESET}`);
250
- lines.push(`Received status: ${RED}${receivedStatus}${RESET}`);
251
- lines.push("");
252
- lines.push(`${DIM}${request.method} ${request.path}${RESET}`);
253
- if (request.body) lines.push(formatJson(request.body, DIM));
254
- if (responseBody) {
255
- lines.push("");
256
- lines.push(`${DIM}Response:${RESET}`);
257
- lines.push(formatJson(responseBody, RED));
258
- }
259
- return lines.join("\n");
260
- }
261
247
  function formatTableDiff(table, columns, expected, actual) {
262
248
  const lines = [];
263
249
  lines.push(`Table "${table}" mismatch`);
@@ -306,66 +292,9 @@ function formatResponseDiff(file, expected, actual) {
306
292
  }
307
293
  return lines.join("\n");
308
294
  }
309
- function formatExitCodeError(expected, received, stdout, stderr) {
310
- const lines = [];
311
- lines.push(`Expected exit code: ${GREEN}${expected}${RESET}`);
312
- lines.push(`Received exit code: ${RED}${received}${RESET}`);
313
- if (stdout.trim()) {
314
- lines.push("");
315
- lines.push(`${DIM}stdout:${RESET}`);
316
- for (const line of stdout.trim().split("\n").slice(-15)) lines.push(` ${DIM}${line}${RESET}`);
317
- }
318
- if (stderr.trim()) {
319
- lines.push("");
320
- lines.push(`${DIM}stderr:${RESET}`);
321
- for (const line of stderr.trim().split("\n").slice(-15)) lines.push(` ${RED}${line}${RESET}`);
322
- }
323
- return lines.join("\n");
324
- }
325
- function formatStdoutDiff(file, expected, actual) {
326
- const lines = [];
327
- lines.push(`Output mismatch (${file})`);
328
- lines.push("");
329
- lines.push(`${GREEN}- Expected${RESET}`);
330
- lines.push(`${RED}+ Received${RESET}`);
331
- lines.push("");
332
- const expectedLines = expected.split("\n");
333
- const actualLines = actual.split("\n");
334
- const maxLines = Math.max(expectedLines.length, actualLines.length);
335
- for (let i = 0; i < maxLines; i++) {
336
- const exp = expectedLines[i];
337
- const act = actualLines[i];
338
- if (exp === act) lines.push(` ${exp}`);
339
- else {
340
- if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
341
- if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
342
- }
343
- }
344
- return lines.join("\n");
345
- }
346
- function formatFileMissing(path) {
347
- return `Expected file to exist: ${RED}${path}${RESET}`;
348
- }
349
- function formatFileUnexpected(path) {
350
- return `Expected file NOT to exist: ${RED}${path}${RESET}`;
351
- }
352
- function formatFileContentMismatch(path, expected, actual) {
353
- const lines = [];
354
- lines.push(`File "${path}" does not contain expected content`);
355
- lines.push("");
356
- lines.push(`${GREEN}Expected to contain:${RESET}`);
357
- lines.push(` ${GREEN}${expected}${RESET}`);
358
- lines.push("");
359
- lines.push(`${RED}Actual content (first 20 lines):${RESET}`);
360
- for (const line of actual.split("\n").slice(0, 20)) lines.push(` ${DIM}${line}${RESET}`);
361
- return lines.join("\n");
362
- }
363
295
  function rowLabel(n) {
364
296
  return n === 1 ? "1 row" : `${n} rows`;
365
297
  }
366
- function formatJson(value, color) {
367
- return JSON.stringify(value, null, 2).split("\n").map((line) => `${color}${line}${RESET}`).join("\n");
368
- }
369
298
  function formatRow(row) {
370
299
  return row.map((v) => String(v ?? "null")).join(" | ");
371
300
  }
@@ -861,205 +790,35 @@ var HonoAdapter = class {
861
790
  }
862
791
  };
863
792
  //#endregion
864
- //#region src/specification/assertions/base.ts
865
- /**
866
- * Base assertion that handles .not negation.
867
- * Subclasses call this.assert(condition, message, negatedMessage) for each predicate.
868
- */
869
- var BaseAssertion = class {
870
- negated = false;
871
- get not() {
872
- const clone = Object.create(Object.getPrototypeOf(this));
873
- Object.assign(clone, this);
874
- clone.negated = !this.negated;
875
- return clone;
876
- }
877
- assert(condition, message, negatedMessage) {
878
- if (this.negated) {
879
- if (condition) throw new Error(negatedMessage);
880
- } else if (!condition) throw new Error(message);
881
- }
882
- };
883
- //#endregion
884
- //#region src/specification/assertions/file.ts
885
- /**
886
- * Assertions on a file in the working directory.
887
- * Usage: result.file("dist/index.js").toExist()
888
- */
889
- var FileAssertion = class extends BaseAssertion {
890
- filePath;
891
- resolvedPath;
892
- constructor(filePath, workDir) {
893
- super();
894
- this.filePath = filePath;
895
- this.resolvedPath = (0, node_path.resolve)(workDir, filePath);
896
- }
897
- toExist() {
898
- const exists = (0, node_fs.existsSync)(this.resolvedPath);
899
- this.assert(exists, formatFileMissing(this.filePath), formatFileUnexpected(this.filePath));
900
- }
901
- toContain(expected) {
902
- if (!(0, node_fs.existsSync)(this.resolvedPath)) {
903
- if (this.negated) return;
904
- throw new Error(formatFileMissing(this.filePath));
905
- }
906
- const content = (0, node_fs.readFileSync)(this.resolvedPath, "utf8");
907
- const found = content.includes(expected);
908
- this.assert(found, formatFileContentMismatch(this.filePath, expected, content), `Expected file "${this.filePath}" NOT to contain "${expected}"`);
909
- }
910
- toMatch(pattern) {
911
- if (!(0, node_fs.existsSync)(this.resolvedPath)) {
912
- if (this.negated) return;
913
- throw new Error(formatFileMissing(this.filePath));
914
- }
915
- const content = (0, node_fs.readFileSync)(this.resolvedPath, "utf8");
916
- const found = pattern.test(content);
917
- this.assert(found, `Expected file "${this.filePath}" to match: ${pattern}\n\nActual content:\n${content.slice(0, 500)}`, `Expected file "${this.filePath}" NOT to match: ${pattern}`);
918
- }
919
- };
920
- //#endregion
921
- //#region src/specification/assertions/response.ts
922
- /**
923
- * Assertions on an HTTP response body.
924
- * Usage: result.response.toMatchFile("expected.json")
925
- */
926
- var ResponseAssertion = class extends BaseAssertion {
927
- body;
928
- testDir;
929
- constructor(body, testDir) {
930
- super();
931
- this.body = body;
932
- this.testDir = testDir;
933
- }
934
- toMatchFile(file) {
935
- const expected = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "responses", file), "utf8"));
936
- const match = JSON.stringify(this.body) === JSON.stringify(expected);
937
- this.assert(match, formatResponseDiff(file, expected, this.body), `Expected response NOT to match file "${file}", but it did`);
938
- }
939
- toContain(subset) {
940
- const bodyStr = JSON.stringify(this.body);
941
- const subsetStr = JSON.stringify(subset);
942
- const bodyObj = typeof this.body === "object" && this.body !== null ? this.body : {};
943
- const match = Object.entries(subset).every(([key, value]) => JSON.stringify(bodyObj[key]) === JSON.stringify(value));
944
- this.assert(match, `Expected response to contain: ${subsetStr}\n\nActual response:\n${bodyStr}`, `Expected response NOT to contain: ${subsetStr}`);
945
- }
946
- };
947
- //#endregion
948
- //#region src/specification/assertions/string.ts
949
- /**
950
- * Assertions on a string (stdout, stderr, response body).
951
- * Usage: result.stdout.toContain("hello")
952
- */
953
- var StringAssertion = class extends BaseAssertion {
954
- actual;
955
- label;
956
- testDir;
957
- constructor(actual, label, testDir) {
958
- super();
959
- this.actual = actual;
960
- this.label = label;
961
- this.testDir = testDir;
962
- }
963
- toContain(expected, options) {
964
- if (options?.near) {
965
- const found = this.containsNear(expected, options.near);
966
- this.assert(found, `Expected ${this.label} to contain "${expected}" near "${options.near}"\n\n${this.label}:\n${this.truncate(this.actual)}`, `Expected ${this.label} NOT to contain "${expected}" near "${options.near}", but it was found`);
967
- } else {
968
- const found = this.actual.includes(expected);
969
- this.assert(found, `Expected ${this.label} to contain: "${expected}"\n\nActual ${this.label}:\n${this.truncate(this.actual)}`, `Expected ${this.label} NOT to contain: "${expected}"`);
970
- }
971
- }
972
- toMatch(pattern) {
973
- const found = pattern.test(this.actual);
974
- this.assert(found, `Expected ${this.label} to match: ${pattern}\n\nActual ${this.label}:\n${this.truncate(this.actual)}`, `Expected ${this.label} NOT to match: ${pattern}`);
975
- }
976
- toMatchFile(file) {
977
- if (!this.testDir) throw new Error("toMatchFile requires a test directory context");
978
- const expected = (0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "expected", file), "utf8").trim();
979
- const actual = this.actual.trim();
980
- const match = actual === expected;
981
- this.assert(match, formatStdoutDiff(file, expected, actual), `Expected ${this.label} NOT to match file "${file}", but it did`);
982
- }
983
- toBeEmpty() {
984
- const empty = this.actual.trim() === "";
985
- this.assert(empty, `Expected ${this.label} to be empty\n\nActual ${this.label}:\n${this.truncate(this.actual)}`, `Expected ${this.label} NOT to be empty`);
986
- }
987
- containsNear(target, near, proximity = 500) {
988
- const clean = this.stripAnsi(this.actual);
989
- const nearLower = near.toLowerCase();
990
- const targetLower = target.toLowerCase();
991
- let searchFrom = 0;
992
- while (true) {
993
- const idx = clean.toLowerCase().indexOf(nearLower, searchFrom);
994
- if (idx === -1) break;
995
- const windowStart = Math.max(0, idx - proximity);
996
- const windowEnd = Math.min(clean.length, idx + nearLower.length + proximity);
997
- if (clean.substring(windowStart, windowEnd).toLowerCase().includes(targetLower)) return true;
998
- searchFrom = idx + 1;
999
- }
1000
- return false;
1001
- }
1002
- stripAnsi(str) {
1003
- return str.replace(/\x1b\[[0-9;]*m/g, "");
1004
- }
1005
- truncate(str, maxLines = 20) {
1006
- const lines = str.split("\n");
1007
- if (lines.length <= maxLines) return str;
1008
- return `${lines.slice(0, maxLines).join("\n")}\n... (${lines.length - maxLines} more lines)`;
1009
- }
1010
- };
1011
- //#endregion
1012
- //#region src/specification/assertions/table.ts
1013
- /**
1014
- * Assertions on a database table.
1015
- * Usage: await result.table("users").toMatch({ columns: ["name"], rows: [["Alice"]] })
1016
- */
1017
- var TableAssertion = class extends BaseAssertion {
793
+ //#region src/specification/specification.ts
794
+ var TableAssertion = class {
1018
795
  tableName;
1019
796
  db;
1020
797
  constructor(tableName, db) {
1021
- super();
1022
798
  this.tableName = tableName;
1023
799
  this.db = db;
1024
800
  }
1025
801
  async toMatch(expected) {
1026
802
  const actual = await this.db.query(this.tableName, expected.columns);
1027
- const match = JSON.stringify(actual) === JSON.stringify(expected.rows);
1028
- this.assert(match, formatTableDiff(this.tableName, expected.columns, expected.rows, actual), `Expected table "${this.tableName}" NOT to match, but it did`);
803
+ if (JSON.stringify(actual) !== JSON.stringify(expected.rows)) throw new Error(formatTableDiff(this.tableName, expected.columns, expected.rows, actual));
1029
804
  }
1030
805
  async toBeEmpty() {
1031
806
  const actual = await this.db.query(this.tableName, ["*"]);
1032
- const empty = actual.length === 0;
1033
- this.assert(empty, `Expected table "${this.tableName}" to be empty, but it has ${actual.length} rows`, `Expected table "${this.tableName}" NOT to be empty, but it is`);
807
+ if (actual.length !== 0) throw new Error(`Expected table "${this.tableName}" to be empty, but it has ${actual.length} rows`);
1034
808
  }
1035
809
  };
1036
- //#endregion
1037
- //#region src/specification/assertions/value.ts
1038
- /**
1039
- * Assertions on a single value (exit code, status code).
1040
- * Usage: result.exitCode.toBe(0)
1041
- */
1042
- var ValueAssertion = class extends BaseAssertion {
1043
- actual;
1044
- label;
1045
- context;
1046
- constructor(actual, label, context) {
1047
- super();
1048
- this.actual = actual;
1049
- this.label = label;
1050
- this.context = context;
810
+ var ResponseAccessor = class {
811
+ body;
812
+ testDir;
813
+ constructor(body, testDir) {
814
+ this.body = body;
815
+ this.testDir = testDir;
1051
816
  }
1052
- toBe(expected) {
1053
- const match = this.actual === expected;
1054
- let message;
1055
- if (this.label === "exit code" && this.context?.stdout !== void 0) message = formatExitCodeError(expected, this.actual, this.context.stdout ?? "", this.context.stderr ?? "");
1056
- else if (this.label === "status" && this.context?.request) message = formatStatusError(expected, this.actual, this.context.request, this.context.responseBody);
1057
- else message = `Expected ${this.label}: ${expected}\nReceived ${this.label}: ${this.actual}`;
1058
- this.assert(match, message, `Expected ${this.label} NOT to be ${expected}, but it was`);
817
+ toMatchFile(file) {
818
+ const expected = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "responses", file), "utf8"));
819
+ if (JSON.stringify(this.body) !== JSON.stringify(expected)) throw new Error(formatResponseDiff(file, expected, this.body));
1059
820
  }
1060
821
  };
1061
- //#endregion
1062
- //#region src/specification/specification.ts
1063
822
  var SpecificationResult = class {
1064
823
  commandResult;
1065
824
  config;
@@ -1077,32 +836,34 @@ var SpecificationResult = class {
1077
836
  }
1078
837
  get exitCode() {
1079
838
  if (!this.commandResult) throw new Error(".exitCode requires a CLI action (.exec())");
1080
- return new ValueAssertion(this.commandResult.exitCode, "exit code", {
1081
- stderr: this.commandResult.stderr,
1082
- stdout: this.commandResult.stdout
1083
- });
839
+ return this.commandResult.exitCode;
1084
840
  }
1085
841
  get status() {
1086
- if (!this.responseData || !this.requestInfo) throw new Error(".status requires an HTTP action (.get(), .post(), etc.)");
1087
- return new ValueAssertion(this.responseData.status, "status", {
1088
- request: this.requestInfo,
1089
- responseBody: this.responseData.body
1090
- });
1091
- }
1092
- get response() {
1093
- if (!this.responseData) throw new Error(".response requires an HTTP action (.get(), .post(), etc.)");
1094
- return new ResponseAssertion(this.responseData.body, this.testDir);
842
+ if (!this.responseData) throw new Error(".status requires an HTTP action (.get(), .post(), etc.)");
843
+ return this.responseData.status;
1095
844
  }
1096
845
  get stdout() {
1097
846
  if (!this.commandResult) throw new Error(".stdout requires a CLI action (.exec())");
1098
- return new StringAssertion(this.commandResult.stdout, "stdout", this.testDir);
847
+ return this.commandResult.stdout;
1099
848
  }
1100
849
  get stderr() {
1101
850
  if (!this.commandResult) throw new Error(".stderr requires a CLI action (.exec())");
1102
- return new StringAssertion(this.commandResult.stderr, "stderr", this.testDir);
851
+ return this.commandResult.stderr;
852
+ }
853
+ get response() {
854
+ if (!this.responseData) throw new Error(".response requires an HTTP action (.get(), .post(), etc.)");
855
+ return new ResponseAccessor(this.responseData.body, this.testDir);
1103
856
  }
1104
857
  file(path) {
1105
- return new FileAssertion(path, this.workDir ?? this.testDir);
858
+ const resolvedPath = (0, node_path.resolve)(this.workDir ?? this.testDir, path);
859
+ const exists = (0, node_fs.existsSync)(resolvedPath);
860
+ return {
861
+ get content() {
862
+ if (!exists) throw new Error(`File not found: ${path}`);
863
+ return (0, node_fs.readFileSync)(resolvedPath, "utf8");
864
+ },
865
+ exists
866
+ };
1106
867
  }
1107
868
  table(tableName, options) {
1108
869
  const db = this.resolveDatabase(options?.service);
@@ -1215,6 +976,7 @@ var SpecificationBuilder = class {
1215
976
  return this.runCliAction(workDir);
1216
977
  }
1217
978
  prepareWorkDir() {
979
+ if (!this.projectName && this.fixtures.length === 0) return this.config.fixturesRoot ?? process.cwd();
1218
980
  const tempDir = (0, node_fs.mkdtempSync)((0, node_path.resolve)((0, node_os.tmpdir)(), "spec-cli-"));
1219
981
  if (this.projectName && this.config.fixturesRoot) {
1220
982
  const projectDir = (0, node_path.resolve)(this.config.fixturesRoot, this.projectName);
@@ -1246,8 +1008,8 @@ var SpecificationBuilder = class {
1246
1008
  else if (Array.isArray(this.commandArgs)) {
1247
1009
  commandResult = {
1248
1010
  exitCode: 0,
1249
- stdout: "",
1250
- stderr: ""
1011
+ stderr: "",
1012
+ stdout: ""
1251
1013
  };
1252
1014
  for (const args of this.commandArgs) {
1253
1015
  commandResult = await this.config.command.exec(args, workDir);
@@ -1276,16 +1038,26 @@ function getCallerDir() {
1276
1038
  }
1277
1039
  throw new Error("Cannot detect caller directory from stack trace");
1278
1040
  }
1279
- /**
1280
- * Create a specification runner.
1281
- * Automatically detects the test directory from the call site.
1282
- */
1283
1041
  function createSpecificationRunner(config) {
1284
1042
  return (label) => {
1285
1043
  return new SpecificationBuilder(config, getCallerDir(), label);
1286
1044
  };
1287
1045
  }
1288
1046
  //#endregion
1047
+ //#region src/specification/grep.ts
1048
+ /**
1049
+ * Extract text blocks from output that contain a pattern.
1050
+ * Splits by blank lines (how linter/compiler output is structured),
1051
+ * returns only blocks matching the pattern.
1052
+ *
1053
+ * @example
1054
+ * expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
1055
+ * expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
1056
+ */
1057
+ function grep(output, pattern) {
1058
+ return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
1059
+ }
1060
+ //#endregion
1289
1061
  //#region src/specification/index.ts
1290
1062
  /**
1291
1063
  * Resolve root — if relative, resolves from the caller's directory.
@@ -1410,6 +1182,7 @@ exports.HonoAdapter = HonoAdapter;
1410
1182
  exports.Orchestrator = Orchestrator;
1411
1183
  exports.cli = cli;
1412
1184
  exports.e2e = e2e;
1185
+ exports.grep = grep;
1413
1186
  exports.integration = integration;
1414
1187
  exports.mockOf = mockOf;
1415
1188
  exports.mockOfDate = mockOfDate;