@jterrazz/test 3.2.0 → 3.3.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.js CHANGED
@@ -2,9 +2,10 @@ import { i as __require, o as __toESM, t as __commonJSMin } from "./chunk.js";
2
2
  import { t as require_dist$1 } from "./dist.js";
3
3
  import MockDatePackage from "mockdate";
4
4
  import { mockDeep } from "vitest-mock-extended";
5
+ import { cpSync, existsSync, mkdtempSync, readFileSync } from "node:fs";
5
6
  import { dirname, isAbsolute, resolve } from "node:path";
6
7
  import { execSync } from "node:child_process";
7
- import { existsSync, readFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
8
9
  //#region src/mocking/mock-of-date.ts
9
10
  const mockOfDate = MockDatePackage;
10
11
  //#endregion
@@ -279,6 +280,60 @@ function formatResponseDiff(file, expected, actual) {
279
280
  }
280
281
  return lines.join("\n");
281
282
  }
283
+ function formatExitCodeError(expected, received, stdout, stderr) {
284
+ const lines = [];
285
+ lines.push(`Expected exit code: ${GREEN}${expected}${RESET}`);
286
+ lines.push(`Received exit code: ${RED}${received}${RESET}`);
287
+ if (stdout.trim()) {
288
+ lines.push("");
289
+ lines.push(`${DIM}stdout:${RESET}`);
290
+ for (const line of stdout.trim().split("\n").slice(-15)) lines.push(` ${DIM}${line}${RESET}`);
291
+ }
292
+ if (stderr.trim()) {
293
+ lines.push("");
294
+ lines.push(`${DIM}stderr:${RESET}`);
295
+ for (const line of stderr.trim().split("\n").slice(-15)) lines.push(` ${RED}${line}${RESET}`);
296
+ }
297
+ return lines.join("\n");
298
+ }
299
+ function formatStdoutDiff(file, expected, actual) {
300
+ const lines = [];
301
+ lines.push(`Output mismatch (${file})`);
302
+ lines.push("");
303
+ lines.push(`${GREEN}- Expected${RESET}`);
304
+ lines.push(`${RED}+ Received${RESET}`);
305
+ lines.push("");
306
+ const expectedLines = expected.split("\n");
307
+ const actualLines = actual.split("\n");
308
+ const maxLines = Math.max(expectedLines.length, actualLines.length);
309
+ for (let i = 0; i < maxLines; i++) {
310
+ const exp = expectedLines[i];
311
+ const act = actualLines[i];
312
+ if (exp === act) lines.push(` ${exp}`);
313
+ else {
314
+ if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
315
+ if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
316
+ }
317
+ }
318
+ return lines.join("\n");
319
+ }
320
+ function formatFileMissing(path) {
321
+ return `Expected file to exist: ${RED}${path}${RESET}`;
322
+ }
323
+ function formatFileUnexpected(path) {
324
+ return `Expected file NOT to exist: ${RED}${path}${RESET}`;
325
+ }
326
+ function formatFileContentMismatch(path, expected, actual) {
327
+ const lines = [];
328
+ lines.push(`File "${path}" does not contain expected content`);
329
+ lines.push("");
330
+ lines.push(`${GREEN}Expected to contain:${RESET}`);
331
+ lines.push(` ${GREEN}${expected}${RESET}`);
332
+ lines.push("");
333
+ lines.push(`${RED}Actual content (first 20 lines):${RESET}`);
334
+ for (const line of actual.split("\n").slice(0, 20)) lines.push(` ${DIM}${line}${RESET}`);
335
+ return lines.join("\n");
336
+ }
282
337
  function rowLabel(n) {
283
338
  return n === 1 ? "1 row" : `${n} rows`;
284
339
  }
@@ -4842,6 +4897,46 @@ var Orchestrator = class {
4842
4897
  }
4843
4898
  };
4844
4899
  //#endregion
