velocious 1.0.604 → 1.0.605

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.
Files changed (49) hide show
  1. package/README.md +19 -10
  2. package/build/cli/commands/test/timing-manifest/merge.js +14 -0
  3. package/build/environment-handlers/base.js +9 -0
  4. package/build/environment-handlers/node/cli/commands/test/timing-manifest/merge.js +108 -0
  5. package/build/environment-handlers/node/cli/commands/test.js +48 -7
  6. package/build/environment-handlers/node.js +10 -0
  7. package/build/src/cli/commands/test/timing-manifest/merge.d.ts +10 -0
  8. package/build/src/cli/commands/test/timing-manifest/merge.d.ts.map +1 -0
  9. package/build/src/cli/commands/test/timing-manifest/merge.js +13 -0
  10. package/build/src/environment-handlers/base.d.ts +6 -0
  11. package/build/src/environment-handlers/base.d.ts.map +1 -1
  12. package/build/src/environment-handlers/base.js +9 -1
  13. package/build/src/environment-handlers/node/cli/commands/test/timing-manifest/merge.d.ts +32 -0
  14. package/build/src/environment-handlers/node/cli/commands/test/timing-manifest/merge.d.ts.map +1 -0
  15. package/build/src/environment-handlers/node/cli/commands/test/timing-manifest/merge.js +97 -0
  16. package/build/src/environment-handlers/node/cli/commands/test.d.ts +3 -3
  17. package/build/src/environment-handlers/node/cli/commands/test.d.ts.map +1 -1
  18. package/build/src/environment-handlers/node/cli/commands/test.js +36 -9
  19. package/build/src/environment-handlers/node.d.ts +6 -0
  20. package/build/src/environment-handlers/node.d.ts.map +1 -1
  21. package/build/src/environment-handlers/node.js +10 -1
  22. package/build/src/testing/test-profile-output.d.ts +11 -0
  23. package/build/src/testing/test-profile-output.d.ts.map +1 -1
  24. package/build/src/testing/test-profile-output.js +21 -7
  25. package/build/src/testing/test-profiler.d.ts.map +1 -1
  26. package/build/src/testing/test-profiler.js +3 -2
  27. package/build/src/testing/test-suite-splitter.d.ts +33 -0
  28. package/build/src/testing/test-suite-splitter.d.ts.map +1 -1
  29. package/build/src/testing/test-suite-splitter.js +84 -8
  30. package/build/src/testing/timing-manifest.d.ts +94 -0
  31. package/build/src/testing/timing-manifest.d.ts.map +1 -0
  32. package/build/src/testing/timing-manifest.js +284 -0
  33. package/build/testing/test-profile-output.js +23 -8
  34. package/build/testing/test-profiler.js +2 -1
  35. package/build/testing/test-suite-splitter.js +91 -7
  36. package/build/testing/timing-manifest.js +346 -0
  37. package/build/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +1 -1
  39. package/scripts/bootstrap-provider-runtime.sh +249 -0
  40. package/scripts/verify-docker-dev-environment.js +219 -29
  41. package/src/cli/commands/test/timing-manifest/merge.js +14 -0
  42. package/src/environment-handlers/base.js +9 -0
  43. package/src/environment-handlers/node/cli/commands/test/timing-manifest/merge.js +108 -0
  44. package/src/environment-handlers/node/cli/commands/test.js +48 -7
  45. package/src/environment-handlers/node.js +10 -0
  46. package/src/testing/test-profile-output.js +23 -8
  47. package/src/testing/test-profiler.js +2 -1
  48. package/src/testing/test-suite-splitter.js +91 -7
  49. package/src/testing/timing-manifest.js +346 -0
