@jterrazz/test 3.5.0 → 4.0.1

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/dist/index.d.ts CHANGED
@@ -110,6 +110,99 @@ declare class Orchestrator {
110
110
  getAppUrl(): null | string;
111
111
  }
112
112
  //#endregion
113
+ //#region src/specification/assertions/base.d.ts
114
+ /**
115
+ * Base assertion that handles .not negation.
116
+ * Subclasses call this.assert(condition, message, negatedMessage) for each predicate.
117
+ */
118
+ declare class BaseAssertion {
119
+ protected negated: boolean;
120
+ get not(): this;
121
+ protected assert(condition: boolean, message: string, negatedMessage: string): void;
122
+ }
123
+ //#endregion
124
+ //#region src/specification/assertions/file.d.ts
125
+ /**
126
+ * Assertions on a file in the working directory.
127
+ * Usage: result.file("dist/index.js").toExist()
128
+ */
129
+ declare class FileAssertion extends BaseAssertion {
130
+ private filePath;
131
+ private resolvedPath;
132
+ constructor(filePath: string, workDir: string);
133
+ toExist(): void;
134
+ toContain(expected: string): void;
135
+ toMatch(pattern: RegExp): void;
136
+ }
137
+ //#endregion
138
+ //#region src/specification/assertions/response.d.ts
139
+ /**
140
+ * Assertions on an HTTP response body.
141
+ * Usage: result.response.toMatchFile("expected.json")
142
+ */
143
+ declare class ResponseAssertion extends BaseAssertion {
144
+ private body;
145
+ private testDir;
146
+ constructor(body: unknown, testDir: string);
147
+ toMatchFile(file: string): void;
148
+ toContain(subset: Record<string, unknown>): void;
149
+ }
150
+ //#endregion
151
+ //#region src/specification/assertions/string.d.ts
152
+ /**
153
+ * Assertions on a string (stdout, stderr, response body).
154
+ * Usage: result.stdout.toContain("hello")
155
+ */
156
+ declare class StringAssertion extends BaseAssertion {
157
+ private actual;
158
+ private label;
159
+ private testDir?;
160
+ constructor(actual: string, label: string, testDir?: string);
161
+ toContain(expected: string, options?: {
162
+ near?: string;
163
+ }): void;
164
+ toMatch(pattern: RegExp): void;
165
+ toMatchFile(file: string): void;
166
+ toBeEmpty(): void;
167
+ private containsNear;
168
+ private stripAnsi;
169
+ private truncate;
170
+ }
171
+ //#endregion
172
+ //#region src/specification/assertions/table.d.ts
173
+ /**
174
+ * Assertions on a database table.
175
+ * Usage: await result.table("users").toMatch({ columns: ["name"], rows: [["Alice"]] })
176
+ */
177
+ declare class TableAssertion extends BaseAssertion {
178
+ private tableName;
179
+ private db;
180
+ constructor(tableName: string, db: DatabasePort);
181
+ toMatch(expected: {
182
+ columns: string[];
183
+ rows: unknown[][];
184
+ }): Promise<void>;
185
+ toBeEmpty(): Promise<void>;
186
+ }
187
+ //#endregion
188
+ //#region src/specification/assertions/value.d.ts
189
+ /**
190
+ * Assertions on a single value (exit code, status code).
191
+ * Usage: result.exitCode.toBe(0)
192
+ */
193
+ declare class ValueAssertion extends BaseAssertion {
194
+ private actual;
195
+ private label;
196
+ private context?;
197
+ constructor(actual: number, label: string, context?: {
198
+ request?: any;
199
+ responseBody?: unknown;
200
+ stdout?: string;
201
+ stderr?: string;
202
+ });
203
+ toBe(expected: number): void;
204
+ }
205
+ //#endregion
113
206
  //#region src/specification/ports/command.port.d.ts
