@elench/testkit 0.1.100 → 0.1.102
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/app.mjs +6 -0
- 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/providers/claude.mjs +42 -7
- package/lib/cli/assistant/providers/codex.mjs +87 -9
- package/lib/cli/assistant/providers/events.mjs +71 -0
- package/lib/cli/assistant/providers/index.mjs +5 -4
- package/lib/cli/assistant/providers/shared.mjs +40 -21
- package/lib/cli/assistant/session.mjs +46 -8
- package/lib/cli/assistant/settings.mjs +29 -6
- package/lib/cli/assistant/state.mjs +181 -6
- package/lib/cli/assistant/transcript-text.mjs +35 -0
- package/lib/cli/assistant/view-model.mjs +11 -0
- 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 +14 -8
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
|
-
applyShard,
|
|
3
2
|
buildRuntimeGraphs,
|
|
4
|
-
buildTaskQueue,
|
|
5
3
|
claimNextTask,
|
|
6
4
|
collectSuites,
|
|
7
5
|
resolveRuntimeConfigs,
|
|
8
6
|
} from "./planning.mjs";
|
|
7
|
+
import { buildRunPlanningMetadata, buildScheduledQueue } from "./scheduler/index.mjs";
|
|
9
8
|
import {
|
|
10
9
|
addTrackerError,
|
|
11
10
|
buildServiceTrackers,
|
|
@@ -20,6 +19,7 @@ import {
|
|
|
20
19
|
resetResultArtifacts,
|
|
21
20
|
saveTimings,
|
|
22
21
|
} from "./artifacts.mjs";
|
|
22
|
+
import { loadHistory } from "../history/index.mjs";
|
|
23
23
|
import { createRunLogRegistry } from "./logs.mjs";
|
|
24
24
|
import { createSetupOperationRegistry } from "./setup-operations.mjs";
|
|
25
25
|
import {
|
|
@@ -40,12 +40,14 @@ import { createWorker, runWorker } from "./worker-loop.mjs";
|
|
|
40
40
|
import { ensureRequestedFilesMatch, ensureStatusWriteAllowed } from "./run-guards.mjs";
|
|
41
41
|
import { createLiveSnapshotWriter } from "./live-run.mjs";
|
|
42
42
|
import { finalizeRunArtifacts } from "./run-finalization.mjs";
|
|
43
|
+
import { buildRunProvenance } from "./provenance.mjs";
|
|
43
44
|
|
|
44
45
|
export async function runAll(configs, typeValues, suiteSelectors, opts, allConfigs = configs) {
|
|
45
46
|
const configMap = new Map(allConfigs.map((config) => [config.name, config]));
|
|
46
47
|
const startedAt = Date.now();
|
|
47
48
|
const telemetry = configs[0]?.telemetry || null;
|
|
48
49
|
const productDir = configs[0]?.productDir || process.cwd();
|
|
50
|
+
const provenance = buildRunProvenance(opts.env || process.env);
|
|
49
51
|
await cleanupStaleRuns(productDir);
|
|
50
52
|
resetResultArtifacts(productDir);
|
|
51
53
|
const metadata = {
|
|
@@ -87,30 +89,33 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
87
89
|
);
|
|
88
90
|
reporter?.setServicePlans?.(servicePlans);
|
|
89
91
|
const trackers = buildServiceTrackers(servicePlans, startedAt);
|
|
92
|
+
const runSelection = {
|
|
93
|
+
typeValues,
|
|
94
|
+
suiteSelectors,
|
|
95
|
+
fileNames: requestedFiles,
|
|
96
|
+
serviceFilter: opts.serviceFilter || null,
|
|
97
|
+
scenarioSeed: opts.scenarioSeed || null,
|
|
98
|
+
planning: null,
|
|
99
|
+
};
|
|
90
100
|
let writeLiveSnapshot = () => {};
|
|
91
101
|
const setupRegistry = createSetupOperationRegistry({ logRegistry, onChange: () => writeLiveSnapshot() });
|
|
102
|
+
const executedPlans = servicePlans.filter((plan) => !plan.skipped);
|
|
103
|
+
let exitCode = 0;
|
|
104
|
+
const lifecycle = createRunLifecycle(productDir);
|
|
92
105
|
writeLiveSnapshot = createLiveSnapshotWriter({
|
|
93
106
|
productDir,
|
|
107
|
+
runId: lifecycle.runId,
|
|
94
108
|
configs,
|
|
95
109
|
trackers,
|
|
96
110
|
startedAt,
|
|
97
111
|
execution,
|
|
98
112
|
workerState,
|
|
99
|
-
selection:
|
|
100
|
-
|
|
101
|
-
suiteSelectors,
|
|
102
|
-
fileNames: requestedFiles,
|
|
103
|
-
shard: opts.shard || null,
|
|
104
|
-
serviceFilter: opts.serviceFilter || null,
|
|
105
|
-
scenarioSeed: opts.scenarioSeed || null,
|
|
106
|
-
},
|
|
113
|
+
selection: runSelection,
|
|
114
|
+
provenance,
|
|
107
115
|
metadata,
|
|
108
116
|
logRegistry,
|
|
109
117
|
setupRegistry,
|
|
110
118
|
});
|
|
111
|
-
const executedPlans = servicePlans.filter((plan) => !plan.skipped);
|
|
112
|
-
let exitCode = 0;
|
|
113
|
-
const lifecycle = createRunLifecycle(productDir);
|
|
114
119
|
lifecycle.markRunning();
|
|
115
120
|
lifecycle.installSignalHandlers();
|
|
116
121
|
let results = [];
|
|
@@ -120,8 +125,11 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
120
125
|
try {
|
|
121
126
|
if (executedPlans.length > 0) {
|
|
122
127
|
const timings = loadTimings(productDir);
|
|
128
|
+
const history = loadHistory(productDir);
|
|
123
129
|
const graphs = buildRuntimeGraphs(executedPlans);
|
|
124
|
-
const queue =
|
|
130
|
+
const queue = buildScheduledQueue(executedPlans, graphs, { timings, history });
|
|
131
|
+
runSelection.planning = buildRunPlanningMetadata(queue);
|
|
132
|
+
writeLiveSnapshot();
|
|
125
133
|
reporter?.setTotalFileCount?.(queue.length);
|
|
126
134
|
for (const task of queue) {
|
|
127
135
|
task.scenarioSeed = opts.scenarioSeed || null;
|
|
@@ -191,6 +199,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
191
199
|
);
|
|
192
200
|
const finalized = await finalizeRunArtifacts({
|
|
193
201
|
productDir,
|
|
202
|
+
runId: lifecycle.runId,
|
|
194
203
|
results,
|
|
195
204
|
startedAt,
|
|
196
205
|
finishedAt,
|
|
@@ -198,14 +207,8 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
198
207
|
workerCount: workerState.workerCount,
|
|
199
208
|
runtimeInstanceCount: workerState.runtimeInstanceCount,
|
|
200
209
|
runtimeStats: workerState.runtimeStats,
|
|
201
|
-
selection:
|
|
202
|
-
|
|
203
|
-
suiteSelectors,
|
|
204
|
-
fileNames: requestedFiles,
|
|
205
|
-
shard: opts.shard || null,
|
|
206
|
-
serviceFilter: opts.serviceFilter || null,
|
|
207
|
-
scenarioSeed: opts.scenarioSeed || null,
|
|
208
|
-
},
|
|
210
|
+
selection: runSelection,
|
|
211
|
+
provenance,
|
|
209
212
|
metadata,
|
|
210
213
|
logRegistry,
|
|
211
214
|
setupRegistry,
|
|
@@ -245,10 +248,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
245
248
|
|
|
246
249
|
function collectServicePlans(configs, configMap, typeValues, suiteSelectors, opts, execution, reporter) {
|
|
247
250
|
return configs.map((config) => {
|
|
248
|
-
const suites =
|
|
249
|
-
collectSuites(config, typeValues, suiteSelectors, opts.fileNames || [], opts),
|
|
250
|
-
opts.shard
|
|
251
|
-
);
|
|
251
|
+
const suites = collectSuites(config, typeValues, suiteSelectors, opts.fileNames || [], opts);
|
|
252
252
|
|
|
253
253
|
if (suites.length === 0) {
|
|
254
254
|
reporter?.serviceSkipped?.(
|
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
|
}
|