@jterrazz/test 3.5.0 → 4.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,8 +39,8 @@ test("creates a user", async () => {
39
39
  .run();
40
40
 
41
41
  // Then — user created
42
- result.expectStatus(201);
43
- await result.expectTable("users", {
42
+ result.status.toBe(201);
43
+ await result.table("users").toMatch({
44
44
  columns: ["name"],
45
45
  rows: [["Alice"], ["Bob"]],
46
46
  });
@@ -69,12 +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
73
- .expectExitCode(0)
74
- .expectStdoutContains("Build completed")
75
- .expectFile("dist/index.js")
76
- .expectNoFile("dist/index.cjs")
77
- .expectFileContains("dist/index.js", "Hello");
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");
78
77
  });
79
78
  ```
80
79
 
@@ -166,38 +165,46 @@ Every test follows the same pattern: `spec("label") → setup → action → ass
166
165
 
167
166
  ### Assertions
168
167
 
169
- All assertions return `this` for chaining. Database assertions (`expectTable`) are async.
168
+ Assertions use a scoped API: `result.{scope}.{assertion}`. Database assertions (`result.table()`) are async.
170
169
 
171
170
  **HTTP-specific:**
172
171
 
173
- | Method | Description |
174
- | ------------------------------ | -------------------------------------------------- |
175
- | `.expectStatus(code)` | Assert HTTP status code |
176
- | `.expectResponse("file.json")` | Assert response body matches `responses/file.json` |
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` |
177
176
 
178
177
  **CLI-specific:**
179
178
 
180
- | Method | Description |
181
- | ---------------------------- | ----------------------------------------- |
182
- | `.expectExitCode(code)` | Assert process exit code |
183
- | `.expectStdoutContains(str)` | Assert stdout contains string |
184
- | `.expectStderrContains(str)` | Assert stderr contains string |
185
- | `.expectStdout("file.txt")` | Assert stdout matches `expected/file.txt` |
186
- | `.expectStderr("file.txt")` | Assert stderr matches `expected/file.txt` |
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 |
187
192
 
188
193
  **Cross-mode:**
189
194
 
190
- | Method | Description |
191
- | ------------------------------------------------- | --------------------------------------- |
192
- | `.expectTable(table, { columns, rows })` | Assert database table contents |
193
- | `.expectTable(table, { columns, rows, service })` | Assert on a specific database |
194
- | `.expectFile(path)` | Assert file exists in working directory |
195
- | `.expectNoFile(path)` | Assert file does not exist |
196
- | `.expectFileContains(path, content)` | Assert file contains string |
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 |
197
204
 
198
205
  ## Multi-database support
199
206
 
200
- When multiple databases are declared, `seed()` and `expectTable()` accept `{ service: "name" }` to target a specific database by its compose name. Without `service`, both default to the first postgres.
207
+ When multiple databases are declared, `seed()` and `result.table()` accept `{ service: "name" }` to target a specific database by its compose name. Without `service`, both default to the first postgres.
201
208
 
202
209
  ```typescript
203
210
  const db = postgres({ compose: "db" });
@@ -214,11 +221,10 @@ const result = await spec("cross-db")
214
221
  .post("/users", "request.json")
215
222
  .run();
216
223
 
217
- await result.expectTable("users", { columns: ["name"], rows: [["Alice"]] });
218
- await result.expectTable("events", {
224
+ await result.table("users").toMatch({ columns: ["name"], rows: [["Alice"]] });
225
+ await result.table("events", { service: "analytics-db" }).toMatch({
219
226
  columns: ["type"],
220
227
  rows: [["user_created"]],
221
- service: "analytics-db",
222
228
  });
223
229
  ```
224
230
 
@@ -300,8 +306,8 @@ test("creates a user and returns 201", async () => {
300
306
  .run();
301
307
 
302
308
  // Then — user created with all three in table
303
- result.expectStatus(201);
304
- await result.expectTable("users", {
309
+ result.status.toBe(201);
310
+ await result.table("users").toMatch({
305
311
  columns: ["name"],
306
312
  rows: [["Alice"], ["Bob"], ["Charlie"]],
307
313
  });
package/dist/index.cjs CHANGED
@@ -861,92 +861,258 @@ var HonoAdapter = class {
861
861
  }
862
862
  };
863
863
  //#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 {
1018
+ tableName;
1019
+ db;
1020
+ constructor(tableName, db) {
1021
+ super();
1022
+ this.tableName = tableName;
1023
+ this.db = db;
1024
+ }
1025
+ async toMatch(expected) {
1026
+ 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`);
1029
+ }
1030
+ async toBeEmpty() {
1031
+ 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`);
1034
+ }
1035
+ };
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;
1051
+ }
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`);
1059
+ }
1060
+ };
1061
+ //#endregion
864
1062
  //#region src/specification/specification.ts