114
207
  /**
115
208
  * Result of executing a CLI command.
@@ -174,7 +267,7 @@ declare class SpecificationResult {
174
267
  private commandResult?;
175
268
  private config;
176
269
  private requestInfo?;
177
- private response?;
270
+ private responseData?;
178
271
  private testDir;
179
272
  private workDir?;
180
273
  constructor(options: {
@@ -185,23 +278,16 @@ declare class SpecificationResult {
185
278
  testDir: string;
186
279
  workDir?: string;
187
280
  });
188
- expectStatus(code: number): this;
189
- expectResponse(file: string): this;
190
- expectExitCode(code: number): this;
191
- expectStdout(file: string): this;
192
- expectStdoutContains(str: string): this;
193
- expectStderr(file: string): this;
194
- expectStderrContains(str: string): this;
195
- expectTable(table: string, options: {
196
- columns: string[];
197
- rows: unknown[][];
281
+ get exitCode(): ValueAssertion;
282
+ get status(): ValueAssertion;
283
+ get response(): ResponseAssertion;
284
+ get stdout(): StringAssertion;
285
+ get stderr(): StringAssertion;
286
+ file(path: string): FileAssertion;
287
+ table(tableName: string, options?: {
198
288
  service?: string;
199
- }): Promise<this>;
200
- expectFile(path: string): this;
201
- expectNoFile(path: string): this;
202
- expectFileContains(path: string, content: string): this;
289
+ }): TableAssertion;
203
290
  private resolveDatabase;
204
- private resolveWorkPath;
205
291
  }
206
292
  declare class SpecificationBuilder {
207
293
  private commandArgs;
package/dist/index.js CHANGED
@@ -837,92 +837,258 @@ var HonoAdapter = class {
837
837
  }
838
838
  };
839
839
  //#endregion
840
+ //#region src/specification/assertions/base.ts
841
+ /**
842
+ * Base assertion that handles .not negation.
843
+ * Subclasses call this.assert(condition, message, negatedMessage) for each predicate.
844
+ */
845
+ var BaseAssertion = class {
846
+ negated = false;
847
+ get not() {
848
+ const clone = Object.create(Object.getPrototypeOf(this));
849
+ Object.assign(clone, this);
850
+ clone.negated = !this.negated;
851
+ return clone;
852
+ }
853
+ assert(condition, message, negatedMessage) {
854
+ if (this.negated) {
855
+ if (condition) throw new Error(negatedMessage);
856
+ } else if (!condition) throw new Error(message);
857
+ }
858
+ };
859
+ //#endregion
860
+ //#region src/specification/assertions/file.ts
861
+ /**
862
+ * Assertions on a file in the working directory.
863
+ * Usage: result.file("dist/index.js").toExist()
864
+ */
865
+ var FileAssertion = class extends BaseAssertion {
866
+ filePath;
867
+ resolvedPath;
868
+ constructor(filePath, workDir) {
869
+ super();
870
+ this.filePath = filePath;
871
+ this.resolvedPath = resolve(workDir, filePath);
872
+ }
873
+ toExist() {
874
+ const exists = existsSync(this.resolvedPath);
875
+ this.assert(exists, formatFileMissing(this.filePath), formatFileUnexpected(this.filePath));
876
+ }
877
+ toContain(expected) {
878
+ if (!existsSync(this.resolvedPath)) {
879
+ if (this.negated) return;
880
+ throw new Error(formatFileMissing(this.filePath));
881
+ }
882
+ const content = readFileSync(this.resolvedPath, "utf8");
883
+ const found = content.includes(expected);
884
+ this.assert(found, formatFileContentMismatch(this.filePath, expected, content), `Expected file "${this.filePath}" NOT to contain "${expected}"`);
885
+ }
886
+ toMatch(pattern) {
887
+ if (!existsSync(this.resolvedPath)) {
888
+ if (this.negated) return;
889
+ throw new Error(formatFileMissing(this.filePath));
890
+ }
891
+ const content = readFileSync(this.resolvedPath, "utf8");
892
+ const found = pattern.test(content);
893
+ 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}`);
894
+ }
895
+ };
896
+ //#endregion
897
+ //#region src/specification/assertions/response.ts
898
+ /**
899
+ * Assertions on an HTTP response body.
900
+ * Usage: result.response.toMatchFile("expected.json")
901
+ */
902
+ var ResponseAssertion = class extends BaseAssertion {
903
+ body;
904
+ testDir;
905
+ constructor(body, testDir) {
906
+ super();
907
+ this.body = body;
908
+ this.testDir = testDir;
909
+ }
910
+ toMatchFile(file) {
911
+ const expected = JSON.parse(readFileSync(resolve(this.testDir, "responses", file), "utf8"));
912
+ const match = JSON.stringify(this.body) === JSON.stringify(expected);
913
+ this.assert(match, formatResponseDiff(file, expected, this.body), `Expected response NOT to match file "${file}", but it did`);
914
+ }
915
+ toContain(subset) {
916
+ const bodyStr = JSON.stringify(this.body);
917
+ const subsetStr = JSON.stringify(subset);
918
+ const bodyObj = typeof this.body === "object" && this.body !== null ? this.body : {};
919
+ const match = Object.entries(subset).every(([key, value]) => JSON.stringify(bodyObj[key]) === JSON.stringify(value));
920
+ this.assert(match, `Expected response to contain: ${subsetStr}\n\nActual response:\n${bodyStr}`, `Expected response NOT to contain: ${subsetStr}`);
921
+ }
922
+ };
923
+ //#endregion
924
+ //#region src/specification/assertions/string.ts
925
+ /**
926
+ * Assertions on a string (stdout, stderr, response body).
927
+ * Usage: result.stdout.toContain("hello")
928
+ */
929
+ var StringAssertion = class extends BaseAssertion {
930
+ actual;
931
+ label;
932
+ testDir;
933
+ constructor(actual, label, testDir) {
934
+ super();
935
+ this.actual = actual;
936
+ this.label = label;
937
+ this.testDir = testDir;
938
+ }
939
+ toContain(expected, options) {
940
+ if (options?.near) {
941
+ const found = this.containsNear(expected, options.near);
942
+ 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`);
943
+ } else {
944
+ const found = this.actual.includes(expected);
945
+ 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}"`);
946
+ }
947
+ }
948
+ toMatch(pattern) {
949
+ const found = pattern.test(this.actual);
950
+ 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}`);
951
+ }
952
+ toMatchFile(file) {
953
+ if (!this.testDir) throw new Error("toMatchFile requires a test directory context");
954
+ const expected = readFileSync(resolve(this.testDir, "expected", file), "utf8").trim();
955
+ const actual = this.actual.trim();
956
+ const match = actual === expected;
957
+ this.assert(match, formatStdoutDiff(file, expected, actual), `Expected ${this.label} NOT to match file "${file}", but it did`);
958
+ }
959
+ toBeEmpty() {
960
+ const empty = this.actual.trim() === "";
961
+ 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`);
962
+ }
963
+ containsNear(target, near, proximity = 500) {
964
+ const clean = this.stripAnsi(this.actual);
965
+ const nearLower = near.toLowerCase();
966
+ const targetLower = target.toLowerCase();
967
+ let searchFrom = 0;
968
+ while (true) {
969
+ const idx = clean.toLowerCase().indexOf(nearLower, searchFrom);
970
+ if (idx === -1) break;
971
+ const windowStart = Math.max(0, idx - proximity);
972
+ const windowEnd = Math.min(clean.length, idx + nearLower.length + proximity);
973
+ if (clean.substring(windowStart, windowEnd).toLowerCase().includes(targetLower)) return true;
974
+ searchFrom = idx + 1;
975
+ }
976
+ return false;
977
+ }
978
+ stripAnsi(str) {
979
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
980
+ }
981
+ truncate(str, maxLines = 20) {
982
+ const lines = str.split("\n");
983
+ if (lines.length <= maxLines) return str;
984
+ return `${lines.slice(0, maxLines).join("\n")}\n... (${lines.length - maxLines} more lines)`;
985
+ }
986
+ };
987
+ //#endregion
988
+ //#region src/specification/assertions/table.ts
989
+ /**
990
+ * Assertions on a database table.
991
+ * Usage: await result.table("users").toMatch({ columns: ["name"], rows: [["Alice"]] })
992
+ */
993
+ var TableAssertion = class extends BaseAssertion {
994
+ tableName;
995
+ db;
996
+ constructor(tableName, db) {
997
+ super();
998
+ this.tableName = tableName;
999
+ this.db = db;
1000
+ }
1001
+ async toMatch(expected) {
1002
+ const actual = await this.db.query(this.tableName, expected.columns);
1003
+ const match = JSON.stringify(actual) === JSON.stringify(expected.rows);
1004
+ this.assert(match, formatTableDiff(this.tableName, expected.columns, expected.rows, actual), `Expected table "${this.tableName}" NOT to match, but it did`);
1005
+ }
1006
+ async toBeEmpty() {
1007
+ const actual = await this.db.query(this.tableName, ["*"]);
1008
+ const empty = actual.length === 0;
1009
+ 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`);
1010
+ }
1011
+ };
1012
+ //#endregion
1013
+ //#region src/specification/assertions/value.ts
1014
+ /**
1015
+ * Assertions on a single value (exit code, status code).
1016
+ * Usage: result.exitCode.toBe(0)
1017
+ */
1018
+ var ValueAssertion = class extends BaseAssertion {
1019
+ actual;
1020
+ label;
1021
+ context;
1022
+ constructor(actual, label, context) {
1023
+ super();
1024
+ this.actual = actual;
1025
+ this.label = label;
1026
+ this.context = context;
1027
+ }
1028
+ toBe(expected) {
1029
+ const match = this.actual === expected;
1030
+ let message;
1031
+ if (this.label === "exit code" && this.context?.stdout !== void 0) message = formatExitCodeError(expected, this.actual, this.context.stdout ?? "", this.context.stderr ?? "");
1032
+ else if (this.label === "status" && this.context?.request) message = formatStatusError(expected, this.actual, this.context.request, this.context.responseBody);
1033
+ else message = `Expected ${this.label}: ${expected}\nReceived ${this.label}: ${this.actual}`;
1034
+ this.assert(match, message, `Expected ${this.label} NOT to be ${expected}, but it was`);
1035
+ }
1036
+ };
1037
+ //#endregion
840
1038
  //#region src/specification/specification.ts
841
1039
  var SpecificationResult = class {
842
1040
  commandResult;
843
1041
  config;
844
1042
  requestInfo;
845
- response;
1043
+ responseData;
846
1044
  testDir;
847
1045
  workDir;
848
1046
  constructor(options) {
849
- this.response = options.response;
1047
+ this.responseData = options.response;
850
1048
  this.commandResult = options.commandResult;
851
1049
  this.config = options.config;
852
1050
  this.testDir = options.testDir;
853
1051
  this.requestInfo = options.requestInfo;
854
1052
  this.workDir = options.workDir;
855
1053
  }
856
- expectStatus(code) {
857
- if (!this.response || !this.requestInfo) throw new Error("expectStatus requires an HTTP action (.get(), .post(), etc.)");
858
- if (this.response.status !== code) throw new Error(formatStatusError(code, this.response.status, this.requestInfo, this.response.body));
859
- return this;
860
- }
861
- expectResponse(file) {
862
- if (!this.response) throw new Error("expectResponse requires an HTTP action (.get(), .post(), etc.)");
863
- const expected = JSON.parse(readFileSync(resolve(this.testDir, "responses", file), "utf8"));
864
- if (JSON.stringify(this.response.body) !== JSON.stringify(expected)) throw new Error(formatResponseDiff(file, expected, this.response.body));
865
- return this;
866
- }
867
- expectExitCode(code) {
868
- if (!this.commandResult) throw new Error("expectExitCode requires a CLI action (.exec())");
869
- if (this.commandResult.exitCode !== code) throw new Error(formatExitCodeError(code, this.commandResult.exitCode, this.commandResult.stdout, this.commandResult.stderr));
870
- return this;
871
- }
872
- expectStdout(file) {
873
- if (!this.commandResult) throw new Error("expectStdout requires a CLI action (.exec())");
874
- const expected = readFileSync(resolve(this.testDir, "expected", file), "utf8").trim();
875
- const actual = this.commandResult.stdout.trim();
876
- if (actual !== expected) throw new Error(formatStdoutDiff(file, expected, actual));
877
- return this;
878
- }
879
- expectStdoutContains(str) {
880
- if (!this.commandResult) throw new Error("expectStdoutContains requires a CLI action (.exec())");
881
- if (!this.commandResult.stdout.includes(str)) throw new Error(`Expected stdout to contain: "${str}"\n\nActual stdout:\n${this.commandResult.stdout}`);
882
- return this;
1054
+ get exitCode() {
1055
+ if (!this.commandResult) throw new Error(".exitCode requires a CLI action (.exec())");
1056
+ return new ValueAssertion(this.commandResult.exitCode, "exit code", {
1057
+ stderr: this.commandResult.stderr,
1058
+ stdout: this.commandResult.stdout
1059
+ });
883
1060
  }
884
- expectStderr(file) {
885
- if (!this.commandResult) throw new Error("expectStderr requires a CLI action (.exec())");
886
- const expected = readFileSync(resolve(this.testDir, "expected", file), "utf8").trim();
887
- const actual = this.commandResult.stderr.trim();
888
- if (actual !== expected) throw new Error(formatStdoutDiff(file, expected, actual));
889
- return this;
1061
+ get status() {
1062
+ if (!this.responseData || !this.requestInfo) throw new Error(".status requires an HTTP action (.get(), .post(), etc.)");
1063
+ return new ValueAssertion(this.responseData.status, "status", {
1064
+ request: this.requestInfo,
1065
+ responseBody: this.responseData.body
1066
+ });
890
1067
  }
891
- expectStderrContains(str) {
892
- if (!this.commandResult) throw new Error("expectStderrContains requires a CLI action (.exec())");
893
- if (!this.commandResult.stderr.includes(str)) throw new Error(`Expected stderr to contain: "${str}"\n\nActual stderr:\n${this.commandResult.stderr}`);
894
- return this;
1068
+ get response() {
1069
+ if (!this.responseData) throw new Error(".response requires an HTTP action (.get(), .post(), etc.)");
1070
+ return new ResponseAssertion(this.responseData.body, this.testDir);
895
1071
  }
896
- async expectTable(table, options) {
897
- const db = this.resolveDatabase(options.service);
898
- if (!db) throw new Error(options.service ? `expectTable requires database "${options.service}" but it was not found` : "expectTable requires a database adapter");
899
- const actual = await db.query(table, options.columns);
900
- if (JSON.stringify(actual) !== JSON.stringify(options.rows)) throw new Error(formatTableDiff(table, options.columns, options.rows, actual));
901
- return this;
1072
+ get stdout() {
1073
+ if (!this.commandResult) throw new Error(".stdout requires a CLI action (.exec())");
1074
+ return new StringAssertion(this.commandResult.stdout, "stdout", this.testDir);
902
1075
  }
903
- expectFile(path) {
904
- if (!existsSync(this.resolveWorkPath(path))) throw new Error(formatFileMissing(path));
905
- return this;
1076
+ get stderr() {
1077
+ if (!this.commandResult) throw new Error(".stderr requires a CLI action (.exec())");
1078
+ return new StringAssertion(this.commandResult.stderr, "stderr", this.testDir);
906
1079
  }
907
- expectNoFile(path) {
908
- if (existsSync(this.resolveWorkPath(path))) throw new Error(formatFileUnexpected(path));
909
- return this;
1080
+ file(path) {
1081
+ return new FileAssertion(path, this.workDir ?? this.testDir);
910
1082
  }
911
- expectFileContains(path, content) {
912
- const resolved = this.resolveWorkPath(path);
913
- if (!existsSync(resolved)) throw new Error(formatFileMissing(path));
914
- const actual = readFileSync(resolved, "utf8");
915
- if (!actual.includes(content)) throw new Error(formatFileContentMismatch(path, content, actual));
916
- return this;
1083
+ table(tableName, options) {
1084
+ const db = this.resolveDatabase(options?.service);
1085
+ if (!db) throw new Error(options?.service ? `table("${tableName}") requires database "${options.service}" but it was not found` : `table("${tableName}") requires a database adapter`);
1086
+ return new TableAssertion(tableName, db);
917
1087
  }
918
1088
  resolveDatabase(serviceName) {
919
1089
  if (serviceName && this.config.databases) return this.config.databases.get(serviceName);
920
1090
  return this.config.database;
921
1091
  }
922
- resolveWorkPath(path) {
923
- if (this.workDir) return resolve(this.workDir, path);
924
- return resolve(this.testDir, path);
925
- }
926
1092
  };
927
1093
  var SpecificationBuilder = class {
928
1094
  commandArgs = null;
@@ -1025,6 +1191,7 @@ var SpecificationBuilder = class {
1025
1191
  return this.runCliAction(workDir);
1026
1192
  }
1027
1193
  prepareWorkDir() {
1194
+ if (!this.projectName && this.fixtures.length === 0) return this.config.fixturesRoot ?? process.cwd();
1028
1195
  const tempDir = mkdtempSync(resolve(tmpdir(), "spec-cli-"));
1029
1196
  if (this.projectName && this.config.fixturesRoot) {
1030
1197
  const projectDir = resolve(this.config.fixturesRoot, this.projectName);