@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.
@@ -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
+ }
@@ -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: 7,
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: 9,
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,
@@ -27,7 +27,6 @@ export function ensureStatusWriteAllowed(opts, typeValues, suiteSelectors, reque
27
27
  typeValues,
28
28
  suiteSelectors,
29
29
  requestedFiles,
30
- opts.shard || null,
31
30
  opts.serviceFilter || null
32
31
  )
33
32
  ) {
@@ -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
+ }
@@ -23,13 +23,12 @@ export function findUnmatchedRequestedFiles(
23
23
  );
24
24
  }
25
25
 
26
- export function isFullRunSelection(typeValues, suiteSelectors, fileNames, shard, serviceFilter) {
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
- timingUpdates.push({
76
- key: outcome.task.timingKey,
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) {
@@ -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: parsed?.files && typeof parsed.files === "object" ? parsed.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: Math.max(1, Math.round(update.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 durationMs = Math.max(
56
+ const avgDurationMs = Math.max(
36
57
  1,
37
- Math.round(((existing.durationMs || update.durationMs) * (runs - 1) + update.durationMs) / runs)
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
- }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/next-analysis",
3
- "version": "0.1.100",
3
+ "version": "0.1.101",
4
4
  "description": "SWC-backed Next.js source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {