@elench/testkit 0.1.29 → 0.1.31

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.
@@ -631,7 +631,7 @@ async function runPlaywrightBatch(targetConfig, batch) {
631
631
  }
632
632
 
633
633
  console.log(
634
- `\n── ${targetConfig.workerLabel} playwright:${targetConfig.name} (${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"}) ──`
634
+ `\n── ${targetConfig.workerLabel} playwright:${targetConfig.name} (${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"})${formatPlaywrightBatchFiles(batch)} ──`
635
635
  );
636
636
 
637
637
  const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
@@ -655,7 +655,8 @@ async function runPlaywrightBatch(targetConfig, batch) {
655
655
  }
656
656
 
657
657
  const parsed = parsePlaywrightJsonResults(result.stdout, cwd);
658
- const batchDurationMs = Date.now() - startedAt;
658
+ const finishedAt = Date.now();
659
+ const batchDurationMs = finishedAt - startedAt;
659
660
  const genericError =
660
661
  result.exitCode === 0
661
662
  ? parsed.errors[0] || null
@@ -677,6 +678,8 @@ async function runPlaywrightBatch(targetConfig, batch) {
677
678
  fileResult.durationMs > 0
678
679
  ? fileResult.durationMs
679
680
  : Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
681
+ startedAt,
682
+ finishedAt,
680
683
  };
681
684
  }
682
685
 
@@ -685,6 +688,8 @@ async function runPlaywrightBatch(targetConfig, batch) {
685
688
  failed: result.exitCode !== 0,
686
689
  error: result.exitCode !== 0 ? genericError : null,
687
690
  durationMs: Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
691
+ startedAt,
692
+ finishedAt,
688
693
  };
689
694
  });
690
695
  }
@@ -912,6 +917,15 @@ function formatBatchDescriptor(batch) {
912
917
  return frameworkLabel ? ` (${frameworkLabel}, ${fileLabel})` : ` (${fileLabel})`;
913
918
  }
914
919
 
920
+ function formatPlaywrightBatchFiles(batch) {
921
+ if (!batch?.tasks?.length) return "";
922
+ const files = batch.tasks.map((task) => task.file);
923
+ if (files.length === 1) return ` · ${files[0]}`;
924
+ const preview = files.slice(0, 3).join(", ");
925
+ const suffix = files.length > 3 ? `, +${files.length - 3} more` : "";
926
+ return ` · ${preview}${suffix}`;
927
+ }
928
+
915
929
  function formatFrameworkLabel(framework) {
916
930
  if (!framework || framework === "k6") return "";
917
931
  return framework;
@@ -1087,12 +1101,15 @@ async function runDefaultRuntimeTask(targetConfig, task, args) {
1087
1101
 
1088
1102
  const summary = readDefaultRuntimeSummary(summaryFile);
1089
1103
  const runtimeError = determineDefaultRuntimeFailure(result, summary);
1104
+ const finishedAt = Date.now();
1090
1105
 
1091
1106
  return {
1092
1107
  task,
1093
1108
  failed: runtimeError !== null,
1094
1109
  error: runtimeError,
1095
- durationMs: Date.now() - startedAt,
1110
+ durationMs: finishedAt - startedAt,
1111
+ startedAt,
1112
+ finishedAt,
1096
1113
  };
1097
1114
  }
1098
1115
 
@@ -69,7 +69,9 @@ export function collectSuites(config, suiteType, suiteNames, frameworkFilter, fi
69
69
  ? Math.max(2, files.length)
70
70
  : Math.max(1, files.length)),
71
71
  maxFileConcurrency:
72
- framework === "k6" ? suite.testkit?.maxFileConcurrency || 1 : 1,
72
+ framework === "k6" || framework === "playwright"
73
+ ? suite.testkit?.maxFileConcurrency || 1
74
+ : 1,
73
75
  });
74
76
  orderIndex += 1;
75
77
  }
@@ -183,9 +185,7 @@ export function buildTaskQueue(servicePlans, graphs, timings) {
183
185
  timingKey,
184
186
  estimatedDurationMs: estimateTaskDuration(timings, timingKey, suite),
185
187
  maxBatchSize:
186
- suite.framework === "playwright"
187
- ? Number.POSITIVE_INFINITY
188
- : suite.maxFileConcurrency || 1,
188
+ suite.maxFileConcurrency || 1,
189
189
  });
190
190
  nextId += 1;
191
191
  }
