@elench/testkit 0.1.100 → 0.1.101
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 +6 -3
- package/lib/cli/args.mjs +0 -19
- package/lib/cli/assistant/command-observer.mjs +75 -44
- package/lib/cli/assistant/command-results.mjs +29 -2
- package/lib/cli/assistant/context-pack.mjs +21 -1
- package/lib/cli/assistant/settings.mjs +27 -5
- package/lib/cli/assistant/state.mjs +7 -1
- package/lib/cli/command-flags.mjs +0 -3
- package/lib/cli/entrypoint.mjs +0 -2
- package/lib/cli/operations/run/operation.mjs +0 -3
- package/lib/runner/live-run.mjs +5 -1
- package/lib/runner/orchestrator.mjs +26 -26
- package/lib/runner/planning.mjs +0 -75
- package/lib/runner/provenance.mjs +20 -0
- package/lib/runner/reporting.mjs +14 -9
- package/lib/runner/run-finalization.mjs +5 -2
- package/lib/runner/run-guards.mjs +0 -1
- package/lib/runner/scheduler/estimates.mjs +61 -0
- package/lib/runner/scheduler/identity.mjs +31 -0
- package/lib/runner/scheduler/index.mjs +126 -0
- package/lib/runner/scheduler/observations.mjs +27 -0
- package/lib/runner/selection.mjs +1 -2
- package/lib/runner/worker-loop.mjs +3 -4
- package/lib/timing/index.mjs +33 -33
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +5 -5
package/lib/runner/planning.mjs
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { buildTimingKey, estimateTaskDuration } from "../timing/index.mjs";
|
|
2
1
|
import {
|
|
3
2
|
matchesSelectedTypes,
|
|
4
3
|
matchesSuiteSelectors,
|
|
@@ -86,11 +85,6 @@ export function collectSuites(config, typeValues, suiteSelectors, fileNames = []
|
|
|
86
85
|
return suites;
|
|
87
86
|
}
|
|
88
87
|
|
|
89
|
-
export function applyShard(suites, shard) {
|
|
90
|
-
if (!shard) return suites;
|
|
91
|
-
return suites.filter((_unused, index) => index % shard.total === shard.index - 1);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
88
|
export function orderedTypes(types) {
|
|
95
89
|
const ordered = [];
|
|
96
90
|
for (const known of TYPE_ORDER) {
|
|
@@ -143,52 +137,6 @@ export function buildRuntimeGraphs(servicePlans) {
|
|
|
143
137
|
}));
|
|
144
138
|
}
|
|
145
139
|
|
|
146
|
-
export function buildTaskQueue(servicePlans, graphs, timings) {
|
|
147
|
-
const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
|
|
148
|
-
const tasks = [];
|
|
149
|
-
let nextId = 1;
|
|
150
|
-
|
|
151
|
-
for (const plan of servicePlans) {
|
|
152
|
-
if (plan.skipped) continue;
|
|
153
|
-
|
|
154
|
-
const graph = graphByKey.get(plan.assignedGraphKey);
|
|
155
|
-
if (!graph) {
|
|
156
|
-
throw new Error(`Assigned graph "${plan.assignedGraphKey}" not found for ${plan.config.name}`);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
for (const suite of plan.suites) {
|
|
160
|
-
for (const file of suite.files) {
|
|
161
|
-
const timingKey = buildTimingKey(plan.config.name, suite, file);
|
|
162
|
-
tasks.push({
|
|
163
|
-
id: nextId,
|
|
164
|
-
graphKey: graph.key,
|
|
165
|
-
targetName: plan.config.name,
|
|
166
|
-
serviceName: plan.config.name,
|
|
167
|
-
suiteKey: `${suite.type}:${suite.name}`,
|
|
168
|
-
suiteName: suite.name,
|
|
169
|
-
type: suite.type,
|
|
170
|
-
framework: suite.framework,
|
|
171
|
-
orderIndex: suite.orderIndex,
|
|
172
|
-
file,
|
|
173
|
-
locks: resolveTaskLocks(plan.config, suite, file),
|
|
174
|
-
resourceCost: 1,
|
|
175
|
-
timingKey,
|
|
176
|
-
estimatedDurationMs: estimateTaskDuration(timings, timingKey, suite),
|
|
177
|
-
});
|
|
178
|
-
nextId += 1;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
return tasks.sort(
|
|
184
|
-
(a, b) =>
|
|
185
|
-
b.estimatedDurationMs - a.estimatedDurationMs ||
|
|
186
|
-
a.serviceName.localeCompare(b.serviceName) ||
|
|
187
|
-
a.suiteKey.localeCompare(b.suiteKey) ||
|
|
188
|
-
a.file.localeCompare(b.file)
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
140
|
export function claimNextTask(queue, preferredGraphKey, isRunnable = () => true) {
|
|
193
141
|
if (queue.length === 0) return null;
|
|
194
142
|
|
|
@@ -259,29 +207,6 @@ function applySkipRules(config, displayType, suiteName, files, opts = {}) {
|
|
|
259
207
|
};
|
|
260
208
|
}
|
|
261
209
|
|
|
262
|
-
function resolveTaskLocks(config, suite, file) {
|
|
263
|
-
const locks = new Set();
|
|
264
|
-
const matchedSuiteRules = config.testkit.requirements?.suites || [];
|
|
265
|
-
for (const rule of matchedSuiteRules) {
|
|
266
|
-
if (matchesSuiteSelectors(suite.displayType, suite.name, [rule.selector])) {
|
|
267
|
-
for (const lockName of rule.locks || []) {
|
|
268
|
-
locks.add(lockName);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const normalizedFile = normalizePathSeparators(file);
|
|
274
|
-
const fileMetadata = config.testkit.fileMetadataByPath?.get(normalizedFile) || null;
|
|
275
|
-
for (const lockName of config.testkit.requirements?.fileLocksByPath?.get(normalizedFile) || []) {
|
|
276
|
-
locks.add(lockName);
|
|
277
|
-
}
|
|
278
|
-
for (const lockName of fileMetadata?.locks || []) {
|
|
279
|
-
locks.add(lockName);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
return [...locks].sort();
|
|
283
|
-
}
|
|
284
|
-
|
|
285
210
|
function normalizePathSeparators(filePath) {
|
|
286
211
|
return String(filePath).split("\\").join("/");
|
|
287
212
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const ASSISTANT_SESSION_ENV = "TESTKIT_ASSISTANT_SESSION_ID";
|
|
2
|
+
const ASSISTANT_COMMAND_ID_ENV = "TESTKIT_ASSISTANT_COMMAND_ID";
|
|
3
|
+
|
|
4
|
+
export function buildRunProvenance(env = process.env) {
|
|
5
|
+
const sessionId = normalizeOptionalString(env?.[ASSISTANT_SESSION_ENV]);
|
|
6
|
+
const commandId = normalizeOptionalString(env?.[ASSISTANT_COMMAND_ID_ENV]);
|
|
7
|
+
if (!sessionId && !commandId) return null;
|
|
8
|
+
return {
|
|
9
|
+
assistant: {
|
|
10
|
+
sessionId,
|
|
11
|
+
commandId,
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeOptionalString(value) {
|
|
17
|
+
if (value == null) return null;
|
|
18
|
+
const stringValue = String(value).trim();
|
|
19
|
+
return stringValue || null;
|
|
20
|
+
}
|
package/lib/runner/reporting.mjs
CHANGED
|
@@ -6,7 +6,6 @@ export function buildStatusArtifact({
|
|
|
6
6
|
typeValues,
|
|
7
7
|
suiteSelectors,
|
|
8
8
|
fileNames,
|
|
9
|
-
shard,
|
|
10
9
|
serviceFilter,
|
|
11
10
|
scenarioSeed,
|
|
12
11
|
metadata,
|
|
@@ -67,7 +66,6 @@ export function buildStatusArtifact({
|
|
|
67
66
|
types: [...(typeValues || ["all"])].sort(),
|
|
68
67
|
suiteSelectors: [...(suiteSelectors || [])].map((selector) => selector.raw).sort(),
|
|
69
68
|
fileNames: [...(fileNames || [])].sort(),
|
|
70
|
-
shard: shard || null,
|
|
71
69
|
serviceFilter: serviceFilter || null,
|
|
72
70
|
scenarioSeed: scenarioSeed || null,
|
|
73
71
|
};
|
|
@@ -76,11 +74,10 @@ export function buildStatusArtifact({
|
|
|
76
74
|
scope.types[0] === "all" &&
|
|
77
75
|
scope.suiteSelectors.length === 0 &&
|
|
78
76
|
scope.fileNames.length === 0 &&
|
|
79
|
-
scope.shard === null &&
|
|
80
77
|
scope.serviceFilter === null;
|
|
81
78
|
|
|
82
79
|
return {
|
|
83
|
-
schemaVersion:
|
|
80
|
+
schemaVersion: 8,
|
|
84
81
|
source: "testkit",
|
|
85
82
|
notice: "Generated file. Do not edit manually.",
|
|
86
83
|
product: {
|
|
@@ -99,6 +96,7 @@ export function buildStatusArtifact({
|
|
|
99
96
|
|
|
100
97
|
export function buildRunArtifact({
|
|
101
98
|
productDir,
|
|
99
|
+
runId = null,
|
|
102
100
|
results,
|
|
103
101
|
startedAt,
|
|
104
102
|
finishedAt,
|
|
@@ -109,9 +107,10 @@ export function buildRunArtifact({
|
|
|
109
107
|
typeValues,
|
|
110
108
|
suiteSelectors,
|
|
111
109
|
fileNames,
|
|
112
|
-
shard,
|
|
113
110
|
serviceFilter,
|
|
114
111
|
scenarioSeed,
|
|
112
|
+
planning = null,
|
|
113
|
+
provenance = null,
|
|
115
114
|
metadata,
|
|
116
115
|
summarizeDbBackend,
|
|
117
116
|
serviceLogs = [],
|
|
@@ -134,7 +133,7 @@ export function buildRunArtifact({
|
|
|
134
133
|
const dbBackend = summarizeDbBackend(results);
|
|
135
134
|
|
|
136
135
|
return {
|
|
137
|
-
schemaVersion:
|
|
136
|
+
schemaVersion: 10,
|
|
138
137
|
source: "testkit",
|
|
139
138
|
generatedAt: new Date(finishedAt).toISOString(),
|
|
140
139
|
product: {
|
|
@@ -144,6 +143,7 @@ export function buildRunArtifact({
|
|
|
144
143
|
git: metadata.git,
|
|
145
144
|
host: metadata.host,
|
|
146
145
|
run: {
|
|
146
|
+
id: runId,
|
|
147
147
|
status: runStatus || (failedServices.length > 0 ? "failed" : "passed"),
|
|
148
148
|
startedAt: new Date(startedAt).toISOString(),
|
|
149
149
|
finishedAt: new Date(finishedAt).toISOString(),
|
|
@@ -157,11 +157,12 @@ export function buildRunArtifact({
|
|
|
157
157
|
types: typeValues,
|
|
158
158
|
suiteSelectors: suiteSelectors.map((selector) => selector.raw),
|
|
159
159
|
fileNames,
|
|
160
|
-
shard,
|
|
161
160
|
serviceFilter,
|
|
162
161
|
scenarioSeed: scenarioSeed || null,
|
|
163
162
|
testkitVersion: metadata.testkitVersion,
|
|
164
163
|
},
|
|
164
|
+
...(provenance ? { provenance } : {}),
|
|
165
|
+
...(planning ? { planning } : {}),
|
|
165
166
|
summary: {
|
|
166
167
|
services: {
|
|
167
168
|
total: executed.length,
|
|
@@ -216,6 +217,7 @@ export function buildRunArtifact({
|
|
|
216
217
|
|
|
217
218
|
export function buildLiveRunArtifact({
|
|
218
219
|
productDir,
|
|
220
|
+
runId = null,
|
|
219
221
|
results,
|
|
220
222
|
startedAt,
|
|
221
223
|
updatedAt,
|
|
@@ -226,9 +228,10 @@ export function buildLiveRunArtifact({
|
|
|
226
228
|
typeValues,
|
|
227
229
|
suiteSelectors,
|
|
228
230
|
fileNames,
|
|
229
|
-
shard,
|
|
230
231
|
serviceFilter,
|
|
231
232
|
scenarioSeed,
|
|
233
|
+
planning = null,
|
|
234
|
+
provenance = null,
|
|
232
235
|
metadata,
|
|
233
236
|
summarizeDbBackend,
|
|
234
237
|
serviceLogs = [],
|
|
@@ -237,6 +240,7 @@ export function buildLiveRunArtifact({
|
|
|
237
240
|
}) {
|
|
238
241
|
return buildRunArtifact({
|
|
239
242
|
productDir,
|
|
243
|
+
runId,
|
|
240
244
|
results,
|
|
241
245
|
startedAt,
|
|
242
246
|
finishedAt: updatedAt,
|
|
@@ -247,9 +251,10 @@ export function buildLiveRunArtifact({
|
|
|
247
251
|
typeValues,
|
|
248
252
|
suiteSelectors,
|
|
249
253
|
fileNames,
|
|
250
|
-
shard,
|
|
251
254
|
serviceFilter,
|
|
252
255
|
scenarioSeed,
|
|
256
|
+
planning,
|
|
257
|
+
provenance,
|
|
253
258
|
metadata,
|
|
254
259
|
summarizeDbBackend,
|
|
255
260
|
serviceLogs,
|
|
@@ -9,6 +9,7 @@ import { shouldFailRegressionSync, validateRegressionIssues } from "../regressio
|
|
|
9
9
|
|
|
10
10
|
export async function finalizeRunArtifacts({
|
|
11
11
|
productDir,
|
|
12
|
+
runId,
|
|
12
13
|
results,
|
|
13
14
|
startedAt,
|
|
14
15
|
finishedAt,
|
|
@@ -17,6 +18,7 @@ export async function finalizeRunArtifacts({
|
|
|
17
18
|
runtimeInstanceCount,
|
|
18
19
|
runtimeStats,
|
|
19
20
|
selection,
|
|
21
|
+
provenance = null,
|
|
20
22
|
metadata,
|
|
21
23
|
logRegistry,
|
|
22
24
|
setupRegistry,
|
|
@@ -28,6 +30,7 @@ export async function finalizeRunArtifacts({
|
|
|
28
30
|
}) {
|
|
29
31
|
const runArtifact = buildRunArtifact({
|
|
30
32
|
productDir,
|
|
33
|
+
runId,
|
|
31
34
|
results,
|
|
32
35
|
startedAt,
|
|
33
36
|
finishedAt,
|
|
@@ -38,9 +41,10 @@ export async function finalizeRunArtifacts({
|
|
|
38
41
|
typeValues: selection.typeValues,
|
|
39
42
|
suiteSelectors: selection.suiteSelectors,
|
|
40
43
|
fileNames: selection.fileNames,
|
|
41
|
-
shard: selection.shard,
|
|
42
44
|
serviceFilter: selection.serviceFilter,
|
|
43
45
|
scenarioSeed: selection.scenarioSeed,
|
|
46
|
+
planning: selection.planning || null,
|
|
47
|
+
provenance,
|
|
44
48
|
metadata,
|
|
45
49
|
summarizeDbBackend,
|
|
46
50
|
serviceLogs: logRegistry.listServiceLogs(),
|
|
@@ -54,7 +58,6 @@ export async function finalizeRunArtifacts({
|
|
|
54
58
|
typeValues: selection.typeValues,
|
|
55
59
|
suiteSelectors: selection.suiteSelectors,
|
|
56
60
|
fileNames: selection.fileNames,
|
|
57
|
-
shard: selection.shard,
|
|
58
61
|
serviceFilter: selection.serviceFilter,
|
|
59
62
|
scenarioSeed: selection.scenarioSeed,
|
|
60
63
|
metadata,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export const DEFAULT_ESTIMATE_SOURCE = "default";
|
|
2
|
+
|
|
3
|
+
export function estimateTask({ task, suite, timings, history } = {}) {
|
|
4
|
+
const timingEstimate = estimateFromTimings(timings, task);
|
|
5
|
+
if (timingEstimate) return timingEstimate;
|
|
6
|
+
|
|
7
|
+
const historyEstimate = estimateFromHistory(history, task);
|
|
8
|
+
if (historyEstimate) return historyEstimate;
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
durationMs: estimateDefaultDuration(task, suite),
|
|
12
|
+
source: DEFAULT_ESTIMATE_SOURCE,
|
|
13
|
+
confidence: "low",
|
|
14
|
+
runs: 0,
|
|
15
|
+
key: null,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function estimateDefaultDuration(task = {}, suite = {}) {
|
|
20
|
+
const base =
|
|
21
|
+
task.framework === "playwright" || suite.framework === "playwright"
|
|
22
|
+
? 20_000
|
|
23
|
+
: task.type === "dal" || suite.type === "dal"
|
|
24
|
+
? 4_000
|
|
25
|
+
: 8_000;
|
|
26
|
+
const suiteWeight = Number(suite.weight || 1);
|
|
27
|
+
const suiteFileCount = Math.max(1, Number(suite.files?.length || 1));
|
|
28
|
+
return Math.max(1_000, Math.round((base * suiteWeight) / suiteFileCount));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function estimateFromTimings(timings, task = {}) {
|
|
32
|
+
const candidates = [task.timingKey, ...(task.legacyTimingKeys || [])].filter(Boolean);
|
|
33
|
+
for (const key of candidates) {
|
|
34
|
+
const entry = timings?.files?.[key];
|
|
35
|
+
const durationMs = Number(entry?.durationMs || entry?.avgDurationMs || 0);
|
|
36
|
+
if (durationMs <= 0) continue;
|
|
37
|
+
const runs = Number(entry.runs || 0);
|
|
38
|
+
return {
|
|
39
|
+
durationMs: Math.max(1, Math.round(durationMs)),
|
|
40
|
+
source: "timings",
|
|
41
|
+
confidence: runs >= 3 ? "high" : "medium",
|
|
42
|
+
runs,
|
|
43
|
+
key,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function estimateFromHistory(history, task = {}) {
|
|
50
|
+
const entry = history?.tests?.[task.historyKey];
|
|
51
|
+
const durationMs = Number(entry?.avgDurationMs || 0);
|
|
52
|
+
if (durationMs <= 0) return null;
|
|
53
|
+
const runs = Number(entry.durationCount || entry.runCount || 0);
|
|
54
|
+
return {
|
|
55
|
+
durationMs: Math.max(1, Math.round(durationMs)),
|
|
56
|
+
source: "history",
|
|
57
|
+
confidence: runs >= 3 ? "medium" : "low",
|
|
58
|
+
runs,
|
|
59
|
+
key: task.historyKey,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
|
|
3
|
+
export function normalizeTaskPath(filePath) {
|
|
4
|
+
return String(filePath || "").split(path.sep).join("/").replace(/^\.\/+/, "");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function buildSchedulerTaskKey({ serviceName, displayType, type, framework, file } = {}) {
|
|
8
|
+
return [
|
|
9
|
+
serviceName || "service",
|
|
10
|
+
displayType || type || "unknown",
|
|
11
|
+
framework || "k6",
|
|
12
|
+
normalizeTaskPath(file),
|
|
13
|
+
].join("|");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildLegacyTimingKey({ serviceName, type, framework, file } = {}) {
|
|
17
|
+
return [
|
|
18
|
+
serviceName || "service",
|
|
19
|
+
framework || "k6",
|
|
20
|
+
type || "unknown",
|
|
21
|
+
normalizeTaskPath(file),
|
|
22
|
+
].join("|");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildSchedulerHistoryKey({ serviceName, displayType, type, file } = {}) {
|
|
26
|
+
return [
|
|
27
|
+
serviceName || "service",
|
|
28
|
+
displayType || type || "unknown",
|
|
29
|
+
normalizeTaskPath(file),
|
|
30
|
+
].join("|");
|
|
31
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildLegacyTimingKey,
|
|
3
|
+
buildSchedulerHistoryKey,
|
|
4
|
+
buildSchedulerTaskKey,
|
|
5
|
+
} from "./identity.mjs";
|
|
6
|
+
import { estimateTask } from "./estimates.mjs";
|
|
7
|
+
import { matchesSuiteSelectors } from "../suite-selection.mjs";
|
|
8
|
+
|
|
9
|
+
export const SCHEDULER_POLICY = "longest-estimated-duration-first";
|
|
10
|
+
export const SCHEDULER_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
export function buildScheduledQueue(servicePlans, graphs, { timings, history } = {}) {
|
|
13
|
+
const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
|
|
14
|
+
const tasks = [];
|
|
15
|
+
let nextId = 1;
|
|
16
|
+
|
|
17
|
+
for (const plan of servicePlans) {
|
|
18
|
+
if (plan.skipped) continue;
|
|
19
|
+
|
|
20
|
+
const graph = graphByKey.get(plan.assignedGraphKey);
|
|
21
|
+
if (!graph) {
|
|
22
|
+
throw new Error(`Assigned graph "${plan.assignedGraphKey}" not found for ${plan.config.name}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const suite of plan.suites) {
|
|
26
|
+
for (const file of suite.files) {
|
|
27
|
+
const baseTask = {
|
|
28
|
+
id: nextId,
|
|
29
|
+
graphKey: graph.key,
|
|
30
|
+
targetName: plan.config.name,
|
|
31
|
+
serviceName: plan.config.name,
|
|
32
|
+
suiteKey: `${suite.type}:${suite.name}`,
|
|
33
|
+
suiteName: suite.name,
|
|
34
|
+
type: suite.type,
|
|
35
|
+
displayType: suite.displayType || suite.type,
|
|
36
|
+
framework: suite.framework,
|
|
37
|
+
orderIndex: suite.orderIndex,
|
|
38
|
+
file,
|
|
39
|
+
locks: resolveTaskLocks(plan.config, suite, file),
|
|
40
|
+
resourceCost: 1,
|
|
41
|
+
};
|
|
42
|
+
const timingKey = buildSchedulerTaskKey(baseTask);
|
|
43
|
+
const task = {
|
|
44
|
+
...baseTask,
|
|
45
|
+
timingKey,
|
|
46
|
+
legacyTimingKeys: [buildLegacyTimingKey(baseTask)].filter((key) => key !== timingKey),
|
|
47
|
+
historyKey: buildSchedulerHistoryKey(baseTask),
|
|
48
|
+
};
|
|
49
|
+
const estimate = estimateTask({ task, suite, timings, history });
|
|
50
|
+
tasks.push({
|
|
51
|
+
...task,
|
|
52
|
+
estimatedDurationMs: estimate.durationMs,
|
|
53
|
+
estimateSource: estimate.source,
|
|
54
|
+
estimateConfidence: estimate.confidence,
|
|
55
|
+
estimateRuns: estimate.runs,
|
|
56
|
+
estimateKey: estimate.key,
|
|
57
|
+
});
|
|
58
|
+
nextId += 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return tasks
|
|
64
|
+
.sort(compareScheduledTasks)
|
|
65
|
+
.map((task, index) => ({
|
|
66
|
+
...task,
|
|
67
|
+
schedulerRank: index + 1,
|
|
68
|
+
schedulerPolicy: SCHEDULER_POLICY,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildRunPlanningMetadata(queue) {
|
|
73
|
+
return {
|
|
74
|
+
scheduler: {
|
|
75
|
+
policy: SCHEDULER_POLICY,
|
|
76
|
+
version: SCHEDULER_VERSION,
|
|
77
|
+
},
|
|
78
|
+
tasks: (queue || []).map((task) => ({
|
|
79
|
+
rank: task.schedulerRank || null,
|
|
80
|
+
id: task.id,
|
|
81
|
+
service: task.serviceName,
|
|
82
|
+
suite: task.suiteName,
|
|
83
|
+
type: task.displayType || task.type,
|
|
84
|
+
framework: task.framework,
|
|
85
|
+
file: task.file,
|
|
86
|
+
graphKey: task.graphKey,
|
|
87
|
+
estimatedDurationMs: task.estimatedDurationMs,
|
|
88
|
+
estimateSource: task.estimateSource,
|
|
89
|
+
estimateConfidence: task.estimateConfidence,
|
|
90
|
+
estimateRuns: task.estimateRuns || 0,
|
|
91
|
+
timingKey: task.timingKey,
|
|
92
|
+
historyKey: task.historyKey,
|
|
93
|
+
})),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function compareScheduledTasks(a, b) {
|
|
98
|
+
return (
|
|
99
|
+
b.estimatedDurationMs - a.estimatedDurationMs ||
|
|
100
|
+
a.serviceName.localeCompare(b.serviceName) ||
|
|
101
|
+
a.suiteKey.localeCompare(b.suiteKey) ||
|
|
102
|
+
a.file.localeCompare(b.file)
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveTaskLocks(config, suite, file) {
|
|
107
|
+
const locks = new Set();
|
|
108
|
+
const matchedSuiteRules = config.testkit.requirements?.suites || [];
|
|
109
|
+
for (const rule of matchedSuiteRules) {
|
|
110
|
+
if (matchesSuiteSelectors(suite.displayType, suite.name, [rule.selector])) {
|
|
111
|
+
for (const lockName of rule.locks || []) {
|
|
112
|
+
locks.add(lockName);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const normalizedFile = String(file).split("\\").join("/");
|
|
118
|
+
const fileMetadata = config.testkit.fileMetadataByPath?.get(normalizedFile) || null;
|
|
119
|
+
for (const lockName of config.testkit.requirements?.fileLocksByPath?.get(normalizedFile) || []) {
|
|
120
|
+
locks.add(lockName);
|
|
121
|
+
}
|
|
122
|
+
for (const lockName of fileMetadata?.locks || []) {
|
|
123
|
+
locks.add(lockName);
|
|
124
|
+
}
|
|
125
|
+
return [...locks].sort();
|
|
126
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function buildTimingObservation(task, outcome = {}) {
|
|
2
|
+
const status = normalizeOutcomeStatus(outcome);
|
|
3
|
+
if (status === "skipped" || status === "not_run") return null;
|
|
4
|
+
|
|
5
|
+
const durationMs = Number(outcome.durationMs || 0);
|
|
6
|
+
if (durationMs <= 0) return null;
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
key: task.timingKey,
|
|
10
|
+
legacyKeys: task.legacyTimingKeys || [],
|
|
11
|
+
durationMs,
|
|
12
|
+
status,
|
|
13
|
+
startedAt: outcome.startedAt || null,
|
|
14
|
+
finishedAt: outcome.finishedAt || null,
|
|
15
|
+
estimate: {
|
|
16
|
+
durationMs: task.estimatedDurationMs || null,
|
|
17
|
+
source: task.estimateSource || null,
|
|
18
|
+
confidence: task.estimateConfidence || null,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeOutcomeStatus(outcome = {}) {
|
|
24
|
+
if (outcome.status === "skipped") return "skipped";
|
|
25
|
+
if (outcome.status === "not_run") return "not_run";
|
|
26
|
+
return outcome.failed ? "failed" : "passed";
|
|
27
|
+
}
|
package/lib/runner/selection.mjs
CHANGED
|
@@ -23,13 +23,12 @@ export function findUnmatchedRequestedFiles(
|
|
|
23
23
|
);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export function isFullRunSelection(typeValues, suiteSelectors, fileNames,
|
|
26
|
+
export function isFullRunSelection(typeValues, suiteSelectors, fileNames, serviceFilter) {
|
|
27
27
|
return (
|
|
28
28
|
(typeValues || []).length === 1 &&
|
|
29
29
|
typeValues[0] === "all" &&
|
|
30
30
|
(suiteSelectors || []).length === 0 &&
|
|
31
31
|
(fileNames || []).length === 0 &&
|
|
32
|
-
(shard || null) === null &&
|
|
33
32
|
(serviceFilter || null) === null
|
|
34
33
|
);
|
|
35
34
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { formatError } from "./formatting.mjs";
|
|
2
2
|
import { runDalTask, runHttpK6Task } from "./default-runtime-runner.mjs";
|
|
3
3
|
import { runPlaywrightTask } from "./playwright-runner.mjs";
|
|
4
|
+
import { buildTimingObservation } from "./scheduler/observations.mjs";
|
|
4
5
|
|
|
5
6
|
const HTTP_K6_TYPES = new Set(["integration", "e2e", "scenario", "load"]);
|
|
6
7
|
|
|
@@ -72,10 +73,8 @@ export async function runWorker(
|
|
|
72
73
|
const outcome = await runTask(lease.context, task, lifecycle, lease, reporter);
|
|
73
74
|
recordTaskOutcome(trackers, outcome.task, outcome);
|
|
74
75
|
reporter?.taskFinished?.(outcome.task, outcome);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
durationMs: outcome.durationMs,
|
|
78
|
-
});
|
|
76
|
+
const timingObservation = buildTimingObservation(outcome.task, outcome);
|
|
77
|
+
if (timingObservation) timingUpdates.push(timingObservation);
|
|
79
78
|
worker.taskCount += 1;
|
|
80
79
|
await runtimeManager.release(lease);
|
|
81
80
|
} catch (error) {
|
package/lib/timing/index.mjs
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import path from "path";
|
|
2
|
-
|
|
3
1
|
export function createEmptyTimings() {
|
|
4
2
|
return {
|
|
5
3
|
version: 1,
|
|
@@ -8,9 +6,24 @@ export function createEmptyTimings() {
|
|
|
8
6
|
}
|
|
9
7
|
|
|
10
8
|
export function normalizeTimings(parsed) {
|
|
9
|
+
const files = {};
|
|
10
|
+
const parsedFiles = parsed?.files && typeof parsed.files === "object" ? parsed.files : {};
|
|
11
|
+
for (const [key, entry] of Object.entries(parsedFiles)) {
|
|
12
|
+
const durationMs = Math.max(0, Math.round(Number(entry?.durationMs || entry?.avgDurationMs || 0)));
|
|
13
|
+
files[key] = {
|
|
14
|
+
durationMs,
|
|
15
|
+
avgDurationMs: Math.max(0, Math.round(Number(entry?.avgDurationMs || durationMs || 0))),
|
|
16
|
+
lastDurationMs: Math.max(0, Math.round(Number(entry?.lastDurationMs || durationMs || 0))),
|
|
17
|
+
minDurationMs: Math.max(0, Math.round(Number(entry?.minDurationMs || durationMs || 0))),
|
|
18
|
+
maxDurationMs: Math.max(0, Math.round(Number(entry?.maxDurationMs || durationMs || 0))),
|
|
19
|
+
runs: Number(entry?.runs || 0),
|
|
20
|
+
lastStatus: entry?.lastStatus || null,
|
|
21
|
+
updatedAt: entry?.updatedAt || null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
11
24
|
return {
|
|
12
25
|
version: 1,
|
|
13
|
-
files
|
|
26
|
+
files,
|
|
14
27
|
};
|
|
15
28
|
}
|
|
16
29
|
|
|
@@ -21,53 +34,40 @@ export function applyTimingUpdates(timings, updates, updatedAt = new Date().toIS
|
|
|
21
34
|
};
|
|
22
35
|
|
|
23
36
|
for (const update of updates) {
|
|
37
|
+
if (!update?.key) continue;
|
|
38
|
+
const updateDurationMs = Math.max(1, Math.round(Number(update.durationMs || 0)));
|
|
39
|
+
if (!Number.isFinite(updateDurationMs)) continue;
|
|
24
40
|
const existing = next.files[update.key];
|
|
25
41
|
if (!existing) {
|
|
26
42
|
next.files[update.key] = {
|
|
27
|
-
durationMs:
|
|
43
|
+
durationMs: updateDurationMs,
|
|
44
|
+
avgDurationMs: updateDurationMs,
|
|
45
|
+
lastDurationMs: updateDurationMs,
|
|
46
|
+
minDurationMs: updateDurationMs,
|
|
47
|
+
maxDurationMs: updateDurationMs,
|
|
28
48
|
runs: 1,
|
|
49
|
+
lastStatus: update.status || null,
|
|
29
50
|
updatedAt,
|
|
30
51
|
};
|
|
31
52
|
continue;
|
|
32
53
|
}
|
|
33
54
|
|
|
34
55
|
const runs = Number(existing.runs || 0) + 1;
|
|
35
|
-
const
|
|
56
|
+
const avgDurationMs = Math.max(
|
|
36
57
|
1,
|
|
37
|
-
Math.round(((existing.
|
|
58
|
+
Math.round(((existing.avgDurationMs || existing.durationMs || updateDurationMs) * (runs - 1) + updateDurationMs) / runs)
|
|
38
59
|
);
|
|
39
60
|
next.files[update.key] = {
|
|
40
|
-
durationMs,
|
|
61
|
+
durationMs: avgDurationMs,
|
|
62
|
+
avgDurationMs,
|
|
63
|
+
lastDurationMs: updateDurationMs,
|
|
64
|
+
minDurationMs: Math.min(Number(existing.minDurationMs || updateDurationMs), updateDurationMs),
|
|
65
|
+
maxDurationMs: Math.max(Number(existing.maxDurationMs || updateDurationMs), updateDurationMs),
|
|
41
66
|
runs,
|
|
67
|
+
lastStatus: update.status || existing.lastStatus || null,
|
|
42
68
|
updatedAt,
|
|
43
69
|
};
|
|
44
70
|
}
|
|
45
71
|
|
|
46
72
|
return next;
|
|
47
73
|
}
|
|
48
|
-
|
|
49
|
-
export function estimateTaskDuration(timings, timingKey, suite) {
|
|
50
|
-
const cached = timings.files[timingKey];
|
|
51
|
-
if (cached?.durationMs) return cached.durationMs;
|
|
52
|
-
|
|
53
|
-
const base =
|
|
54
|
-
suite.framework === "playwright"
|
|
55
|
-
? 20_000
|
|
56
|
-
: suite.type === "dal"
|
|
57
|
-
? 4_000
|
|
58
|
-
: 8_000;
|
|
59
|
-
return Math.max(1_000, Math.round((base * suite.weight) / Math.max(1, suite.files.length)));
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function buildTimingKey(serviceName, suite, file) {
|
|
63
|
-
return [
|
|
64
|
-
serviceName,
|
|
65
|
-
suite.framework,
|
|
66
|
-
suite.type,
|
|
67
|
-
normalizePathSeparators(file),
|
|
68
|
-
].join("|");
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function normalizePathSeparators(filePath) {
|
|
72
|
-
return filePath.split(path.sep).join("/");
|
|
73
|
-
}
|