@@ -0,0 +1,108 @@
1
+ // @ts-check
2
+
3
+ import BaseCommand from "../../../../../../cli/base-command.js"
4
+ import fs from "node:fs/promises"
5
+ import path from "node:path"
6
+ import { writeTimingManifest } from "../../../../../../testing/test-profile-output.js"
7
+ import { mergeTestProfileTimingManifests } from "../../../../../../testing/timing-manifest.js"
8
+
9
+ /**
10
+ * @typedef {object} TimingManifestMergeArguments
11
+ * @property {string[]} inputPaths - Rich profile input paths.
12
+ * @property {string} outputPath - Plain timing manifest output path.
13
+ */
14
+
15
+ /** Node implementation for timing-manifest aggregation. */
16
+ export default class TestTimingManifestMerge extends BaseCommand {
17
+ /**
18
+ * Runs execute.
19
+ * @returns {Promise<Record<string, number>>} - Complete merged timing manifest.
20
+ */
21
+ async execute() {
22
+ const {inputPaths, outputPath} = parseTimingManifestMergeArguments(this.processArgs || [], process.cwd())
23
+ const inputs = []
24
+
25
+ for (const inputPath of inputPaths) {
26
+ let content
27
+
28
+ try {
29
+ content = await fs.readFile(inputPath, "utf8")
30
+ } catch (error) {
31
+ throw new Error(`Failed to read test profile: ${inputPath}`, {cause: error})
32
+ }
33
+
34
+ let profile
35
+
36
+ try {
37
+ profile = JSON.parse(content)
38
+ } catch (error) {
39
+ throw new Error(`Failed to parse test profile: ${inputPath}`, {cause: error})
40
+ }
41
+
42
+ inputs.push({profile, source: inputPath})
43
+ }
44
+
45
+ const timingManifest = mergeTestProfileTimingManifests(inputs)
46
+
47
+ await writeTimingManifest({outputPath, timingManifest})
48
+ console.log(`Merged ${inputPaths.length} test profile shards into ${outputPath} (${Object.keys(timingManifest).length} files)`)
49
+
50
+ return timingManifest
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Recognizes one output option spelling.
56
+ * @param {string} argument - Current argument.
57
+ * @param {string | undefined} nextArgument - Following argument.
58
+ * @returns {{matched: boolean, skipNext: boolean, value: string | undefined}} - Parsed output option.
59
+ */
60
+ function timingManifestOutputArgument(argument, nextArgument) {
61
+ if (argument === "--output") {
62
+ return {matched: true, skipNext: true, value: nextArgument}
63
+ }
64
+
65
+ if (argument.startsWith("--output=")) {
66
+ return {matched: true, skipNext: false, value: argument.slice("--output=".length)}
67
+ }
68
+
69
+ return {matched: false, skipNext: false, value: undefined}
70
+ }
71
+
72
+ /**
73
+ * Parses strict merge arguments and resolves their paths.
74
+ * @param {string[]} processArgs - Raw CLI arguments, including command name.
75
+ * @param {string} cwd - Command working directory.
76
+ * @returns {TimingManifestMergeArguments} - Validated resolved paths.
77
+ */
78
+ export function parseTimingManifestMergeArguments(processArgs, cwd) {
79
+ const commandName = processArgs[0] || "test:timing-manifest:merge"
80
+ const inputPaths = []
81
+ let outputPath
82
+
83
+ for (let index = 1; index < processArgs.length; index++) {
84
+ const argument = processArgs[index]
85
+ const outputArgument = timingManifestOutputArgument(argument, processArgs[index + 1])
86
+
87
+ if (outputArgument.matched) {
88
+ if (!outputArgument.value || outputArgument.value.startsWith("-")) throw new Error("Missing value for --output")
89
+ if (outputPath) throw new Error("--output may only be provided once")
90
+ outputPath = path.resolve(cwd, outputArgument.value)
91
+ if (outputArgument.skipNext) index++
92
+ continue
93
+ }
94
+
95
+ if (argument.startsWith("-")) throw new Error(`Unknown argument for ${commandName}: ${argument}`)
96
+ inputPaths.push(path.resolve(cwd, argument))
97
+ }
98
+
99
+ if (!outputPath) throw new Error("--output is required")
100
+ if (inputPaths.length === 0) throw new Error("At least one rich test profile input is required")
101
+
102
+ const uniqueInputPaths = new Set(inputPaths)
103
+
104
+ if (uniqueInputPaths.size !== inputPaths.length) throw new Error("Each rich test profile input must be provided once")
105
+ if (uniqueInputPaths.has(outputPath)) throw new Error("Timing manifest output must not overwrite an input profile")
106
+
107
+ return {inputPaths, outputPath}
108
+ }
@@ -10,6 +10,11 @@ import { formatTestProfileSummary, writeTestProfileOutputs } from "../../../../t
10
10
  import TestRunner from "../../../../testing/test-runner.js"
11
11
  import TestSuiteSplitter from "../../../../testing/test-suite-splitter.js"
12
12
  import { normalizeExamplePatterns, parseFilters } from "../../../../testing/test-filter-parser.js"
13
+ import {
14
+ canonicalTimingManifestPath,
15
+ timingManifestFileSetHash,
16
+ validateTimingManifest
17
+ } from "../../../../testing/timing-manifest.js"
13
18
  import { prepareSourcePeerPackage } from "../../source-peer-package.js"
14
19
 
15
20
  export default class VelociousCliCommandsTest extends BaseCommand {
@@ -95,14 +100,28 @@ export default class VelociousCliCommandsTest extends BaseCommand {
95
100
 
96
101
  try {
97
102
  const discoverTestFiles = async () => {
103
+ const timingManifest = await loadTimingManifest(profileOptions.timingManifestPath)
98
104
  let discoveredTestFiles = await testFilesFinder.findTestFiles()
105
+ const lineFilters = testFilesFinder.getLineFiltersByFile()
106
+
107
+ if (profiler) {
108
+ const discoveredFilePaths = discoveredTestFiles.map((filePath) => {
109
+ return canonicalTimingManifestPath(path.relative(directory, filePath))
110
+ })
111
+
112
+ profiler.setSelection({
113
+ discoveredFileCount: discoveredTestFiles.length,
114
+ hasLineFilters: Object.keys(lineFilters).length > 0,
115
+ pathBase: process.env.VELOCIOUS_TEST_DIR ? "test-directory" : "configuration-directory",
116
+ testFileSetHash: timingManifestFileSetHash(discoveredFilePaths)
117
+ })
118
+ }
99
119
 
100
120
  if (groups !== undefined || groupNumber !== undefined) {
101
121
  if (groups === undefined || groupNumber === undefined) {
102
122
  throw new Error("Both --groups and --group-number must be provided together")
103
123
  }
104
124
 
105
- const timingManifest = await loadTimingManifest(profileOptions.timingManifestPath)
106
125
  const splitter = new TestSuiteSplitter({
107
126
  groups,
108
127
  groupNumber,
@@ -111,6 +130,15 @@ export default class VelociousCliCommandsTest extends BaseCommand {
111
130
  timingManifest
112
131
  })
113
132
 
133
+ if (profileOptions.timingManifestPath) {
134
+ const coverage = splitter.getTimingManifestCoverage()
135
+
136
+ console.log(picocolors.cyan(
137
+ `Timing manifest coverage: measured=${coverage.measuredFiles} ` +
138
+ `heuristic=${coverage.heuristicFiles} stale=${coverage.staleEntries}`
139
+ ))
140
+ }
141
+
114
142
  discoveredTestFiles = splitter.getGroupFiles()
115
143
  console.log(picocolors.cyan(`Running group ${groupNumber} of ${groups} (${discoveredTestFiles.length} files)`))
116
144
  }
@@ -158,6 +186,9 @@ export default class VelociousCliCommandsTest extends BaseCommand {
158
186
  process.once("SIGTERM", () => { void handleSignal("SIGTERM") })
159
187
 
160
188
  await testRunner.prepare()
189
+ const effectiveExcludeTagCount = testRunner.getExcludeTagSet().size
190
+
191
+ profiler?.setSelection({excludeTagCount: effectiveExcludeTagCount})
161
192
 
162
193
  if (testRunner.getTestsCount() === 0) {
163
194
  await finalizeProfile("no-tests")
@@ -170,7 +201,7 @@ export default class VelociousCliCommandsTest extends BaseCommand {
170
201
  const lineFilters = testRunner.getLineFilters()
171
202
  const hasLineFilters = Object.keys(lineFilters).length > 0
172
203
  const hasExampleFilters = examplePatterns.length > 0
173
- const hasTagFilters = includeTags.length > 0 || excludeTags.length > 0
204
+ const hasTagFilters = includeTags.length > 0 || effectiveExcludeTagCount > 0
174
205
 
175
206
  if ((hasTagFilters || hasLineFilters || hasExampleFilters) && executedTests === 0) {
176
207
  console.error(picocolors.red("\nNo tests matched the provided filters"))
@@ -277,20 +308,30 @@ export function resolveTestProfileOptions({cwd, profile, profileJsonPath, timing
277
308
  }
278
309
 
279
310
  /**
280
- * Loads an optional JSON timing manifest. Unreadable or malformed input intentionally falls back to heuristics.
311
+ * Loads and validates an explicitly supplied plain JSON timing manifest.
281
312
  * @param {string | undefined} timingManifestPath - Timing manifest path.
282
- * @returns {Promise<ReturnType<typeof JSON.parse> | undefined>} - Parsed manifest when readable and valid JSON.
313
+ * @returns {Promise<Record<string, number> | undefined>} - Canonical manifest, or undefined when not requested.
283
314
  */
284
315
  export async function loadTimingManifest(timingManifestPath) {
285
316
  if (!timingManifestPath) return undefined
286
317
 
318
+ let content
319
+
287
320
  try {
288
- return JSON.parse(await fs.readFile(timingManifestPath, "utf8"))
321
+ content = await fs.readFile(timingManifestPath, "utf8")
289
322
  } catch (error) {
290
- if (error instanceof Error) return undefined
323
+ throw new Error(`Failed to read timing manifest: ${timingManifestPath}`, {cause: error})
324
+ }
325
+
326
+ let parsed
291
327
 
292
- throw error
328
+ try {
329
+ parsed = JSON.parse(content)
330
+ } catch (error) {
331
+ throw new Error(`Failed to parse timing manifest: ${timingManifestPath}`, {cause: error})
293
332
  }
333
+
334
+ return validateTimingManifest(parsed, {source: `Timing manifest ${timingManifestPath}`})
294
335
  }
295
336
 
296
337
  /**
@@ -14,6 +14,7 @@ import CliCommandsLintRelationships from "./node/cli/commands/lint/relationships
14
14
  import CliCommandsRoutes from "./node/cli/commands/routes.js"
15
15
  import CliCommandsServer from "./node/cli/commands/server.js"
16
16
  import CliCommandsTest from "./node/cli/commands/test.js"
17
+ import CliCommandsTestTimingManifestMerge from "./node/cli/commands/test/timing-manifest/merge.js"
17
18
  import CliCommandsBackgroundJobsMain from "./node/cli/commands/background-jobs-main.js"
18
19
  import CliCommandsBackgroundJobsWorker from "./node/cli/commands/background-jobs-worker.js"
19
20
  import CliCommandsBackgroundJobsRunner from "./node/cli/commands/background-jobs-runner.js"
@@ -830,6 +831,15 @@ export default class VelociousEnvironmentHandlerNode extends Base{
830
831
  return await this.forwardCommand(command, CliCommandsTest)
831
832
  }
832
833
 
834
+ /**
835
+ * Runs cli commands test timing manifest merge.
836
+ * @param {import("../cli/base-command.js").default} command - Command.
837
+ * @returns {Promise<ReturnType<typeof JSON.parse>>} - Resolves with the command result.
838
+ */
839
+ async cliCommandsTestTimingManifestMerge(command) {
840
+ return await this.forwardCommand(command, CliCommandsTestTimingManifestMerge)
841
+ }
842
+
833
843
  /**
834
844
  * Runs cli commands background jobs main.
835
845
  * @param {import("../cli/base-command.js").default} command - Command.
@@ -3,6 +3,7 @@
3
3
  import fs from "node:fs/promises"
4
4
  import path from "node:path"
5
5
  import { roundProfileDuration } from "./test-profiler.js"
6
+ import { validateTimingManifest } from "./timing-manifest.js"
6
7
 
7
8
  const FORBIDDEN_PROFILE_FIELDS = new Set([
8
9
  "bind",
@@ -156,20 +157,34 @@ async function atomicWrite(outputPath, content) {
156
157
  export function timingManifestFromProfile(profile) {
157
158
  /** @type {Record<string, number>} */
158
159
  const manifest = {}
160
+ const validatedManifest = validateTimingManifest(profile.timingManifest || {}, {source: "Test profile timing manifest"})
159
161
 
160
- for (const filePath of Object.keys(profile.timingManifest || {}).sort()) {
161
- const durationMs = profile.timingManifest[filePath]
162
-
163
- if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs < 0) {
164
- throw new Error(`Invalid timing manifest duration for ${filePath}`)
165
- }
166
-
162
+ for (const [filePath, durationMs] of Object.entries(validatedManifest)) {
167
163
  manifest[filePath] = roundProfileDuration(durationMs)
168
164
  }
169
165
 
170
166
  return manifest
171
167
  }
172
168
 
169
+ /**
170
+ * Atomically writes a canonical splitter-compatible timing manifest.
171
+ * @param {object} args - Output arguments.
172
+ * @param {string} args.outputPath - Final output path.
173
+ * @param {Record<string, number>} args.timingManifest - Validated or candidate timing manifest.
174
+ * @returns {Promise<void>} - Resolves after atomic replacement.
175
+ */
176
+ export async function writeTimingManifest({outputPath, timingManifest}) {
177
+ const normalized = validateTimingManifest(timingManifest)
178
+ /** @type {Record<string, number>} */
179
+ const rounded = {}
180
+
181
+ for (const [filePath, durationMs] of Object.entries(normalized)) {
182
+ rounded[filePath] = roundProfileDuration(durationMs)
183
+ }
184
+
185
+ await atomicWrite(outputPath, `${JSON.stringify(rounded, null, 2)}\n`)
186
+ }
187
+
173
188
  /**
174
189
  * Atomically writes requested test profile outputs.
175
190
  * @param {object} args - Output options.
@@ -189,7 +204,7 @@ export async function writeTestProfileOutputs({profile, profileJsonPath, timingM
189
204
  }
190
205
 
191
206
  if (timingManifestOutputPath) {
192
- await atomicWrite(timingManifestOutputPath, `${JSON.stringify(timingManifest, null, 2)}\n`)
207
+ await writeTimingManifest({outputPath: timingManifestOutputPath, timingManifest})
193
208
  }
194
209
  }
195
210
 
@@ -4,6 +4,7 @@ import { createHash } from "node:crypto"
4
4
  import path from "node:path"
5
5
  import { registerTestProfileContextReader } from "./test-profile-context.js"
6
6
  import { validateTestActivityName } from "./test-profile-activity.js"
7
+ import { compareTimingManifestPaths } from "./timing-manifest.js"
7
8
 
8
9
  /** @typedef {"passed" | "failed" | "interrupted" | "timed-out"} TestProfileAttemptStatus */
9
10
  /** @typedef {{user: number, system: number}} ProcessCpuUsage */
@@ -625,7 +626,7 @@ export default class TestProfiler {
625
626
  attemptsMs: roundProfileDuration(file.attemptsMs),
626
627
  totalMs: roundProfileDuration(file.totalMs)
627
628
  }))
628
- .sort((fileA, fileB) => fileA.path.localeCompare(fileB.path))
629
+ .sort((fileA, fileB) => compareTimingManifestPaths(fileA.path, fileB.path))
629
630
  const timingManifest = Object.fromEntries(files.map((file) => [file.path, file.totalMs]))
630
631
  const attempts = this._tests.reduce((sum, test) => sum + test.attempts.length, 0)
631
632
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import path from "path"
4
4
  import restArgsError from "../utils/rest-args-error.js"
5
+ import { canonicalTimingManifestPath, compareTimingManifestPaths } from "./timing-manifest.js"
5
6
 
6
7
  /**
7
8
  * SplitterFileEntry type.
@@ -95,14 +96,13 @@ export default class TestSuiteSplitter {
95
96
  * @returns {number} - Weight value.
96
97
  */
97
98
  computeWeight(filePath) {
98
- const relativePath = this.normalizeRelativePath(path.relative(this._baseDirectory, filePath))
99
- const projectRelativePath = this.normalizeRelativePath(path.join(path.basename(this._baseDirectory), relativePath))
100
- const duration = this._timingManifest[relativePath] ?? this._timingManifest[projectRelativePath]
99
+ const duration = this.timingManifestDuration(filePath)
101
100
 
102
- if (duration !== undefined) {
101
+ if (duration !== undefined && duration > 0) {
103
102
  return duration
104
103
  }
105
104
 
105
+ const relativePath = this.heuristicRelativePath(filePath)
106
106
  let weight = DEFAULT_WEIGHT
107
107
 
108
108
  // Extract the type directory from the relative path.
@@ -139,8 +139,12 @@ export default class TestSuiteSplitter {
139
139
  }
140
140
 
141
141
  for (const [filePath, duration] of Object.entries(timingManifest)) {
142
- if (typeof duration === "number" && Number.isFinite(duration) && duration > 0) {
142
+ if (typeof duration !== "number" || !Number.isFinite(duration) || duration < 0) continue
143
+
144
+ try {
143
145
  normalized[this.normalizeRelativePath(filePath)] = duration
146
+ } catch (error) {
147
+ if (!(error instanceof Error)) throw error
144
148
  }
145
149
  }
146
150
 
@@ -153,11 +157,91 @@ export default class TestSuiteSplitter {
153
157
  * @returns {string} - Normalized relative test path.
154
158
  */
155
159
  normalizeRelativePath(filePath) {
156
- return filePath
160
+ return canonicalTimingManifestPath(filePath)
161
+ }
162
+
163
+ /**
164
+ * Returns the permissive relative path used only for heuristic classification.
165
+ * @param {string} filePath - Absolute test file path.
166
+ * @returns {string} - Portable relative path which may escape the profiling base.
167
+ */
168
+ heuristicRelativePath(filePath) {
169
+ return path.relative(this._baseDirectory, filePath)
157
170
  .replaceAll("\\", "/")
158
171
  .replace(/^\.\//, "")
159
172
  }
160
173
 
174
+ /**
175
+ * Returns a canonical manifest key when the file is representable under the profiling base.
176
+ * @param {string} filePath - Absolute test file path.
177
+ * @returns {string | undefined} - Canonical relative path, or undefined for an external file.
178
+ */
179
+ manifestRelativePath(filePath) {
180
+ const relativePath = path.relative(this._baseDirectory, filePath)
181
+
182
+ if (!relativePath || path.isAbsolute(relativePath)) return
183
+ if (relativePath === ".." || relativePath.startsWith(`..${path.sep}`)) return
184
+
185
+ return this.normalizeRelativePath(relativePath)
186
+ }
187
+
188
+ /**
189
+ * Returns the first manifest entry matching a discovered test file.
190
+ * @param {string} filePath - Absolute test file path.
191
+ * @returns {number | undefined} - Recorded duration, including zero.
192
+ */
193
+ timingManifestDuration(filePath) {
194
+ for (const manifestPath of this.timingManifestPaths(filePath)) {
195
+ if (Object.hasOwn(this._timingManifest, manifestPath)) return this._timingManifest[manifestPath]
196
+ }
197
+
198
+ return undefined
199
+ }
200
+
201
+ /**
202
+ * Returns compatible manifest keys for one discovered file.
203
+ * @param {string} filePath - Absolute test file path.
204
+ * @returns {string[]} - Canonical keys in matching priority order.
205
+ */
206
+ timingManifestPaths(filePath) {
207
+ const relativePath = this.manifestRelativePath(filePath)
208
+
209
+ if (!relativePath) return []
210
+
211
+ const projectRelativePath = this.normalizeRelativePath(path.join(path.basename(this._baseDirectory), relativePath))
212
+
213
+ return relativePath === projectRelativePath ? [relativePath] : [relativePath, projectRelativePath]
214
+ }
215
+
216
+ /**
217
+ * Summarizes timing-history coverage for the complete discovered suite.
218
+ * @returns {{heuristicFiles: number, measuredFiles: number, staleEntries: number}} - Compact coverage counts.
219
+ */
220
+ getTimingManifestCoverage() {
221
+ const matchedManifestPaths = new Set()
222
+ let measuredFiles = 0
223
+
224
+ for (const filePath of this._testFiles) {
225
+ let matchedDuration
226
+
227
+ for (const manifestPath of this.timingManifestPaths(filePath)) {
228
+ if (!Object.hasOwn(this._timingManifest, manifestPath)) continue
229
+
230
+ matchedManifestPaths.add(manifestPath)
231
+ matchedDuration = this._timingManifest[manifestPath]
232
+ break
233
+ }
234
+
235
+ if (matchedDuration !== undefined && matchedDuration > 0) measuredFiles++
236
+ }
237
+
238
+ return {
239
+ heuristicFiles: this._testFiles.length - measuredFiles,
240
+ measuredFiles,
241
+ staleEntries: Object.keys(this._timingManifest).length - matchedManifestPaths.size
242
+ }
243
+ }
244
+
161
245
  /**
162
246
  * Sorts files by weight descending, then by path for determinism.
163
247
  * @param {SplitterFileEntry[]} files - Weighted files.
@@ -169,7 +253,7 @@ export default class TestSuiteSplitter {
169
253
  return b.weight - a.weight
170
254
  }
171
255
 
172
- return a.filePath.localeCompare(b.filePath)
256
+ return compareTimingManifestPaths(a.filePath, b.filePath)
173
257
  })
174
258
  }
175
259