4900
+ //#region src/specification/adapters/exec.adapter.ts
4901
+ /**
4902
+ * Executes CLI commands via execSync.
4903
+ * Used by cli() for local command execution.
4904
+ */
4905
+ var ExecAdapter = class {
4906
+ command;
4907
+ constructor(command) {
4908
+ this.command = command;
4909
+ }
4910
+ async exec(args, cwd) {
4911
+ const env = {
4912
+ ...process.env,
4913
+ INIT_CWD: void 0
4914
+ };
4915
+ try {
4916
+ return {
4917
+ exitCode: 0,
4918
+ stdout: execSync(`${this.command} ${args}`, {
4919
+ cwd,
4920
+ encoding: "utf8",
4921
+ env,
4922
+ stdio: [
4923
+ "pipe",
4924
+ "pipe",
4925
+ "pipe"
4926
+ ]
4927
+ }),
4928
+ stderr: ""
4929
+ };
4930
+ } catch (error) {
4931
+ return {
4932
+ exitCode: error.status ?? 1,
4933
+ stdout: error.stdout?.toString() ?? "",
4934
+ stderr: error.stderr?.toString() ?? ""
4935
+ };
4936
+ }
4937
+ }
4938
+ };
4939
+ //#endregion
4845
4940
  //#region src/specification/adapters/fetch.adapter.ts
