@elench/testkit 0.1.40 → 0.1.42
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 +27 -13
- package/bin/testkit.mjs +6 -1
- package/lib/cli/args.mjs +0 -4
- package/lib/cli/args.test.mjs +0 -5
- package/lib/cli/index.mjs +4 -11
- package/lib/config/index.mjs +78 -24
- package/lib/database/index.mjs +19 -7
- package/lib/database/naming.mjs +2 -2
- package/lib/database/naming.test.mjs +2 -2
- package/lib/runner/default-runtime-runner.mjs +52 -55
- package/lib/runner/execution-config.mjs +31 -70
- package/lib/runner/execution-config.test.mjs +30 -74
- package/lib/runner/formatting.mjs +0 -15
- package/lib/runner/formatting.test.mjs +0 -18
- package/lib/runner/lifecycle.mjs +106 -8
- package/lib/runner/orchestrator.mjs +16 -10
- package/lib/runner/planning.mjs +66 -138
- package/lib/runner/planning.test.mjs +101 -167
- package/lib/runner/playwright-config.mjs +13 -2
- package/lib/runner/playwright-config.test.mjs +26 -6
- package/lib/runner/playwright-runner.mjs +50 -56
- package/lib/runner/readiness.mjs +2 -2
- package/lib/runner/reporting.mjs +4 -3
- package/lib/runner/reporting.test.mjs +2 -5
- package/lib/runner/results.mjs +1 -1
- package/lib/runner/results.test.mjs +1 -1
- package/lib/runner/runtime-contexts.mjs +20 -24
- package/lib/runner/runtime-manager.mjs +228 -0
- package/lib/runner/runtime-manager.test.mjs +206 -0
- package/lib/runner/services.mjs +8 -6
- package/lib/runner/state.mjs +1 -2
- package/lib/runner/state.test.mjs +2 -4
- package/lib/runner/template.mjs +90 -60
- package/lib/runner/template.test.mjs +59 -27
- package/lib/runner/worker-loop.mjs +35 -32
- package/lib/setup/index.d.ts +15 -10
- package/package.json +1 -1
- package/lib/runner/stack-manager.mjs +0 -146
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
buildGraphDirName,
|
|
5
5
|
buildRuntimeGraphs,
|
|
6
6
|
buildTaskQueue,
|
|
7
|
-
|
|
7
|
+
claimNextTask,
|
|
8
8
|
collectSuites,
|
|
9
9
|
resolveRuntimeConfigs,
|
|
10
10
|
} from "./planning.mjs";
|
|
@@ -14,20 +14,23 @@ function makeConfig(name, extras = {}) {
|
|
|
14
14
|
return {
|
|
15
15
|
name,
|
|
16
16
|
suites: extras.suites || {},
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
stackCount: 1,
|
|
23
|
-
},
|
|
24
|
-
serviceExecution: providedTestkit.serviceExecution || {
|
|
25
|
-
suites: [],
|
|
26
|
-
files: [],
|
|
27
|
-
fileStackModeByPath: new Map(),
|
|
28
|
-
},
|
|
29
|
-
...providedTestkit,
|
|
17
|
+
testkit: {
|
|
18
|
+
dependsOn: extras.dependsOn || providedTestkit.dependsOn || [],
|
|
19
|
+
execution: providedTestkit.execution || {
|
|
20
|
+
workers: 1,
|
|
21
|
+
fileTimeoutSeconds: 60,
|
|
30
22
|
},
|
|
23
|
+
runtime: providedTestkit.runtime || {
|
|
24
|
+
instances: 1,
|
|
25
|
+
maxConcurrentTasks: Number.POSITIVE_INFINITY,
|
|
26
|
+
},
|
|
27
|
+
requirements: providedTestkit.requirements || {
|
|
28
|
+
suites: [],
|
|
29
|
+
files: [],
|
|
30
|
+
fileLocksByPath: new Map(),
|
|
31
|
+
},
|
|
32
|
+
...providedTestkit,
|
|
33
|
+
},
|
|
31
34
|
...extras,
|
|
32
35
|
};
|
|
33
36
|
}
|
|
@@ -67,17 +70,6 @@ describe("runner-planning", () => {
|
|
|
67
70
|
},
|
|
68
71
|
],
|
|
69
72
|
},
|
|
70
|
-
testkit: {
|
|
71
|
-
dependsOn: [],
|
|
72
|
-
execution: {
|
|
73
|
-
workers: 1,
|
|
74
|
-
stackMode: "shared",
|
|
75
|
-
stackCount: 1,
|
|
76
|
-
},
|
|
77
|
-
serviceExecution: {
|
|
78
|
-
suites: [],
|
|
79
|
-
},
|
|
80
|
-
},
|
|
81
73
|
});
|
|
82
74
|
|
|
83
75
|
expect(collectSuites(config, ["int"], [], [])[0]).toMatchObject({
|
|
@@ -122,15 +114,6 @@ describe("runner-planning", () => {
|
|
|
122
114
|
],
|
|
123
115
|
},
|
|
124
116
|
testkit: {
|
|
125
|
-
dependsOn: [],
|
|
126
|
-
execution: {
|
|
127
|
-
workers: 1,
|
|
128
|
-
stackMode: "shared",
|
|
129
|
-
stackCount: 1,
|
|
130
|
-
},
|
|
131
|
-
serviceExecution: {
|
|
132
|
-
suites: [],
|
|
133
|
-
},
|
|
134
117
|
skip: {
|
|
135
118
|
fileReasonByPath: new Map([
|
|
136
119
|
["__testkit__/billing/a.int.testkit.ts", "Billing is stubbed"],
|
|
@@ -153,21 +136,9 @@ describe("runner-planning", () => {
|
|
|
153
136
|
totalFileCount: 2,
|
|
154
137
|
}),
|
|
155
138
|
]);
|
|
156
|
-
|
|
157
|
-
expect(collectSuites(config, ["int"], [], [], { ignoreSkipRules: true })).toEqual([
|
|
158
|
-
expect.objectContaining({
|
|
159
|
-
name: "billing",
|
|
160
|
-
files: [
|
|
161
|
-
"__testkit__/billing/a.int.testkit.ts",
|
|
162
|
-
"__testkit__/billing/b.int.testkit.ts",
|
|
163
|
-
],
|
|
164
|
-
skippedFiles: [],
|
|
165
|
-
totalFileCount: 2,
|
|
166
|
-
}),
|
|
167
|
-
]);
|
|
168
139
|
});
|
|
169
140
|
|
|
170
|
-
it("applies
|
|
141
|
+
it("applies lock requirements to matching suites and files", () => {
|
|
171
142
|
const api = makeConfig("api", {
|
|
172
143
|
suites: {
|
|
173
144
|
integration: [
|
|
@@ -180,28 +151,31 @@ describe("runner-planning", () => {
|
|
|
180
151
|
],
|
|
181
152
|
orderIndex: 0,
|
|
182
153
|
weight: 2,
|
|
183
|
-
maxFileConcurrency: 1,
|
|
184
154
|
},
|
|
185
155
|
],
|
|
186
156
|
},
|
|
187
157
|
testkit: {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
stackCount: 1,
|
|
158
|
+
runtime: {
|
|
159
|
+
instances: 3,
|
|
160
|
+
maxConcurrentTasks: 2,
|
|
192
161
|
},
|
|
193
|
-
|
|
194
|
-
suites: [
|
|
162
|
+
requirements: {
|
|
163
|
+
suites: [
|
|
164
|
+
{
|
|
165
|
+
selector: { kind: "typed", type: "int", name: "routes", raw: "int:routes" },
|
|
166
|
+
locks: ["suite-lock"],
|
|
167
|
+
},
|
|
168
|
+
],
|
|
195
169
|
files: [
|
|
196
170
|
{
|
|
197
171
|
path: "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
|
|
198
|
-
|
|
172
|
+
locks: ["worker-loop"],
|
|
199
173
|
},
|
|
200
174
|
],
|
|
201
|
-
|
|
175
|
+
fileLocksByPath: new Map([
|
|
202
176
|
[
|
|
203
177
|
"src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
|
|
204
|
-
"
|
|
178
|
+
["worker-loop"],
|
|
205
179
|
],
|
|
206
180
|
]),
|
|
207
181
|
},
|
|
@@ -239,21 +213,35 @@ describe("runner-planning", () => {
|
|
|
239
213
|
expect.arrayContaining([
|
|
240
214
|
expect.objectContaining({
|
|
241
215
|
file: "src/api/routes/__testkit__/health.int.testkit.ts",
|
|
242
|
-
|
|
243
|
-
accessMode: "shared",
|
|
216
|
+
locks: ["suite-lock"],
|
|
244
217
|
}),
|
|
245
218
|
expect.objectContaining({
|
|
246
219
|
file: "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
|
|
247
|
-
|
|
248
|
-
accessMode: "exclusive",
|
|
220
|
+
locks: ["suite-lock", "worker-loop"],
|
|
249
221
|
}),
|
|
250
222
|
])
|
|
251
223
|
);
|
|
252
224
|
});
|
|
253
225
|
|
|
254
|
-
it("
|
|
255
|
-
const api = makeConfig("api"
|
|
256
|
-
|
|
226
|
+
it("builds exact runtime graphs and claims single file tasks", () => {
|
|
227
|
+
const api = makeConfig("api", {
|
|
228
|
+
testkit: {
|
|
229
|
+
runtime: {
|
|
230
|
+
instances: 2,
|
|
231
|
+
maxConcurrentTasks: 4,
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
const frontend = makeConfig("frontend", {
|
|
236
|
+
dependsOn: ["api"],
|
|
237
|
+
testkit: {
|
|
238
|
+
runtime: {
|
|
239
|
+
instances: 1,
|
|
240
|
+
maxConcurrentTasks: 2,
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
257
245
|
const plans = [
|
|
258
246
|
{
|
|
259
247
|
config: api,
|
|
@@ -269,9 +257,6 @@ describe("runner-planning", () => {
|
|
|
269
257
|
files: ["a.js", "b.js"],
|
|
270
258
|
orderIndex: 0,
|
|
271
259
|
weight: 2,
|
|
272
|
-
maxFileConcurrency: 2,
|
|
273
|
-
stackMode: "isolated",
|
|
274
|
-
accessMode: "exclusive",
|
|
275
260
|
},
|
|
276
261
|
],
|
|
277
262
|
},
|
|
@@ -286,12 +271,9 @@ describe("runner-planning", () => {
|
|
|
286
271
|
name: "auth",
|
|
287
272
|
type: "e2e",
|
|
288
273
|
framework: "playwright",
|
|
289
|
-
files: ["auth.spec.js"
|
|
274
|
+
files: ["auth.spec.js"],
|
|
290
275
|
orderIndex: 0,
|
|
291
|
-
weight:
|
|
292
|
-
maxFileConcurrency: 1,
|
|
293
|
-
stackMode: "isolated",
|
|
294
|
-
accessMode: "exclusive",
|
|
276
|
+
weight: 1,
|
|
295
277
|
},
|
|
296
278
|
],
|
|
297
279
|
},
|
|
@@ -300,112 +282,64 @@ describe("runner-planning", () => {
|
|
|
300
282
|
expect(applyShard(["a", "b", "c", "d"], { index: 2, total: 2 })).toEqual(["b", "d"]);
|
|
301
283
|
|
|
302
284
|
const graphs = buildRuntimeGraphs(plans);
|
|
303
|
-
expect(graphs).
|
|
304
|
-
|
|
285
|
+
expect(graphs).toEqual([
|
|
286
|
+
expect.objectContaining({
|
|
287
|
+
key: "api",
|
|
288
|
+
instanceCount: 2,
|
|
289
|
+
maxConcurrentTasks: 4,
|
|
290
|
+
targetNames: ["api"],
|
|
291
|
+
}),
|
|
292
|
+
expect.objectContaining({
|
|
293
|
+
key: "api|frontend",
|
|
294
|
+
instanceCount: 1,
|
|
295
|
+
maxConcurrentTasks: 2,
|
|
296
|
+
targetNames: ["frontend"],
|
|
297
|
+
}),
|
|
298
|
+
]);
|
|
305
299
|
expect(buildGraphDirName(["api", "frontend"])).toBe("api__frontend");
|
|
306
300
|
|
|
307
301
|
const queue = buildTaskQueue(plans, graphs, { files: {} });
|
|
308
|
-
expect(queue).toHaveLength(
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const thirdBatch = claimNextBatch(queue, "api|frontend");
|
|
320
|
-
expect(thirdBatch.tasks).toHaveLength(2);
|
|
321
|
-
expect(thirdBatch.framework).toBe("k6");
|
|
302
|
+
expect(queue).toHaveLength(3);
|
|
303
|
+
expect(claimNextTask(queue, "api|frontend")).toMatchObject({
|
|
304
|
+
framework: "playwright",
|
|
305
|
+
graphKey: "api|frontend",
|
|
306
|
+
file: "auth.spec.js",
|
|
307
|
+
});
|
|
308
|
+
expect(claimNextTask(queue, "api")).toMatchObject({
|
|
309
|
+
framework: "k6",
|
|
310
|
+
graphKey: "api",
|
|
311
|
+
});
|
|
322
312
|
});
|
|
323
313
|
|
|
324
|
-
it("
|
|
325
|
-
const
|
|
326
|
-
const plans = [
|
|
314
|
+
it("skips blocked preferred-graph work and claims the next runnable task", () => {
|
|
315
|
+
const queue = [
|
|
327
316
|
{
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
runtimeNames: ["frontend"],
|
|
332
|
-
runtimeKey: "frontend",
|
|
333
|
-
suites: [
|
|
334
|
-
{
|
|
335
|
-
name: "auth",
|
|
336
|
-
type: "e2e",
|
|
337
|
-
framework: "playwright",
|
|
338
|
-
files: ["a.spec.js", "b.spec.js", "c.spec.js"],
|
|
339
|
-
orderIndex: 0,
|
|
340
|
-
weight: 3,
|
|
341
|
-
maxFileConcurrency: 2,
|
|
342
|
-
stackMode: "isolated",
|
|
343
|
-
accessMode: "exclusive",
|
|
344
|
-
},
|
|
345
|
-
],
|
|
346
|
-
},
|
|
347
|
-
];
|
|
348
|
-
|
|
349
|
-
const graphs = buildRuntimeGraphs(plans);
|
|
350
|
-
const queue = buildTaskQueue(plans, graphs, { files: {} });
|
|
351
|
-
|
|
352
|
-
const firstBatch = claimNextBatch(queue, "frontend");
|
|
353
|
-
expect(firstBatch.tasks.map((task) => task.file)).toEqual(["a.spec.js", "b.spec.js"]);
|
|
354
|
-
expect(firstBatch.tasks).toHaveLength(2);
|
|
355
|
-
expect(firstBatch.framework).toBe("playwright");
|
|
356
|
-
|
|
357
|
-
const secondBatch = claimNextBatch(queue, "frontend");
|
|
358
|
-
expect(secondBatch.tasks).toHaveLength(1);
|
|
359
|
-
expect(secondBatch.framework).toBe("playwright");
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
it("applies typed suite execution rules from config", () => {
|
|
363
|
-
const config = makeConfig("api", {
|
|
364
|
-
suites: {
|
|
365
|
-
integration: [{ name: "health", files: ["__testkit__/health/health.int.testkit.ts"] }],
|
|
366
|
-
},
|
|
367
|
-
testkit: {
|
|
368
|
-
dependsOn: [],
|
|
369
|
-
execution: {
|
|
370
|
-
workers: 8,
|
|
371
|
-
stackMode: "shared",
|
|
372
|
-
stackCount: 1,
|
|
373
|
-
},
|
|
374
|
-
serviceExecution: {
|
|
375
|
-
suites: [{ selector: { kind: "typed", type: "int", name: "health", raw: "int:health" }, stackMode: "isolated" }],
|
|
376
|
-
},
|
|
317
|
+
id: 1,
|
|
318
|
+
graphKey: "api",
|
|
319
|
+
file: "blocked.js",
|
|
377
320
|
},
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
const graphs = buildRuntimeGraphs([
|
|
381
321
|
{
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
runtimeNames: ["api"],
|
|
386
|
-
runtimeKey: "api",
|
|
387
|
-
suites: collectSuites(config, ["int"], [], []),
|
|
322
|
+
id: 2,
|
|
323
|
+
graphKey: "api|frontend",
|
|
324
|
+
file: "runnable.js",
|
|
388
325
|
},
|
|
389
|
-
]
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
runtimeConfigs: [config],
|
|
396
|
-
runtimeNames: ["api"],
|
|
397
|
-
runtimeKey: "api",
|
|
398
|
-
assignedGraphKey: "api",
|
|
399
|
-
suites: collectSuites(config, ["int"], [], []),
|
|
400
|
-
},
|
|
401
|
-
],
|
|
402
|
-
graphs,
|
|
403
|
-
{ files: {} }
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
const claimed = claimNextTask(
|
|
329
|
+
queue,
|
|
330
|
+
"api",
|
|
331
|
+
(task) => task.file === "runnable.js"
|
|
404
332
|
);
|
|
405
333
|
|
|
406
|
-
expect(
|
|
407
|
-
|
|
408
|
-
|
|
334
|
+
expect(claimed).toMatchObject({
|
|
335
|
+
id: 2,
|
|
336
|
+
file: "runnable.js",
|
|
409
337
|
});
|
|
338
|
+
expect(queue).toEqual([
|
|
339
|
+
expect.objectContaining({
|
|
340
|
+
id: 1,
|
|
341
|
+
file: "blocked.js",
|
|
342
|
+
}),
|
|
343
|
+
]);
|
|
410
344
|
});
|
|
411
345
|
});
|
|
@@ -3,8 +3,14 @@ import path from "path";
|
|
|
3
3
|
import { pathToFileURL } from "url";
|
|
4
4
|
import { normalizePathSeparators } from "./state.mjs";
|
|
5
5
|
|
|
6
|
-
export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles) {
|
|
7
|
-
|
|
6
|
+
export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, lease) {
|
|
7
|
+
if (!lease?.leaseDir) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
`Playwright task for service "${targetConfig.name}" requires a lease-scoped directory`
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const stateDir = lease.leaseDir;
|
|
8
14
|
const outputDir = resolvePlaywrightOutputDir(stateDir);
|
|
9
15
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
10
16
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
@@ -22,6 +28,9 @@ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles) {
|
|
|
22
28
|
` testDir: ${JSON.stringify(cwd)},\n` +
|
|
23
29
|
` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
|
|
24
30
|
` outputDir: ${JSON.stringify(outputDir)},\n` +
|
|
31
|
+
` workers: 1,\n` +
|
|
32
|
+
` fullyParallel: false,\n` +
|
|
33
|
+
` webServer: undefined,\n` +
|
|
25
34
|
`};\n`;
|
|
26
35
|
} else {
|
|
27
36
|
source =
|
|
@@ -29,6 +38,8 @@ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles) {
|
|
|
29
38
|
` testDir: ${JSON.stringify(cwd)},\n` +
|
|
30
39
|
` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
|
|
31
40
|
` outputDir: ${JSON.stringify(outputDir)},\n` +
|
|
41
|
+
` workers: 1,\n` +
|
|
42
|
+
` fullyParallel: false,\n` +
|
|
32
43
|
`};\n`;
|
|
33
44
|
}
|
|
34
45
|
|
|
@@ -24,29 +24,49 @@ function makeTempDir(prefix) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
describe("runner-playwright-config", () => {
|
|
27
|
-
it("uses a
|
|
27
|
+
it("uses a lease-local output directory under the lease dir", async () => {
|
|
28
28
|
const productDir = makeTempDir("testkit-playwright-product-");
|
|
29
|
-
const
|
|
29
|
+
const leaseDir = path.join(productDir, ".testkit", "leases", "lease-12");
|
|
30
30
|
const cwd = path.join(productDir, "frontend");
|
|
31
31
|
fs.mkdirSync(cwd, { recursive: true });
|
|
32
32
|
fs.writeFileSync(
|
|
33
33
|
path.join(cwd, "playwright.config.mjs"),
|
|
34
|
-
"export default { outputDir: 'shared-test-results' };\n"
|
|
34
|
+
"export default { outputDir: 'shared-test-results', workers: 8, fullyParallel: true, webServer: { command: 'npm run dev', url: 'http://127.0.0.1:3000' } };\n"
|
|
35
35
|
);
|
|
36
36
|
|
|
37
37
|
const configPath = ensurePlaywrightTestConfig(
|
|
38
|
-
{ productDir, stateDir },
|
|
38
|
+
{ name: "frontend", productDir, stateDir: path.join(productDir, ".testkit", "frontend") },
|
|
39
39
|
cwd,
|
|
40
|
-
["frontend/__testkit__/homepage/homepage.pw.testkit.ts"]
|
|
40
|
+
["frontend/__testkit__/homepage/homepage.pw.testkit.ts"],
|
|
41
|
+
{ leaseDir }
|
|
41
42
|
);
|
|
42
43
|
const generated = await import(pathToFileURL(configPath).href + `?t=${Date.now()}`);
|
|
43
44
|
|
|
44
|
-
const expectedOutputDir = resolvePlaywrightOutputDir(
|
|
45
|
+
const expectedOutputDir = resolvePlaywrightOutputDir(leaseDir);
|
|
45
46
|
expect(generated.default.outputDir).toBe(expectedOutputDir);
|
|
47
|
+
expect(generated.default.workers).toBe(1);
|
|
48
|
+
expect(generated.default.fullyParallel).toBe(false);
|
|
49
|
+
expect(generated.default.webServer).toBeUndefined();
|
|
46
50
|
expect(fs.existsSync(expectedOutputDir)).toBe(true);
|
|
47
51
|
expect(fs.readFileSync(configPath, "utf8")).toContain(
|
|
48
52
|
`outputDir: ${JSON.stringify(expectedOutputDir)}`
|
|
49
53
|
);
|
|
54
|
+
expect(fs.readFileSync(configPath, "utf8")).toContain("workers: 1");
|
|
55
|
+
expect(fs.readFileSync(configPath, "utf8")).toContain("fullyParallel: false");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("requires a lease-scoped directory", () => {
|
|
59
|
+
const productDir = makeTempDir("testkit-playwright-product-");
|
|
60
|
+
const cwd = path.join(productDir, "frontend");
|
|
61
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
62
|
+
|
|
63
|
+
expect(() =>
|
|
64
|
+
ensurePlaywrightTestConfig(
|
|
65
|
+
{ name: "frontend", productDir, stateDir: path.join(productDir, ".testkit", "frontend") },
|
|
66
|
+
cwd,
|
|
67
|
+
["frontend/__testkit__/homepage/homepage.pw.testkit.ts"]
|
|
68
|
+
)
|
|
69
|
+
).toThrow('Playwright task for service "frontend" requires a lease-scoped directory');
|
|
50
70
|
});
|
|
51
71
|
|
|
52
72
|
it("finds a supported playwright config file", () => {
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { execa } from "execa";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
} from "../
|
|
6
|
-
import {
|
|
7
|
-
import { formatPlaywrightBatchFiles } from "./formatting.mjs";
|
|
8
|
-
import { printBufferedOutput } from "./processes.mjs";
|
|
3
|
+
import { parsePlaywrightJsonResults } from "../reporters/playwright.mjs";
|
|
4
|
+
import { resolveServiceCwd } from "../config/index.mjs";
|
|
5
|
+
import { formatFileTimeoutBudgetError } from "../shared/file-timeout.mjs";
|
|
6
|
+
import { settleSubprocess } from "./default-runtime-runner.mjs";
|
|
9
7
|
import { ensurePlaywrightTestConfig } from "./playwright-config.mjs";
|
|
10
|
-
import {
|
|
8
|
+
import { printBufferedOutput } from "./processes.mjs";
|
|
11
9
|
import { normalizePathSeparators } from "./state.mjs";
|
|
10
|
+
import { buildPlaywrightEnv } from "./template.mjs";
|
|
11
|
+
import { killChildProcess } from "./processes.mjs";
|
|
12
12
|
|
|
13
|
-
export async function
|
|
13
|
+
export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
|
|
14
14
|
const local = targetConfig.testkit.local;
|
|
15
15
|
if (!local?.baseUrl) {
|
|
16
16
|
throw new Error(
|
|
@@ -18,69 +18,63 @@ export async function runPlaywrightBatch(targetConfig, batch, lifecycle) {
|
|
|
18
18
|
);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
console.log(
|
|
22
|
-
`\n── ${targetConfig.stackLabel} playwright:${targetConfig.name} (${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"})${formatPlaywrightBatchFiles(batch)} ──`
|
|
23
|
-
);
|
|
24
|
-
|
|
25
21
|
const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
)
|
|
29
|
-
|
|
22
|
+
const requestedFile = path.relative(cwd, path.join(targetConfig.productDir, task.file));
|
|
23
|
+
const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, [requestedFile], lease);
|
|
24
|
+
if (lifecycle.isStopRequested()) {
|
|
25
|
+
throw new Error(`testkit run interrupted before starting ${task.file}`);
|
|
26
|
+
}
|
|
30
27
|
const startedAt = Date.now();
|
|
31
|
-
const
|
|
28
|
+
const fileTimeoutSeconds = targetConfig.testkit.execution.fileTimeoutSeconds;
|
|
29
|
+
const subprocess = execa(
|
|
32
30
|
"npx",
|
|
33
31
|
["playwright", "test", "--config", playwrightConfigPath, "--reporter=json"],
|
|
34
32
|
{
|
|
35
33
|
cwd,
|
|
36
|
-
env: buildPlaywrightEnv(targetConfig, local.baseUrl, process.env),
|
|
34
|
+
env: buildPlaywrightEnv(targetConfig, local.baseUrl, lease, process.env),
|
|
37
35
|
reject: false,
|
|
38
|
-
cancelSignal: lifecycle.signal,
|
|
39
36
|
forceKillAfterDelay: 5_000,
|
|
40
37
|
}
|
|
41
38
|
);
|
|
39
|
+
lifecycle.registerProcess(subprocess, () => {
|
|
40
|
+
killChildProcess(subprocess, "SIGINT");
|
|
41
|
+
});
|
|
42
|
+
if (lifecycle.isStopRequested()) {
|
|
43
|
+
const interruptSubprocess = () => killChildProcess(subprocess, "SIGINT");
|
|
44
|
+
if (subprocess.pid) interruptSubprocess();
|
|
45
|
+
else subprocess.once?.("spawn", interruptSubprocess);
|
|
46
|
+
}
|
|
47
|
+
console.log(`\n── ${targetConfig.runtimeLabel} playwright:${targetConfig.name} (${task.file}) ──`);
|
|
48
|
+
let result;
|
|
49
|
+
let timedOut;
|
|
50
|
+
try {
|
|
51
|
+
({ result, timedOut } = await settleSubprocess(subprocess, fileTimeoutSeconds));
|
|
52
|
+
} finally {
|
|
53
|
+
lifecycle.unregisterProcess(subprocess.pid);
|
|
54
|
+
}
|
|
42
55
|
|
|
43
56
|
if (result.stderr) {
|
|
44
|
-
printBufferedOutput(result.stderr, `[${targetConfig.
|
|
57
|
+
printBufferedOutput(result.stderr, `[${targetConfig.runtimeLabel}:${targetConfig.name}:playwright]`);
|
|
45
58
|
}
|
|
46
59
|
|
|
47
|
-
const parsed = parsePlaywrightJsonResults(result.stdout, cwd);
|
|
60
|
+
const parsed = parsePlaywrightJsonResults(result.stdout || "", cwd);
|
|
48
61
|
const finishedAt = Date.now();
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
62
|
+
const durationMs = finishedAt - startedAt;
|
|
63
|
+
const relativeFile = normalizePathSeparators(requestedFile);
|
|
64
|
+
const fileResult = parsed.fileResults.get(relativeFile);
|
|
65
|
+
const genericError = timedOut
|
|
66
|
+
? formatFileTimeoutBudgetError(fileTimeoutSeconds)
|
|
67
|
+
: result.exitCode === 0
|
|
52
68
|
? parsed.errors[0] || null
|
|
53
|
-
: parsed.errors[0] ||
|
|
54
|
-
result.stderr.trim() ||
|
|
55
|
-
`Playwright exited with code ${result.exitCode}`;
|
|
69
|
+
: parsed.errors[0] || result.stderr.trim() || `Playwright exited with code ${result.exitCode}`;
|
|
56
70
|
|
|
57
|
-
return
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
status: fileResult.status,
|
|
67
|
-
error: fileResult.error,
|
|
68
|
-
durationMs:
|
|
69
|
-
fileResult.durationMs > 0
|
|
70
|
-
? fileResult.durationMs
|
|
71
|
-
: Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
|
|
72
|
-
startedAt,
|
|
73
|
-
finishedAt,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return {
|
|
78
|
-
task,
|
|
79
|
-
failed: result.exitCode !== 0,
|
|
80
|
-
error: result.exitCode !== 0 ? genericError : null,
|
|
81
|
-
durationMs: Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
|
|
82
|
-
startedAt,
|
|
83
|
-
finishedAt,
|
|
84
|
-
};
|
|
85
|
-
});
|
|
71
|
+
return {
|
|
72
|
+
task,
|
|
73
|
+
failed: timedOut ? true : fileResult ? fileResult.status === "failed" : result.exitCode !== 0,
|
|
74
|
+
status: timedOut ? "failed" : fileResult?.status || (result.exitCode === 0 ? "passed" : "failed"),
|
|
75
|
+
error: timedOut ? genericError : fileResult?.error || genericError,
|
|
76
|
+
durationMs: fileResult?.durationMs > 0 ? fileResult.durationMs : durationMs,
|
|
77
|
+
startedAt,
|
|
78
|
+
finishedAt,
|
|
79
|
+
};
|
|
86
80
|
}
|
package/lib/runner/readiness.mjs
CHANGED
|
@@ -54,11 +54,11 @@ export async function assertLocalServicePortsAvailable(config, isPortInUse) {
|
|
|
54
54
|
const owner = findPortOwner(config.productDir, socket);
|
|
55
55
|
const ownerDetail = owner
|
|
56
56
|
? owner.active
|
|
57
|
-
? ` Active testkit run ${formatRunSummary(owner.manifest)} owns ${key} via ${owner.service.
|
|
57
|
+
? ` Active testkit run ${formatRunSummary(owner.manifest)} owns ${key} via ${owner.service.runtimeLabel}:${owner.service.serviceName}.`
|
|
58
58
|
: ` Stale testkit run ${formatRunSummary(owner.manifest)} owns ${key}.`
|
|
59
59
|
: "";
|
|
60
60
|
throw new Error(
|
|
61
|
-
`Cannot start "${config.
|
|
61
|
+
`Cannot start "${config.runtimeLabel}:${config.name}" because ${key} is already in use. ` +
|
|
62
62
|
`Stop the existing process and rerun testkit.${ownerDetail}`
|
|
63
63
|
);
|
|
64
64
|
}
|
package/lib/runner/reporting.mjs
CHANGED
|
@@ -96,7 +96,8 @@ export function buildRunArtifact({
|
|
|
96
96
|
finishedAt,
|
|
97
97
|
execution,
|
|
98
98
|
workerCount,
|
|
99
|
-
|
|
99
|
+
runtimeInstanceCount,
|
|
100
|
+
runtimeStats,
|
|
100
101
|
typeValues,
|
|
101
102
|
suiteSelectors,
|
|
102
103
|
fileNames,
|
|
@@ -137,8 +138,8 @@ export function buildRunArtifact({
|
|
|
137
138
|
workers: execution.workers,
|
|
138
139
|
fileTimeoutSeconds: execution.fileTimeoutSeconds,
|
|
139
140
|
workerCount,
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
runtimeInstanceCount,
|
|
142
|
+
runtimeStats: runtimeStats || [],
|
|
142
143
|
dbBackend,
|
|
143
144
|
types: typeValues,
|
|
144
145
|
suiteSelectors: suiteSelectors.map((selector) => selector.raw),
|
|
@@ -54,11 +54,9 @@ describe("runner reporting", () => {
|
|
|
54
54
|
execution: {
|
|
55
55
|
workers: 2,
|
|
56
56
|
fileTimeoutSeconds: 60,
|
|
57
|
-
stackMode: "shared",
|
|
58
|
-
stackCount: 1,
|
|
59
57
|
},
|
|
60
58
|
workerCount: 1,
|
|
61
|
-
|
|
59
|
+
runtimeInstanceCount: 2,
|
|
62
60
|
typeValues: ["all"],
|
|
63
61
|
suiteSelectors: [],
|
|
64
62
|
fileNames: [],
|
|
@@ -85,8 +83,7 @@ describe("runner reporting", () => {
|
|
|
85
83
|
workers: 2,
|
|
86
84
|
fileTimeoutSeconds: 60,
|
|
87
85
|
workerCount: 1,
|
|
88
|
-
|
|
89
|
-
stackCount: 1,
|
|
86
|
+
runtimeInstanceCount: 2,
|
|
90
87
|
});
|
|
91
88
|
expect(artifact.summary.services).toEqual({
|
|
92
89
|
total: 1,
|
package/lib/runner/results.mjs
CHANGED
|
@@ -142,7 +142,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
export function recordGraphError(trackers, graph, message, now = Date.now()) {
|
|
145
|
-
const targetNames = graph?.
|
|
145
|
+
const targetNames = graph?.targetNames || [];
|
|
146
146
|
for (const targetName of targetNames) {
|
|
147
147
|
const tracker = trackers.get(targetName);
|
|
148
148
|
if (tracker && !tracker.skipped) {
|