@@ -213,20 +213,9 @@ export function claimNextBatch(queue, preferredGraphKey) {
213
213
  const seed = queue.splice(index, 1)[0];
214
214
  const tasks = [seed];
215
215
 
216
- if (seed.framework === "playwright") {
217
- for (let cursor = queue.length - 1; cursor >= 0; cursor -= 1) {
218
- const candidate = queue[cursor];
219
- if (
220
- candidate.framework === "playwright" &&
221
- candidate.graphKey === seed.graphKey &&
222
- candidate.targetName === seed.targetName
223
- ) {
224
- tasks.push(candidate);
225
- queue.splice(cursor, 1);
226
- }
227
- }
228
- } else if (seed.maxBatchSize > 1) {
229
- for (let cursor = queue.length - 1; cursor >= 0 && tasks.length < seed.maxBatchSize; cursor -= 1) {
216
+ if (seed.maxBatchSize > 1) {
217
+ for (let cursor = 0; cursor < queue.length; cursor += 1) {
218
+ if (tasks.length >= seed.maxBatchSize) break;
230
219
  const candidate = queue[cursor];
231
220
  if (
232
221
  candidate.framework === seed.framework &&
@@ -237,6 +226,7 @@ export function claimNextBatch(queue, preferredGraphKey) {
237
226
  ) {
238
227
  tasks.push(candidate);
239
228
  queue.splice(cursor, 1);
229
+ cursor -= 1;
240
230
  }
241
231
  }
242
232
  }
@@ -137,11 +137,51 @@ describe("runner-planning", () => {
137
137
  expect(queue).toHaveLength(4);
138
138
 
139
139
  const firstBatch = claimNextBatch(queue, "api|frontend");
140
- expect(firstBatch.tasks).toHaveLength(2);
140
+ expect(firstBatch.tasks).toHaveLength(1);
141
141
  expect(firstBatch.framework).toBe("playwright");
142
142
 
143
143
  const secondBatch = claimNextBatch(queue, "api|frontend");
144
- expect(secondBatch.tasks).toHaveLength(2);
145
- expect(secondBatch.framework).toBe("k6");
144
+ expect(secondBatch.tasks).toHaveLength(1);
145
+ expect(secondBatch.framework).toBe("playwright");
146
+
147
+ const thirdBatch = claimNextBatch(queue, "api|frontend");
148
+ expect(thirdBatch.tasks).toHaveLength(2);
149
+ expect(thirdBatch.framework).toBe("k6");
150
+ });
151
+
152
+ it("allows Playwright suites to opt into bounded multi-file batches", () => {
153
+ const frontend = makeConfig("frontend");
154
+ const plans = [
155
+ {
156
+ config: frontend,
157
+ skipped: false,
158
+ runtimeConfigs: [frontend],
159
+ runtimeNames: ["frontend"],
160
+ runtimeKey: "frontend",
161
+ suites: [
162
+ {
163
+ name: "auth",
164
+ type: "e2e",
165
+ framework: "playwright",
166
+ files: ["a.spec.js", "b.spec.js", "c.spec.js"],
167
+ orderIndex: 0,
168
+ weight: 3,
169
+ maxFileConcurrency: 2,
170
+ },
171
+ ],
172
+ },
173
+ ];
174
+
175
+ const graphs = buildRuntimeGraphs(plans);
176
+ const queue = buildTaskQueue(plans, graphs, { files: {} });
177
+
178
+ const firstBatch = claimNextBatch(queue, "frontend");
179
+ expect(firstBatch.tasks.map((task) => task.file)).toEqual(["a.spec.js", "b.spec.js"]);
180
+ expect(firstBatch.tasks).toHaveLength(2);
181
+ expect(firstBatch.framework).toBe("playwright");
182
+
183
+ const secondBatch = claimNextBatch(queue, "frontend");
184
+ expect(secondBatch.tasks).toHaveLength(1);
185
+ expect(secondBatch.framework).toBe("playwright");
146
186
  });
147
187
  });
@@ -17,6 +17,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
17
17
  startedAt,
18
18
  firstTaskAt: null,
19
19
  lastTaskAt: null,
20
+ totalTaskDurationMs: 0,
20
21
  });
21
22
  continue;
22
23
  }
@@ -72,6 +73,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
72
73
  startedAt,
73
74
  firstTaskAt: null,
74
75
  lastTaskAt: null,
76
+ totalTaskDurationMs: 0,
75
77
  });
76
78
  }
77
79
 
@@ -82,26 +84,38 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
82
84
  const tracker = trackers.get(task.serviceName);
83
85
  if (!tracker || tracker.skipped) return;