865
1063
  var SpecificationResult = class {
866
1064
  commandResult;
867
1065
  config;
868
1066
  requestInfo;
869
- response;
1067
+ responseData;
870
1068
  testDir;
871
1069
  workDir;
872
1070
  constructor(options) {
873
- this.response = options.response;
1071
+ this.responseData = options.response;
874
1072
  this.commandResult = options.commandResult;
875
1073
  this.config = options.config;
876
1074
  this.testDir = options.testDir;
877
1075
  this.requestInfo = options.requestInfo;
878
1076
  this.workDir = options.workDir;
879
1077
  }
880
- expectStatus(code) {
881
- if (!this.response || !this.requestInfo) throw new Error("expectStatus requires an HTTP action (.get(), .post(), etc.)");
882
- if (this.response.status !== code) throw new Error(formatStatusError(code, this.response.status, this.requestInfo, this.response.body));
883
- return this;
884
- }
885
- expectResponse(file) {
886
- if (!this.response) throw new Error("expectResponse requires an HTTP action (.get(), .post(), etc.)");
887
- const expected = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "responses", file), "utf8"));
888
- if (JSON.stringify(this.response.body) !== JSON.stringify(expected)) throw new Error(formatResponseDiff(file, expected, this.response.body));
889
- return this;
890
- }
891
- expectExitCode(code) {
892
- if (!this.commandResult) throw new Error("expectExitCode requires a CLI action (.exec())");
893
- if (this.commandResult.exitCode !== code) throw new Error(formatExitCodeError(code, this.commandResult.exitCode, this.commandResult.stdout, this.commandResult.stderr));
894
- return this;
895
- }
896
- expectStdout(file) {
897
- if (!this.commandResult) throw new Error("expectStdout requires a CLI action (.exec())");
898
- const expected = (0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "expected", file), "utf8").trim();
899
- const actual = this.commandResult.stdout.trim();
900
- if (actual !== expected) throw new Error(formatStdoutDiff(file, expected, actual));
901
- return this;
902
- }
903
- expectStdoutContains(str) {
904
- if (!this.commandResult) throw new Error("expectStdoutContains requires a CLI action (.exec())");
905
- if (!this.commandResult.stdout.includes(str)) throw new Error(`Expected stdout to contain: "${str}"\n\nActual stdout:\n${this.commandResult.stdout}`);
906
- return this;
1078
+ get exitCode() {
1079
+ 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
+ });
907
1084
  }
908
- expectStderr(file) {
909
- if (!this.commandResult) throw new Error("expectStderr requires a CLI action (.exec())");
910
- const expected = (0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "expected", file), "utf8").trim();
911
- const actual = this.commandResult.stderr.trim();
912
- if (actual !== expected) throw new Error(formatStdoutDiff(file, expected, actual));
913
- return this;
1085
+ 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
+ });
914
1091
  }
915
- expectStderrContains(str) {
916
- if (!this.commandResult) throw new Error("expectStderrContains requires a CLI action (.exec())");
917
- if (!this.commandResult.stderr.includes(str)) throw new Error(`Expected stderr to contain: "${str}"\n\nActual stderr:\n${this.commandResult.stderr}`);
918
- return this;
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);
919
1095
  }
920
- async expectTable(table, options) {
921
- const db = this.resolveDatabase(options.service);
922
- if (!db) throw new Error(options.service ? `expectTable requires database "${options.service}" but it was not found` : "expectTable requires a database adapter");
923
- const actual = await db.query(table, options.columns);
924
- if (JSON.stringify(actual) !== JSON.stringify(options.rows)) throw new Error(formatTableDiff(table, options.columns, options.rows, actual));
925
- return this;
1096
+ get stdout() {
1097
+ if (!this.commandResult) throw new Error(".stdout requires a CLI action (.exec())");
1098
+ return new StringAssertion(this.commandResult.stdout, "stdout", this.testDir);
926
1099
  }
927
- expectFile(path) {
928
- if (!(0, node_fs.existsSync)(this.resolveWorkPath(path))) throw new Error(formatFileMissing(path));
929
- return this;
1100
+ get stderr() {
1101
+ if (!this.commandResult) throw new Error(".stderr requires a CLI action (.exec())");
1102
+ return new StringAssertion(this.commandResult.stderr, "stderr", this.testDir);
930
1103
  }
931
- expectNoFile(path) {
932
- if ((0, node_fs.existsSync)(this.resolveWorkPath(path))) throw new Error(formatFileUnexpected(path));
933
- return this;
1104
+ file(path) {
1105
+ return new FileAssertion(path, this.workDir ?? this.testDir);
934
1106
  }
935
- expectFileContains(path, content) {
936
- const resolved = this.resolveWorkPath(path);
937
- if (!(0, node_fs.existsSync)(resolved)) throw new Error(formatFileMissing(path));
938
- const actual = (0, node_fs.readFileSync)(resolved, "utf8");
939
- if (!actual.includes(content)) throw new Error(formatFileContentMismatch(path, content, actual));
940
- return this;
1107
+ table(tableName, options) {
1108
+ const db = this.resolveDatabase(options?.service);
1109
+ if (!db) throw new Error(options?.service ? `table("${tableName}") requires database "${options.service}" but it was not found` : `table("${tableName}") requires a database adapter`);
1110
+ return new TableAssertion(tableName, db);
941
1111
  }
942
1112
  resolveDatabase(serviceName) {
943
1113
  if (serviceName && this.config.databases) return this.config.databases.get(serviceName);
944
1114
  return this.config.database;
945
1115
  }
946
- resolveWorkPath(path) {
947
- if (this.workDir) return (0, node_path.resolve)(this.workDir, path);
948
- return (0, node_path.resolve)(this.testDir, path);
949
- }
950
1116
  };
951
1117
  var SpecificationBuilder = class {
952
1118
  commandArgs = null;