@elench/testkit 0.1.49 → 0.1.50

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
@@ -119,7 +119,7 @@ export default defineTestkitSetup({
119
119
  ...nextService({
120
120
  cwd: "frontend",
121
121
  port: 3000,
122
- start: "exec ./node_modules/.bin/next start --port {port}",
122
+ start: "./node_modules/.bin/next start --port {port}",
123
123
  env: {
124
124
  NEXT_DIST_DIR: "{prepareDir}/dist",
125
125
  NEXT_PUBLIC_API_URL: "{baseUrl:api}",
@@ -212,7 +212,7 @@ function inferLocalRuntime(productDir, cwd) {
212
212
  if (detectNextApp(absoluteCwd)) {
213
213
  return {
214
214
  cwd,
215
- start: "exec ./node_modules/.bin/next dev -p {port}",
215
+ start: "./node_modules/.bin/next dev -p {port}",
216
216
  port: 3000,
217
217
  baseUrl: "http://127.0.0.1:{port}",
218
218
  readyUrl: "http://127.0.0.1:{port}",
@@ -223,7 +223,7 @@ function inferLocalRuntime(productDir, cwd) {
223
223
  if (fs.existsSync(path.join(absoluteCwd, "cmd", "server"))) {
224
224
  return {
225
225
  cwd,
226
- start: "exec go run ./cmd/server",
226
+ start: "go run ./cmd/server",
227
227
  port: 3000,
228
228
  baseUrl: "http://127.0.0.1:{port}",
229
229
  readyUrl: "http://127.0.0.1:{port}/health",
@@ -234,7 +234,7 @@ function inferLocalRuntime(productDir, cwd) {
234
234
  if (fs.existsSync(path.join(absoluteCwd, "package.json")) && fs.existsSync(path.join(absoluteCwd, "src"))) {
235
235
  return {
236
236
  cwd,
237
- start: "exec ./node_modules/.bin/tsx watch src/index.ts",
237
+ start: "./node_modules/.bin/tsx watch src/index.ts",
238
238
  port: 3000,
239
239
  baseUrl: "http://127.0.0.1:{port}",
240
240
  readyUrl: "http://127.0.0.1:{port}/health",
@@ -33,6 +33,8 @@ const LOCAL_PASSWORD = "testkit";
33
33
  const LOCAL_ADMIN_DB = "postgres";
34
34
  const LOCAL_READY_TIMEOUT_MS = 60_000;
35
35
  const LOCAL_POLL_INTERVAL_MS = 1_000;
36
+ const LOCAL_DROP_DATABASE_TIMEOUT_MS = 15_000;
37
+ const LOCAL_DROP_DATABASE_POLL_INTERVAL_MS = 250;
36
38
 
37
39
  export async function prepareDatabaseRuntime(config) {
38
40
  const db = config.testkit.database;
@@ -398,18 +400,78 @@ async function cloneDatabaseFromTemplate(infra, dbName, templateDbName) {
398
400
  }
399
401
 
400
402
  async function dropDatabaseIfExists(infra, dbName) {
401
- await runAdminQuery(infra, [
402
- "-c",
403
- [
404
- `SELECT pg_terminate_backend(pid)`,
405
- `FROM pg_stat_activity`,
406
- `WHERE datname = '${escapeSqlLiteral(dbName)}' AND pid <> pg_backend_pid();`,
407
- ].join(" "),
408
- ]);
409
- await runAdminQuery(infra, [
410
- "-c",
411
- `DROP DATABASE IF EXISTS "${escapeIdentifier(dbName)}"`,
412
- ]);
403
+ await dropDatabaseWithForceOrDrain(infra, dbName);
404
+ }
405
+
406
+ export async function dropDatabaseWithForceOrDrain(infra, dbName, hooks = {}) {
407
+ const runAdminQueryFn = hooks.runAdminQuery || runAdminQuery;
408
+ const databaseExistsFn = hooks.databaseExists || databaseExists;
409
+ const sleepFn = hooks.sleep || sleep;
410
+
411
+ try {
412
+ await runAdminQueryFn(infra, [
413
+ "-c",
414
+ `DROP DATABASE IF EXISTS "${escapeIdentifier(dbName)}" WITH (FORCE)`,
415
+ ]);
416
+ return;
417
+ } catch (error) {
418
+ if (!isUnsupportedForceDropError(error)) {
419
+ throw error;
420
+ }
421
+ }
422
+
423
+ if (!(await databaseExistsFn(infra, dbName))) {
424
+ return;
425
+ }
426
+
427
+ let restoreConnections = false;
428
+ try {
429
+ await runAdminQueryFn(infra, [
430
+ "-c",
431
+ `ALTER DATABASE "${escapeIdentifier(dbName)}" WITH ALLOW_CONNECTIONS false`,
432
+ ]);
433
+ restoreConnections = true;
434
+ await waitForDatabaseConnectionsToDrain(infra, dbName, {
435
+ runAdminQuery: runAdminQueryFn,
436
+ sleep: sleepFn,
437
+ });
438
+ await runAdminQueryFn(infra, [
439
+ "-c",
440
+ `DROP DATABASE IF EXISTS "${escapeIdentifier(dbName)}"`,
441
+ ]);
442
+ restoreConnections = false;
443
+ } finally {
444
+ if (restoreConnections && (await databaseExistsFn(infra, dbName))) {
445
+ await runAdminQueryFn(infra, [
446
+ "-c",
447
+ `ALTER DATABASE "${escapeIdentifier(dbName)}" WITH ALLOW_CONNECTIONS true`,
448
+ ]).catch(() => {
449
+ // Best-effort restoration for failed fallback drops.
450
+ });
451
+ }
452
+ }
453
+ }
454
+
455
+ export async function waitForDatabaseConnectionsToDrain(infra, dbName, hooks = {}) {
456
+ const runAdminQueryFn = hooks.runAdminQuery || runAdminQuery;
457
+ const sleepFn = hooks.sleep || sleep;
458
+ const now = hooks.now || Date.now;
459
+ const timeoutMs = hooks.timeoutMs ?? LOCAL_DROP_DATABASE_TIMEOUT_MS;
460
+ const deadline = now() + timeoutMs;
461
+
462
+ while (true) {
463
+ await terminateDatabaseConnections(infra, dbName, runAdminQueryFn);
464
+ const remainingConnections = await countDatabaseConnections(infra, dbName, runAdminQueryFn);
465
+ if (remainingConnections === 0) {
466
+ return;
467
+ }
468
+ if (now() >= deadline) {
469
+ throw new Error(
470
+ `Timed out waiting for database "${dbName}" connections to close (${remainingConnections} remaining)`
471
+ );
472
+ }
473
+ await sleepFn(LOCAL_DROP_DATABASE_POLL_INTERVAL_MS);
474
+ }
413
475
  }
414
476
 
415
477
  async function runAdminQuery(infra, args) {
@@ -431,6 +493,37 @@ async function runAdminQuery(infra, args) {
431
493
  return stdout;
432
494
  }
433
495
 
496
+ async function terminateDatabaseConnections(infra, dbName, runAdminQueryFn) {
497
+ await runAdminQueryFn(infra, [
498
+ "-c",
499
+ [
500
+ "SELECT pg_terminate_backend(pid)",
501
+ "FROM pg_stat_activity",
502
+ `WHERE datname = '${escapeSqlLiteral(dbName)}' AND pid <> pg_backend_pid();`,
503
+ ].join(" "),
504
+ ]);
505
+ }
506
+
507
+ async function countDatabaseConnections(infra, dbName, runAdminQueryFn) {
508
+ const result = await runAdminQueryFn(infra, [
509
+ "-tAc",
510
+ [
511
+ "SELECT COUNT(*)",
512
+ "FROM pg_stat_activity",
513
+ `WHERE datname = '${escapeSqlLiteral(dbName)}' AND pid <> pg_backend_pid();`,
514
+ ].join(" "),
515
+ ]);
516
+ return Number.parseInt(result.trim(), 10) || 0;
517
+ }
518
+
519
+ function isUnsupportedForceDropError(error) {
520
+ const text = `${error?.shortMessage || ""}\n${error?.stderr || ""}\n${error?.stdout || ""}\n${error?.message || ""}`;
521
+ return (
522
+ text.includes('syntax error at or near "WITH"') ||
523
+ text.includes('option "force"')
524
+ );
525
+ }
526
+
434
527
  async function computeTemplateFingerprint(config) {
435
528
  return computeTemplateFingerprintModel(config);
436
529
  }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ dropDatabaseWithForceOrDrain,
4
+ waitForDatabaseConnectionsToDrain,
5
+ } from "./index.mjs";
6
+
7
+ describe("database lifecycle helpers", () => {
8
+ it("uses DROP DATABASE ... WITH (FORCE) when supported", async () => {
9
+ const calls = [];
10
+
11
+ await dropDatabaseWithForceOrDrain(
12
+ { containerName: "pg", password: "pw", user: "user" },
13
+ "demo",
14
+ {
15
+ async runAdminQuery(_infra, args) {
16
+ calls.push(args);
17
+ return "";
18
+ },
19
+ }
20
+ );
21
+
22
+ expect(calls).toEqual([["-c", 'DROP DATABASE IF EXISTS "demo" WITH (FORCE)']]);
23
+ });
24
+
25
+ it("falls back to draining connections when forced drop is unsupported", async () => {
26
+ const calls = [];
27
+ const counts = ["2", "0"];
28
+
29
+ await dropDatabaseWithForceOrDrain(
30
+ { containerName: "pg", password: "pw", user: "user" },
31
+ "demo",
32
+ {
33
+ async runAdminQuery(_infra, args) {
34
+ calls.push(args);
35
+ if (args[1] === 'DROP DATABASE IF EXISTS "demo" WITH (FORCE)') {
36
+ const error = new Error('syntax error at or near "WITH"');
37
+ error.stderr = 'ERROR: syntax error at or near "WITH"';
38
+ throw error;
39
+ }
40
+ if (args[0] === "-tAc") {
41
+ return counts.shift() || "0";
42
+ }
43
+ return "";
44
+ },
45
+ async databaseExists() {
46
+ return true;
47
+ },
48
+ async sleep() {},
49
+ }
50
+ );
51
+
52
+ expect(calls).toEqual([
53
+ ["-c", 'DROP DATABASE IF EXISTS "demo" WITH (FORCE)'],
54
+ ["-c", 'ALTER DATABASE "demo" WITH ALLOW_CONNECTIONS false'],
55
+ [
56
+ "-c",
57
+ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'demo' AND pid <> pg_backend_pid();",
58
+ ],
59
+ [
60
+ "-tAc",
61
+ "SELECT COUNT(*) FROM pg_stat_activity WHERE datname = 'demo' AND pid <> pg_backend_pid();",
62
+ ],
63
+ [
64
+ "-c",
65
+ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'demo' AND pid <> pg_backend_pid();",
66
+ ],
67
+ [
68
+ "-tAc",
69
+ "SELECT COUNT(*) FROM pg_stat_activity WHERE datname = 'demo' AND pid <> pg_backend_pid();",
70
+ ],
71
+ ["-c", 'DROP DATABASE IF EXISTS "demo"'],
72
+ ]);
73
+ });
74
+
75
+ it("times out clearly while waiting for lingering connections to drain", async () => {
76
+ let now = 0;
77
+ await expect(() =>
78
+ waitForDatabaseConnectionsToDrain(
79
+ { containerName: "pg", password: "pw", user: "user" },
80
+ "demo",
81
+ {
82
+ async runAdminQuery(_infra, args) {
83
+ if (args[0] === "-tAc") return "1";
84
+ return "";
85
+ },
86
+ async sleep() {
87
+ now += 10;
88
+ },
89
+ now: () => now,
90
+ timeoutMs: 20,
91
+ }
92
+ )
93
+ ).rejects.toThrow(/Timed out waiting for database "demo" connections to close/);
94
+ });
95
+ });
@@ -1,8 +1,22 @@
1
1
  import { spawn } from "child_process";
2
2
 
3
+ export function normalizeServiceStartCommand(command) {
4
+ if (typeof command !== "string") {
5
+ throw new Error("Service start command must be a string");
6
+ }
7
+
8
+ const trimmed = command.trim();
9
+ if (trimmed.length === 0) {
10
+ throw new Error("Service start command must not be empty");
11
+ }
12
+
13
+ return trimmed.replace(/^exec\s+/u, "");
14
+ }
15
+
3
16
  export function startDetachedCommand(command, cwd, env) {
17
+ const normalizedCommand = normalizeServiceStartCommand(command);
4
18
  if (process.platform === "win32") {
5
- return spawn(command, {
19
+ return spawn(normalizedCommand, {
6
20
  cwd,
7
21
  env,
8
22
  detached: true,
@@ -12,7 +26,7 @@ export function startDetachedCommand(command, cwd, env) {
12
26
  }
13
27
 
14
28
  const shell = process.env.SHELL || "/bin/sh";
15
- return spawn(shell, ["-lc", `exec ${command}`], {
29
+ return spawn(shell, ["-lc", `exec ${normalizedCommand}`], {
16
30
  cwd,
17
31
  env,
18
32
  detached: true,
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeServiceStartCommand } from "./processes.mjs";
3
+
4
+ describe("runner processes", () => {
5
+ it("strips a single leading exec prefix for backward compatibility", () => {
6
+ expect(normalizeServiceStartCommand("exec node server.mjs")).toBe("node server.mjs");
7
+ expect(normalizeServiceStartCommand(" exec ./node_modules/.bin/next dev -p {port} ")).toBe(
8
+ "./node_modules/.bin/next dev -p {port}"
9
+ );
10
+ });
11
+
12
+ it("leaves plain commands unchanged", () => {
13
+ expect(normalizeServiceStartCommand("node server.mjs")).toBe("node server.mjs");
14
+ });
15
+
16
+ it("rejects empty commands", () => {
17
+ expect(() => normalizeServiceStartCommand(" ")).toThrow(
18
+ /Service start command must not be empty/
19
+ );
20
+ });
21
+ });
@@ -114,7 +114,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
114
114
  const normalizedPath = normalizePathSeparators(task.file);
115
115
  const existingFileResult = suite.fileResultsByPath.get(normalizedPath);
116
116
  const status = normalizeOutcomeStatus(outcome);
117
- if (status !== "skipped") {
117
+ if (status !== "skipped" && status !== "not_run") {
118
118
  suite.completedFileCount += 1;
119
119
  }
120
120
  if (existingFileResult) {
@@ -291,6 +291,7 @@ function formatFrameworkForArtifact(framework) {
291
291
  }
292
292
 
293
293
  function normalizeOutcomeStatus(outcome) {
294
+ if (outcome?.status === "not_run") return "not_run";
294
295
  if (outcome?.status === "skipped") return "skipped";
295
296
  return outcome?.failed ? "failed" : "passed";
296
297
  }
@@ -307,6 +307,67 @@ describe("runner results", () => {
307
307
  ]);
308
308
  });
309
309
 
310
+ it("preserves not-run task outcomes in suite and service summaries", () => {
311
+ const trackers = buildServiceTrackers(
312
+ [
313
+ {
314
+ skipped: false,
315
+ config: {
316
+ name: "api",
317
+ testkit: {
318
+ database: {
319
+ selectedBackend: null,
320
+ },
321
+ },
322
+ },
323
+ suites: [
324
+ {
325
+ name: "health",
326
+ type: "integration",
327
+ framework: "k6",
328
+ files: ["tests/a.int.testkit.ts", "tests/b.int.testkit.ts"],
329
+ orderIndex: 0,
330
+ },
331
+ ],
332
+ },
333
+ ],
334
+ 1000
335
+ );
336
+
337
+ recordTaskOutcome(
338
+ trackers,
339
+ {
340
+ serviceName: "api",
341
+ suiteKey: "integration:health",
342
+ file: "tests/a.int.testkit.ts",
343
+ },
344
+ {
345
+ failed: false,
346
+ status: "not_run",
347
+ reason: "Graph initialization failed",
348
+ durationMs: 0,
349
+ error: null,
350
+ },
351
+ 1000
352
+ );
353
+
354
+ const result = finalizeServiceResult(trackers.get("api"), 1000, 1200);
355
+
356
+ expect(result.completedFileCount).toBe(0);
357
+ expect(result.passedFileCount).toBe(0);
358
+ expect(result.failedFileCount).toBe(0);
359
+ expect(result.skippedFileCount).toBe(0);
360
+ expect(result.notRunFileCount).toBe(2);
361
+ expect(result.suites[0].files).toContainEqual({
362
+ path: "tests/a.int.testkit.ts",
363
+ failed: false,
364
+ status: "not_run",
365
+ durationMs: 0,
366
+ error: null,
367
+ reason: "Graph initialization failed",
368
+ });
369
+ });
370
+
310
371
  it("summarizes mixed db backends", () => {
311
372
  expect(
312
373
  summarizeDbBackend([{ dbBackend: "local" }, { dbBackend: "neon" }])
@@ -1,6 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { buildRuntimeIds } from "./execution-config.mjs";
4
+ import { formatError } from "./formatting.mjs";
4
5
  import {
5
6
  cleanupRuntimeInstanceContext,
6
7
  createRuntimeInstanceContext,
@@ -11,6 +12,7 @@ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {}
11
12
  const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
12
13
  const pools = new Map();
13
14
  const locks = new Map();
15
+ const failedGraphs = new Map();
14
16
  let nextLeaseCounter = 1;
15
17
  const runtimeHooks = {
16
18
  cleanupRuntimeInstanceContext,
@@ -22,6 +24,9 @@ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {}
22
24
 
23
25
  return {
24
26
  canAcquire(task) {
27
+ if (failedGraphs.has(task.graphKey)) {
28
+ return false;
29
+ }
25
30
  const pool = getPool(pools, graphByKey, task, productDir, lifecycle);
26
31
  if (!locksAvailable(locks, task.locks || [])) {
27
32
  return false;
@@ -64,6 +69,9 @@ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {}
64
69
  releaseLocks(locks, task.locks || [], leaseId);
65
70
  cleanupLeaseDir({ leaseDir: path.join(slot.context?.runtimeDir || productDir, "leases", leaseId) });
66
71
  await drainRuntimeSlot(slot, lifecycle, runtimeHooks);
72
+ if (!lifecycle.isStopRequested()) {
73
+ markGraphFailed(failedGraphs, task.graphKey, formatError(error));
74
+ }
67
75
  throw error;
68
76
  }
69
77
  },
@@ -104,6 +112,27 @@ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {}
104
112
  }))
105
113
  .sort((left, right) => String(left.graphKey).localeCompare(String(right.graphKey)));
106
114
  },
115
+ drainFailedTasks(queue) {
116
+ const drained = [];
117
+ for (let index = queue.length - 1; index >= 0; index -= 1) {
118
+ const task = queue[index];
119
+ const message = failedGraphs.get(task.graphKey);
120
+ if (!message) continue;
121
+
122
+ queue.splice(index, 1);
123
+ drained.push({
124
+ task,
125
+ reason: `Not run because runtime graph failed to initialize: ${message}`,
126
+ message,
127
+ graph: graphByKey.get(task.graphKey) || {
128
+ key: task.graphKey,
129
+ targetNames: [task.targetName],
130
+ },
131
+ });
132
+ }
133
+
134
+ return drained.reverse();
135
+ },
107
136
  };
108
137
  }
109
138
 
@@ -218,6 +247,11 @@ function releaseLocks(lockMap, lockNames, leaseId) {
218
247
  }
219
248
  }
220
249
 
250
+ function markGraphFailed(failedGraphs, graphKey, message) {
251
+ if (!graphKey || failedGraphs.has(graphKey)) return;
252
+ failedGraphs.set(graphKey, message);
253
+ }
254
+
221
255
  function sleep(ms) {
222
256
  return new Promise((resolve) => setTimeout(resolve, ms));
223
257
  }
@@ -203,4 +203,50 @@ describe("runtime-manager", () => {
203
203
  await manager.release(leaseTwo);
204
204
  await manager.cleanupAll();
205
205
  });
206
+
207
+ it("blocks a graph after fatal runtime initialization failure and drains remaining tasks", async () => {
208
+ const productDir = makeTempDir("testkit-runtime-manager-");
209
+ const manager = createRuntimeManager({
210
+ productDir,
211
+ lifecycle: makeLifecycle(),
212
+ graphs: [
213
+ {
214
+ key: "api",
215
+ dirName: "api",
216
+ targetNames: ["api"],
217
+ instanceCount: 1,
218
+ maxConcurrentTasks: 1,
219
+ },
220
+ ],
221
+ hooks: {
222
+ ...makeHooks({ created: [], ready: [], cleaned: [] }),
223
+ async ensureRuntimeInstanceReady() {
224
+ throw new Error("prepare failed");
225
+ },
226
+ },
227
+ });
228
+
229
+ const firstTask = makeTask(1, { file: "tests/a.int.testkit.ts", targetName: "api" });
230
+ const secondTask = makeTask(2, { file: "tests/b.int.testkit.ts", targetName: "api" });
231
+
232
+ await expect(manager.acquire(firstTask)).rejects.toThrow(/prepare failed/);
233
+ expect(manager.canAcquire(secondTask)).toBe(false);
234
+
235
+ const queue = [secondTask];
236
+ expect(manager.drainFailedTasks(queue)).toEqual([
237
+ {
238
+ task: secondTask,
239
+ reason: "Not run because runtime graph failed to initialize: prepare failed",
240
+ message: "prepare failed",
241
+ graph: {
242
+ key: "api",
243
+ dirName: "api",
244
+ targetNames: ["api"],
245
+ instanceCount: 1,
246
+ maxConcurrentTasks: 1,
247
+ },
248
+ },
249
+ ]);
250
+ expect(queue).toEqual([]);
251
+ });
206
252
  });
@@ -36,6 +36,27 @@ export async function runWorker(
36
36
  runtimeManager.canAcquire(candidate)
37
37
  );
38
38
  if (!task) {
39
+ const blockedTasks = runtimeManager.drainFailedTasks(queue);
40
+ if (blockedTasks.length > 0) {
41
+ const now = Date.now();
42
+ const recordedGraphs = new Set();
43
+ for (const blocked of blockedTasks) {
44
+ recordTaskOutcome(trackers, blocked.task, {
45
+ failed: false,
46
+ status: "not_run",
47
+ reason: blocked.reason,
48
+ durationMs: 0,
49
+ startedAt: now,
50
+ finishedAt: now,
51
+ error: null,
52
+ });
53
+ if (!recordedGraphs.has(blocked.graph.key)) {
54
+ recordGraphError(trackers, blocked.graph, blocked.message, now);
55
+ recordedGraphs.add(blocked.graph.key);
56
+ }
57
+ }
58
+ continue;
59
+ }
39
60
  if (queue.length === 0) break;
40
61
  await new Promise((resolve) => setTimeout(resolve, 10));
41
62
  continue;
@@ -85,7 +85,7 @@ export function nextService(options = {}) {
85
85
  ...service(options),
86
86
  local: {
87
87
  cwd,
88
- start: options.start || "exec ./node_modules/.bin/next dev -p {port}",
88
+ start: options.start || "./node_modules/.bin/next dev -p {port}",
89
89
  port,
90
90
  baseUrl,
91
91
  readyUrl: options.readyUrl || baseUrl,
@@ -105,7 +105,7 @@ export function tsxService(options = {}) {
105
105
  ...service(options),
106
106
  local: {
107
107
  cwd,
108
- start: options.start || `exec ./node_modules/.bin/tsx watch ${entry}`,
108
+ start: options.start || `./node_modules/.bin/tsx watch ${entry}`,
109
109
  port,
110
110
  baseUrl,
111
111
  readyUrl: options.readyUrl || `${baseUrl}${options.readyPath || "/health"}`,
@@ -196,12 +196,12 @@ export {
196
196
 
197
197
  function defaultGoStartCommand(options) {
198
198
  if (options.command) {
199
- return `exec go run ${options.command}`;
199
+ return `go run ${options.command}`;
200
200
  }
201
201
  if (options.entrypoint) {
202
- return `exec go run ${options.entrypoint}`;
202
+ return `go run ${options.entrypoint}`;
203
203
  }
204
- return "exec go run ./cmd/server";
204
+ return "go run ./cmd/server";
205
205
  }
206
206
 
207
207
  function requiredNumber(value, label) {
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { goService, nextService, tsxService } from "./index.mjs";
3
+
4
+ describe("setup helpers", () => {
5
+ it("emits plain next start commands without an exec prefix", () => {
6
+ const config = nextService({ port: 3000 });
7
+
8
+ expect(config.local.start).toBe("./node_modules/.bin/next dev -p {port}");
9
+ });
10
+
11
+ it("emits plain tsx start commands without an exec prefix", () => {
12
+ const config = tsxService({ port: 3000, entry: "src/server.ts" });
13
+
14
+ expect(config.local.start).toBe("./node_modules/.bin/tsx watch src/server.ts");
15
+ });
16
+
17
+ it("emits plain go start commands without an exec prefix", () => {
18
+ expect(goService({ port: 3000 }).local.start).toBe("go run ./cmd/server");
19
+ expect(goService({ port: 3000, entrypoint: "./cmd/api" }).local.start).toBe(
20
+ "go run ./cmd/api"
21
+ );
22
+ expect(goService({ port: 3000, command: "./cmd/worker" }).local.start).toBe(
23
+ "go run ./cmd/worker"
24
+ );
25
+ });
26
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.49",
3
+ "version": "0.1.50",
4
4
  "description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
5
5
  "type": "module",
6
6
  "types": "./lib/index.d.ts",