84
86
 
85
- if (!tracker.firstTaskAt) tracker.firstTaskAt = finishedAt;
86
- tracker.lastTaskAt = finishedAt;
87
-
88
87
  const suite = tracker.suitesByKey.get(task.suiteKey);
89
88
  if (!suite) return;
90
89
 
90
+ const outcomeDurationMs = Number(outcome.durationMs ?? 0);
91
+ const outcomeFinishedAt = Number(outcome.finishedAt ?? finishedAt);
92
+ const outcomeStartedAt = Number(
93
+ outcome.startedAt ?? Math.max(0, outcomeFinishedAt - outcomeDurationMs)
94
+ );
95
+ tracker.firstTaskAt =
96
+ tracker.firstTaskAt === null
97
+ ? outcomeStartedAt
98
+ : Math.min(tracker.firstTaskAt, outcomeStartedAt);
99
+ tracker.lastTaskAt =
100
+ tracker.lastTaskAt === null
101
+ ? outcomeFinishedAt
102
+ : Math.max(tracker.lastTaskAt, outcomeFinishedAt);
103
+ tracker.totalTaskDurationMs += outcomeDurationMs;
104
+
91
105
  suite.completedFileCount += 1;
92
- suite.durationMs += outcome.durationMs;
106
+ suite.durationMs += outcomeDurationMs;
93
107
  const normalizedPath = normalizePathSeparators(task.file);
94
108
  const existingFileResult = suite.fileResultsByPath.get(normalizedPath);
95
109
  if (existingFileResult) {
96
110
  existingFileResult.failed = outcome.failed;
97
- existingFileResult.durationMs = outcome.durationMs;
111
+ existingFileResult.durationMs = outcomeDurationMs;
98
112
  existingFileResult.error = outcome.error;
99
113
  existingFileResult.status = outcome.failed ? "failed" : "passed";
100
114
  } else {
101
115
  const fileResult = {
102
116
  path: normalizedPath,
103
117
  failed: outcome.failed,
104
- durationMs: outcome.durationMs,
118
+ durationMs: outcomeDurationMs,
105
119
  error: outcome.error,
106
120
  status: outcome.failed ? "failed" : "passed",
107
121
  };
@@ -144,6 +158,7 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
144
158
  completedSuiteCount: 0,
145
159
  failedSuiteCount: 0,
146
160
  durationMs: 0,
161
+ totalTaskDurationMs: 0,
147
162
  suites: [],
148
163
  errors: [],
149
164
  };
@@ -169,11 +184,12 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
169
184
  0
170
185
  );
171
186
  const notRunFileCount = totalFileCount - completedFileCount;
172
- const accumulatedDurationMs = suites.reduce((sum, suite) => sum + suite.durationMs, 0);
187
+ const totalTaskDurationMs =
188
+ tracker.totalTaskDurationMs || suites.reduce((sum, suite) => sum + suite.durationMs, 0);
173
189
  const durationMs =
174
- tracker.firstTaskAt && tracker.lastTaskAt
175
- ? Math.max(tracker.lastTaskAt - tracker.firstTaskAt, accumulatedDurationMs)
176
- : Math.max(finishedAt - startedAt, accumulatedDurationMs);
190
+ tracker.firstTaskAt !== null && tracker.lastTaskAt !== null
191
+ ? Math.max(0, tracker.lastTaskAt - tracker.firstTaskAt)
192
+ : Math.max(0, finishedAt - startedAt);
177
193
 