4846
4941
  /**
4847
4942
  * Server adapter for real HTTP — sends actual fetch requests.
@@ -4904,25 +4999,60 @@ var HonoAdapter = class {
4904
4999
  //#endregion
4905
5000
  //#region src/specification/specification.ts
4906
5001
  var SpecificationResult = class {
4907
- response;
5002
+ commandResult;
4908
5003
  config;
4909
- testDir;
4910
5004
  requestInfo;
4911
- constructor(response, config, testDir, requestInfo) {
4912
- this.response = response;
4913
- this.config = config;
4914
- this.testDir = testDir;
4915
- this.requestInfo = requestInfo;
5005
+ response;
5006
+ testDir;
5007
+ workDir;
5008
+ constructor(options) {
5009
+ this.response = options.response;
5010
+ this.commandResult = options.commandResult;
5011
+ this.config = options.config;
5012
+ this.testDir = options.testDir;
5013
+ this.requestInfo = options.requestInfo;
5014
+ this.workDir = options.workDir;
4916
5015
  }
4917
5016
  expectStatus(code) {
5017
+ if (!this.response || !this.requestInfo) throw new Error("expectStatus requires an HTTP action (.get(), .post(), etc.)");
4918
5018
  if (this.response.status !== code) throw new Error(formatStatusError(code, this.response.status, this.requestInfo, this.response.body));
4919
5019
  return this;
4920
5020
  }
4921
5021
  expectResponse(file) {
5022
+ if (!this.response) throw new Error("expectResponse requires an HTTP action (.get(), .post(), etc.)");
4922
5023
  const expected = JSON.parse(readFileSync(resolve(this.testDir, "responses", file), "utf8"));
4923
5024
  if (JSON.stringify(this.response.body) !== JSON.stringify(expected)) throw new Error(formatResponseDiff(file, expected, this.response.body));
4924
5025
  return this;
4925
5026
  }
5027
+ expectExitCode(code) {
5028
+ if (!this.commandResult) throw new Error("expectExitCode requires a CLI action (.exec())");
5029
+ if (this.commandResult.exitCode !== code) throw new Error(formatExitCodeError(code, this.commandResult.exitCode, this.commandResult.stdout, this.commandResult.stderr));
5030
+ return this;
5031
+ }
5032
+ expectStdout(file) {
5033
+ if (!this.commandResult) throw new Error("expectStdout requires a CLI action (.exec())");
5034
+ const expected = readFileSync(resolve(this.testDir, "expected", file), "utf8").trim();
5035
+ const actual = this.commandResult.stdout.trim();
5036
+ if (actual !== expected) throw new Error(formatStdoutDiff(file, expected, actual));
5037
+ return this;
5038
+ }
5039
+ expectStdoutContains(str) {
5040
+ if (!this.commandResult) throw new Error("expectStdoutContains requires a CLI action (.exec())");
5041
+ if (!this.commandResult.stdout.includes(str)) throw new Error(`Expected stdout to contain: "${str}"\n\nActual stdout:\n${this.commandResult.stdout}`);
5042
+ return this;
5043
+ }
5044
+ expectStderr(file) {
5045
+ if (!this.commandResult) throw new Error("expectStderr requires a CLI action (.exec())");
5046
+ const expected = readFileSync(resolve(this.testDir, "expected", file), "utf8").trim();
5047
+ const actual = this.commandResult.stderr.trim();
5048
+ if (actual !== expected) throw new Error(formatStdoutDiff(file, expected, actual));
5049
+ return this;
5050
+ }
5051
+ expectStderrContains(str) {
5052
+ if (!this.commandResult) throw new Error("expectStderrContains requires a CLI action (.exec())");
5053
+ if (!this.commandResult.stderr.includes(str)) throw new Error(`Expected stderr to contain: "${str}"\n\nActual stderr:\n${this.commandResult.stderr}`);
5054
+ return this;
5055
+ }
4926
5056
  async expectTable(table, options) {
4927
5057
  const db = this.resolveDatabase(options.service);
4928
5058
  if (!db) throw new Error(options.service ? `expectTable requires database "${options.service}" but it was not found` : "expectTable requires a database adapter");
@@ -4930,18 +5060,40 @@ var SpecificationResult = class {
4930
5060
  if (JSON.stringify(actual) !== JSON.stringify(options.rows)) throw new Error(formatTableDiff(table, options.columns, options.rows, actual));
4931
5061
  return this;
4932
5062
  }
5063
+ expectFile(path) {
5064
+ if (!existsSync(this.resolveWorkPath(path))) throw new Error(formatFileMissing(path));
5065
+ return this;
5066
+ }
5067
+ expectNoFile(path) {
5068
+ if (existsSync(this.resolveWorkPath(path))) throw new Error(formatFileUnexpected(path));
5069
+ return this;
5070
+ }
5071
+ expectFileContains(path, content) {
5072
+ const resolved = this.resolveWorkPath(path);
5073
+ if (!existsSync(resolved)) throw new Error(formatFileMissing(path));
5074
+ const actual = readFileSync(resolved, "utf8");
5075
+ if (!actual.includes(content)) throw new Error(formatFileContentMismatch(path, content, actual));
5076
+ return this;
5077
+ }
4933
5078
  resolveDatabase(serviceName) {
4934
5079
  if (serviceName && this.config.databases) return this.config.databases.get(serviceName);
4935
5080
  return this.config.database;
4936
5081
  }
5082
+ resolveWorkPath(path) {
5083
+ if (this.workDir) return resolve(this.workDir, path);
5084
+ return resolve(this.testDir, path);
5085
+ }
4937
5086
  };
4938
5087
  var SpecificationBuilder = class {
5088
+ commandArgs = null;
4939
5089
  config;
4940
- testDir;
5090
+ fixtures = [];
4941
5091
  label;
4942
- seeds = [];
4943
5092
  mocks = [];
5093
+ projectName = null;
4944
5094
  request = null;
5095
+ seeds = [];
5096
+ testDir;
4945
5097
  constructor(config, testDir, label) {
4946
5098
  this.config = config;
4947
5099
  this.testDir = testDir;
@@ -4954,6 +5106,14 @@ var SpecificationBuilder = class {
4954
5106
  });
4955
5107
  return this;
4956
5108
  }
5109
+ fixture(file) {
5110
+ this.fixtures.push({ file });
5111
+ return this;
5112
+ }
5113
+ project(name) {
5114
+ this.projectName = name;
5115
+ return this;
5116
+ }
4957
5117
  mock(file) {
4958
5118
  this.mocks.push({ file });
4959
5119
  return this;
@@ -4967,17 +5127,17 @@ var SpecificationBuilder = class {
4967
5127
  }
4968
5128
  post(path, bodyFile) {
4969
5129
  this.request = {
5130
+ bodyFile,
4970
5131
  method: "POST",
4971
- path,
4972
- bodyFile
5132
+ path
4973
5133
  };
4974
5134
  return this;
4975
5135
  }
4976
5136
  put(path, bodyFile) {
4977
5137
  this.request = {
5138
+ bodyFile,
4978
5139
  method: "PUT",
4979
- path,
4980
- bodyFile
5140
+ path
4981
5141
  };
4982
5142
  return this;
4983
5143
  }
@@ -4988,8 +5148,17 @@ var SpecificationBuilder = class {
4988
5148
  };
4989
5149
  return this;
4990
5150
  }
5151
+ exec(args) {
5152
+ this.commandArgs = args;
5153
+ return this;
5154
+ }
4991
5155
  async run() {
4992
- if (!this.request) throw new Error(`Specification "${this.label}": no request defined. Call .get(), .post(), etc. before .run()`);
5156
+ const hasHttpAction = this.request !== null;
5157
+ const hasCliAction = this.commandArgs !== null;
5158
+ if (!hasHttpAction && !hasCliAction) throw new Error(`Specification "${this.label}": no action defined. Call .get(), .post(), .exec(), etc. before .run()`);
5159
+ if (hasHttpAction && hasCliAction) throw new Error(`Specification "${this.label}": cannot mix HTTP (.get/.post) and CLI (.exec) actions`);
5160
+ let workDir = null;
5161
+ if (hasCliAction) workDir = this.prepareWorkDir();
4993
5162
  if (this.config.databases) for (const db of this.config.databases.values()) await db.reset();
4994
5163
  else if (this.config.database) await this.config.database.reset();
4995
5164
  for (const entry of this.seeds) {
@@ -5002,13 +5171,43 @@ var SpecificationBuilder = class {
5002
5171
  const sql = readFileSync(resolve(this.testDir, "seeds", entry.file), "utf8");
5003
5172
  await db.seed(sql);
5004
5173
  }
5174
+ if (this.fixtures.length > 0 && workDir) for (const entry of this.fixtures) cpSync(resolve(this.testDir, "fixtures", entry.file), resolve(workDir, entry.file), { recursive: true });
5005
5175
  for (const entry of this.mocks) JSON.parse(readFileSync(resolve(this.testDir, "mock", entry.file), "utf8"));
5176
+ if (hasHttpAction) return this.runHttpAction();
5177
+ return this.runCliAction(workDir);
5178
+ }
5179
+ prepareWorkDir() {
5180
+ const tempDir = mkdtempSync(resolve(tmpdir(), "spec-cli-"));
5181
+ if (this.projectName && this.config.fixturesRoot) {
5182
+ const projectDir = resolve(this.config.fixturesRoot, this.projectName);
5183
+ if (!existsSync(projectDir)) throw new Error(`project("${this.projectName}"): fixture project not found at ${projectDir}`);
5184
+ cpSync(projectDir, tempDir, { recursive: true });
5185
+ }
5186
+ return tempDir;
5187
+ }
5188
+ async runHttpAction() {
5189
+ if (!this.config.server) throw new Error("HTTP actions require a server adapter (use integration() or e2e())");
5006
5190
  let body;
5007
5191
  if (this.request.bodyFile) body = JSON.parse(readFileSync(resolve(this.testDir, "requests", this.request.bodyFile), "utf8"));
5008
- return new SpecificationResult(await this.config.server.request(this.request.method, this.request.path, body), this.config, this.testDir, {
5009
- method: this.request.method,
5010
- path: this.request.path,
5011
- body
5192
+ const response = await this.config.server.request(this.request.method, this.request.path, body);
5193
+ return new SpecificationResult({
5194
+ config: this.config,
5195
+ requestInfo: {
5196
+ body,
5197
+ method: this.request.method,
5198
+ path: this.request.path
5199
+ },
5200
+ response,
5201
+ testDir: this.testDir
5202
+ });
5203
+ }
5204
+ async runCliAction(workDir) {
5205
+ if (!this.config.command) throw new Error("CLI actions require a command adapter (use cli())");
5206
+ return new SpecificationResult({
5207
+ commandResult: await this.config.command.exec(this.commandArgs, workDir),
5208
+ config: this.config,
5209
+ testDir: this.testDir,
5210
+ workDir
5012
5211
  });
5013
5212
  }
5014
5213
  };
@@ -5057,21 +5256,25 @@ function resolveProjectRoot(root) {
5057
5256
  return resolve(process.cwd(), root);
5058
5257
  }
5059
5258
  /**
5259
+ * Resolve a CLI command — checks node_modules/.bin, then treats as absolute/PATH.
5260
+ */
5261
+ function resolveCommand(command, root) {
5262
+ if (isAbsolute(command)) return command;
5263
+ const binPath = resolve(root, "node_modules/.bin", command);
5264
+ if (existsSync(binPath)) return binPath;
5265
+ const cwdBinPath = resolve(process.cwd(), "node_modules/.bin", command);
5266
+ if (existsSync(cwdBinPath)) return cwdBinPath;
5267
+ return command;
5268
+ }
5269
+ /**
5060
5270
  * Create an integration specification runner.
5061
5271
  * Starts infra containers via testcontainers, app runs in-process.
5062
- *
5063
- * @example
5064
- * const db = postgres({ compose: "db" });
5065
- * export const spec = await integration({
5066
- * services: [db],
5067
- * app: () => createApp({ databaseUrl: db.connectionString }),
5068
- * });
5069
5272
  */
