@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 +1 -1
- package/lib/config/index.mjs +3 -3
- package/lib/database/index.mjs +105 -12
- package/lib/database/index.test.mjs +95 -0
- package/lib/runner/processes.mjs +16 -2
- package/lib/runner/processes.test.mjs +21 -0
- package/lib/runner/results.mjs +2 -1
- package/lib/runner/results.test.mjs +61 -0
- package/lib/runner/runtime-manager.mjs +34 -0
- package/lib/runner/runtime-manager.test.mjs +46 -0
- package/lib/runner/worker-loop.mjs +21 -0
- package/lib/setup/index.mjs +5 -5
- package/lib/setup/index.test.mjs +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -119,7 +119,7 @@ export default defineTestkitSetup({
|
|
|
119
119
|
...nextService({
|
|
120
120
|
cwd: "frontend",
|
|
121
121
|
port: 3000,
|
|
122
|
-
start: "
|
|
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}",
|
package/lib/config/index.mjs
CHANGED
|
@@ -212,7 +212,7 @@ function inferLocalRuntime(productDir, cwd) {
|
|
|
212
212
|
if (detectNextApp(absoluteCwd)) {
|
|
213
213
|
return {
|
|
214
214
|
cwd,
|
|
215
|
-
start: "
|
|
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: "
|
|
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: "
|
|
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",
|
package/lib/database/index.mjs
CHANGED
|
@@ -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
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
+
});
|
package/lib/runner/processes.mjs
CHANGED
|
@@ -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(
|
|
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 ${
|
|
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
|
+
});
|
package/lib/runner/results.mjs
CHANGED
|
@@ -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;
|
package/lib/setup/index.mjs
CHANGED
|
@@ -85,7 +85,7 @@ export function nextService(options = {}) {
|
|
|
85
85
|
...service(options),
|
|
86
86
|
local: {
|
|
87
87
|
cwd,
|
|
88
|
-
start: options.start || "
|
|
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 ||
|
|
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 `
|
|
199
|
+
return `go run ${options.command}`;
|
|
200
200
|
}
|
|
201
201
|
if (options.entrypoint) {
|
|
202
|
-
return `
|
|
202
|
+
return `go run ${options.entrypoint}`;
|
|
203
203
|
}
|
|
204
|
-
return "
|
|
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
|
+
});
|