178
194
  return {
179
195
  name: tracker.name,
@@ -192,6 +208,7 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
192
208
  failedFileCount,
193
209
  notRunFileCount,
194
210
  durationMs,
211
+ totalTaskDurationMs,
195
212
  suites: suites.map((suite) => ({
196
213
  name: suite.name,
197
214
  type: suite.type,
@@ -385,6 +402,7 @@ export function buildRunArtifact({
385
402
  failedFileCount: result.failedFileCount,
386
403
  notRunFileCount: result.notRunFileCount,
387
404
  durationMs: result.durationMs,
405
+ totalTaskDurationMs: result.totalTaskDurationMs,
388
406
  dbBackend: result.dbBackend,
389
407
  suites: result.suites,
390
408
  errors: result.errors,
@@ -81,6 +81,123 @@ describe("runner-results", () => {
81
81
  ]);
82
82
  });
83
83
 
84
+ it("reports service duration as wall-clock time, not accumulated file time", () => {
85
+ const trackers = buildServiceTrackers(
86
+ [
87
+ {
88
+ skipped: false,
89
+ config: {
90
+ name: "frontend",
91
+ testkit: {
92
+ database: {
93
+ selectedBackend: null,
94
+ },
95
+ },
96
+ },
97
+ suites: [
98
+ {
99
+ name: "browser",
100
+ type: "e2e",
101
+ framework: "playwright",
102
+ files: ["tests/a.pw.testkit.ts", "tests/b.pw.testkit.ts"],
103
+ orderIndex: 0,
104
+ },
105
+ ],
106
+ },
107
+ ],
108
+ 1000
109
+ );
110
+
111
+ recordTaskOutcome(
112
+ trackers,
113
+ {
114
+ serviceName: "frontend",
115
+ suiteKey: "e2e:browser",
116
+ file: "tests/a.pw.testkit.ts",
117
+ },
118
+ {
119
+ failed: false,
120
+ durationMs: 30_000,
121
+ startedAt: 1000,
122
+ finishedAt: 8000,
123
+ error: null,
124
+ },
125
+ 8000
126
+ );
127
+ recordTaskOutcome(
128
+ trackers,
129
+ {
130
+ serviceName: "frontend",
131
+ suiteKey: "e2e:browser",
132
+ file: "tests/b.pw.testkit.ts",
133
+ },
134
+ {
135
+ failed: false,
136
+ durationMs: 45_000,
137
+ startedAt: 3000,
138
+ finishedAt: 9000,
139
+ error: null,
140
+ },
141
+ 9000
142
+ );
143
+
144
+ const result = finalizeServiceResult(trackers.get("frontend"), 1000, 10_000);
145
+
146
+ expect(result.durationMs).toBe(8000);
147
+ expect(result.totalTaskDurationMs).toBe(75_000);
148
+ expect(result.suites[0].durationMs).toBe(75_000);
149
+ });
150
+
151
+ it("handles epoch-zero task timestamps when calculating wall-clock duration", () => {
152
+ const trackers = buildServiceTrackers(
153
+ [
154
+ {
155
+ skipped: false,
156
+ config: {
157
+ name: "frontend",
158
+ testkit: {
159
+ database: {
160
+ selectedBackend: null,
161
+ },
162
+ },
163
+ },
164
+ suites: [
165
+ {
166
+ name: "browser",
167
+ type: "e2e",
168
+ framework: "playwright",
169
+ files: ["tests/a.pw.testkit.ts"],
170
+ orderIndex: 0,
171
+ },
172
+ ],
173
+ },
174
+ ],
175
+ 0
176
+ );
177
+
178
+ recordTaskOutcome(
179
+ trackers,
180
+ {
181
+ serviceName: "frontend",
182
+ suiteKey: "e2e:browser",
183
+ file: "tests/a.pw.testkit.ts",
184
+ },
185
+ {
186
+ failed: false,
187
+ durationMs: 3_000,
188
+ startedAt: 0,
189
+ finishedAt: 8_000,
190
+ error: null,
191
+ },
192
+ 8_000
193
+ );
194
+
195
+ const result = finalizeServiceResult(trackers.get("frontend"), 0, 10_000);
196
+
197
+ expect(result.durationMs).toBe(8_000);
198
+ expect(result.totalTaskDurationMs).toBe(3_000);
199
+ });
200
+
84
201
  it("builds run artifacts and formatting helpers", () => {
85
202
  const results = [
86
203
  {
@@ -96,6 +213,7 @@ describe("runner-results", () => {
96
213
  failedFileCount: 0,
97
214
  notRunFileCount: 0,
98
215
  durationMs: 1200,
216
+ totalTaskDurationMs: 2400,
99
217
  dbBackend: "local",
100
218
  suites: [],
101
219
  errors: [],
@@ -113,6 +231,7 @@ describe("runner-results", () => {
113
231
  failedFileCount: 0,
114
232
  notRunFileCount: 0,
115
233
  durationMs: 0,
234
+ totalTaskDurationMs: 0,
116
235
  dbBackend: null,
117
236
  suites: [],
118
237
  errors: [],
@@ -154,6 +273,8 @@ describe("runner-results", () => {
154
273
  failed: 0,
155
274
  notRun: 0,
156
275
  });
276
+ expect(artifact.services[0].durationMs).toBe(1200);
277
+ expect(artifact.services[0].totalTaskDurationMs).toBe(2400);
157
278
  expect(summarizeDbBackend(results)).toBe("local");
158
279
  expect(formatDuration(65_000)).toBe("1m 05s");
159
280
  expect(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
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",