5070
5273
  async function integration(options) {
5071
5274
  const orchestrator = new Orchestrator({
5072
- services: options.services,
5073
5275
  mode: "integration",
5074
- root: resolveProjectRoot(options.root)
5276
+ root: resolveProjectRoot(options.root),
5277
+ services: options.services
5075
5278
  });
5076
5279
  await orchestrator.start();
5077
5280
  const app = options.app();
@@ -5089,17 +5292,12 @@ async function integration(options) {
5089
5292
  /**
5090
5293
  * Create an E2E specification runner.
5091
5294
  * Starts full docker compose stack. App URL and database auto-detected.
5092
- *
5093
- * @example
5094
- * export const spec = await e2e({
5095
- * root: "../fixtures/app",
5096
- * });
5097
5295
  */
5098
5296
  async function e2e(options = {}) {
5099
5297
  const orchestrator = new Orchestrator({
5100
- services: [],
5101
5298
  mode: "e2e",
5102
- root: resolveProjectRoot(options.root)
5299
+ root: resolveProjectRoot(options.root),
5300
+ services: []
5103
5301
  });
5104
5302
  await orchestrator.startCompose();
5105
5303
  const appUrl = orchestrator.getAppUrl();
@@ -5115,7 +5313,46 @@ async function e2e(options = {}) {
5115
5313
  runner.orchestrator = orchestrator;
5116
5314
  return runner;
5117
5315
  }
5316
+ /**
5317
+ * Create a CLI specification runner.
5318
+ * Runs CLI commands against fixture projects. Optionally starts infrastructure.
5319
+ *
5320
+ * @example
5321
+ * export const spec = await cli({
5322
+ * command: resolve(import.meta.dirname, "../../bin/my-cli.sh"),
5323
+ * root: "../fixtures",
5324
+ * });
5325
+ */
5326
+ async function cli(options) {
5327
+ const root = resolveProjectRoot(options.root);
5328
+ const command = resolveCommand(options.command, root);
5329
+ let orchestrator = null;
5330
+ let database;
5331
+ let databases;
5332
+ if (options.services?.length) {
5333
+ orchestrator = new Orchestrator({
5334
+ mode: "integration",
5335
+ root,
5336
+ services: options.services
5337
+ });
5338
+ await orchestrator.start();
5339
+ database = orchestrator.getDatabase() ?? void 0;
5340
+ const dbMap = orchestrator.getDatabases();
5341
+ databases = dbMap.size > 0 ? dbMap : void 0;
5342
+ }
5343
+ const runner = createSpecificationRunner({
5344
+ command: new ExecAdapter(command),
5345
+ database,
5346
+ databases,
5347
+ fixturesRoot: root
5348
+ });
5349
+ runner.cleanup = async () => {
5350
+ if (orchestrator) await orchestrator.stop();
5351
+ };
5352
+ runner.orchestrator = orchestrator;
5353
+ return runner;
5354
+ }
5118
5355
  //#endregion
5119
- export { FetchAdapter, HonoAdapter, Orchestrator, e2e, integration, mockOf, mockOfDate, normalizeOutput, postgres, redis, stripAnsi };
5356
+ export { ExecAdapter, FetchAdapter, HonoAdapter, Orchestrator, cli, e2e, integration, mockOf, mockOfDate, normalizeOutput, postgres, redis, stripAnsi };
5120
5357
 
5121
5358
  //# sourceMappingURL=index.js.map