velocious 1.0.581 → 1.0.582

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 (27) hide show
  1. package/README.md +17 -1
  2. package/build/database/drivers/base.js +134 -13
  3. package/build/environment-handlers/node/cli/commands/test.js +21 -2
  4. package/build/frontend-model-controller.js +131 -28
  5. package/build/src/database/drivers/base.d.ts +32 -0
  6. package/build/src/database/drivers/base.d.ts.map +1 -1
  7. package/build/src/database/drivers/base.js +109 -17
  8. package/build/src/environment-handlers/node/cli/commands/test.d.ts +6 -0
  9. package/build/src/environment-handlers/node/cli/commands/test.d.ts.map +1 -1
  10. package/build/src/environment-handlers/node/cli/commands/test.js +22 -3
  11. package/build/src/frontend-model-controller.d.ts +40 -0
  12. package/build/src/frontend-model-controller.d.ts.map +1 -1
  13. package/build/src/frontend-model-controller.js +116 -28
  14. package/build/src/testing/test-filter-parser.d.ts +5 -0
  15. package/build/src/testing/test-filter-parser.d.ts.map +1 -1
  16. package/build/src/testing/test-filter-parser.js +19 -2
  17. package/build/src/testing/test-suite-splitter.d.ts +16 -1
  18. package/build/src/testing/test-suite-splitter.d.ts.map +1 -1
  19. package/build/src/testing/test-suite-splitter.js +38 -3
  20. package/build/testing/test-filter-parser.js +21 -1
  21. package/build/testing/test-suite-splitter.js +44 -2
  22. package/package.json +3 -1
  23. package/src/database/drivers/base.js +134 -13
  24. package/src/environment-handlers/node/cli/commands/test.js +21 -2
  25. package/src/frontend-model-controller.js +131 -28
  26. package/src/testing/test-filter-parser.js +21 -1
  27. package/src/testing/test-suite-splitter.js +44 -2
@@ -118,6 +118,14 @@
118
118
  * @property {string[]} updateColumns - Columns to update on conflict.
119
119
  */
120
120
 
121
+ /**
122
+ * SqlTokenResult type.
123
+ * @typedef {object} SqlTokenResult
124
+ * @property {boolean} incomplete - Whether the scan hit its bound before finishing trivia/token parsing.
125
+ * @property {string | undefined} token - Lowercased token when parsing completed; undefined when no token was found.
126
+ * @property {number} index - Index immediately after the parsed token or trivia.
127
+ */
128
+
121
129
  import BacktraceCleaner from "../../utils/backtrace-cleaner.js"
122
130
  import { getDatabaseAnnotations } from "../annotations.js"
123
131
  import { formatDateForDatabase } from "../datetime-storage.js"
@@ -134,6 +142,11 @@ import TableForeignKey from "../table-data/table-foreign-key.js"
134
142
  import wait from "awaitery/build/wait.js"
135
143
  import {optionalPositiveInteger} from "typanic"
136
144
 
145
+ /** Maximum characters inspected when building the debug SQL preview. */
146
+ const SQL_PREVIEW_SCAN_LIMIT = 4096
147
+ /** Maximum characters inspected when deciding whether a statement invalidates schema metadata. */
148
+ const SCHEMA_INVALIDATION_SCAN_LIMIT = 8192
149
+
137
150
  /**
138
151
  * Marks a callback failure that happened after the owning transaction was durably committed.
139
152
  * The public transaction boundary unwraps it before deadlock classification.
@@ -1604,13 +1617,25 @@ export default class VelociousDatabaseDriversBase {
1604
1617
  }
1605
1618
  }
1606
1619
 
1620
+ /**
1621
+ * Returns a bounded prefix of `sql` for lightweight diagnostic scanning.
1622
+ * @param {string} sql - SQL string.
1623
+ * @param {number} limit - Maximum code units to inspect.
1624
+ * @returns {string} - Prefix of `sql`.
1625
+ */
1626
+ _diagnosticSqlPrefix(sql, limit) {
1627
+ return sql.length <= limit ? sql : sql.slice(0, limit)
1628
+ }
1629
+
1607
1630
  /**
1608
1631
  * Runs debug sql preview.
1609
1632
  * @param {string} sql - SQL to preview.
1610
1633
  * @returns {string} - Normalized truncated SQL preview for diagnostics.
1611
1634
  */
1612
1635
  _debugSqlPreview(sql) {
1613
- return sql
1636
+ const prefix = this._diagnosticSqlPrefix(sql, SQL_PREVIEW_SCAN_LIMIT)
1637
+
1638
+ return prefix
1614
1639
  .replace(/\s+/g, " ")
1615
1640
  .trim()
1616
1641
  .slice(0, 500)
@@ -1664,25 +1689,121 @@ export default class VelociousDatabaseDriversBase {
1664
1689
  .replace(/"/g, "'")
1665
1690
  }
1666
1691
 
1692
+ /**
1693
+ * Reads the next SQL token starting at `startIndex`, skipping leading trivia
1694
+ * (BOM, whitespace, block comments, line comments). If the scan cannot finish
1695
+ * skipping trivia before `limit`, the result is marked incomplete so callers
1696
+ * can conservatively treat the statement as schema-invalidating.
1697
+ * @param {string} sql - SQL string.
1698
+ * @param {number} startIndex - Index to start scanning.
1699
+ * @param {number} limit - Maximum absolute index to scan while skipping leading trivia.
1700
+ * @returns {SqlTokenResult} - Token result.
1701
+ */
1702
+ _readSqlToken(sql, startIndex, limit) {
1703
+ let i = startIndex
1704
+ const len = sql.length
1705
+
1706
+ while (i < len && i < limit) {
1707
+ const char = sql[i]
1708
+
1709
+ if (char === "\ufeff" || /\s/.test(char)) {
1710
+ i++
1711
+ continue
1712
+ }
1713
+
1714
+ if (char === "/" && sql[i + 1] === "*") {
1715
+ const close = sql.indexOf("*/", i + 2)
1716
+
1717
+ if (close === -1 || close + 2 > limit) {
1718
+ return {incomplete: true, index: i, token: undefined}
1719
+ }
1720
+
1721
+ i = close + 2
1722
+ continue
1723
+ }
1724
+
1725
+ if (char === "-" && sql[i + 1] === "-") {
1726
+ const newline = sql.indexOf("\n", i + 2)
1727
+
1728
+ if (newline === -1) {
1729
+ return {incomplete: false, index: len, token: undefined}
1730
+ }
1731
+
1732
+ if (newline + 1 > limit) {
1733
+ return {incomplete: true, index: i, token: undefined}
1734
+ }
1735
+
1736
+ i = newline + 1
1737
+ continue
1738
+ }
1739
+
1740
+ let token = ""
1741
+
1742
+ while (i < len) {
1743
+ const c = sql[i]
1744
+
1745
+ if (/\s/.test(c) || c === "\ufeff") break
1746
+ if (c === "/" && sql[i + 1] === "*") break
1747
+ if (c === "-" && sql[i + 1] === "-") break
1748
+
1749
+ token += c
1750
+ i++
1751
+ }
1752
+
1753
+ return {incomplete: false, token: token.toLowerCase(), index: i}
1754
+ }
1755
+
1756
+ if (i >= len) {
1757
+ return {incomplete: false, index: len, token: undefined}
1758
+ }
1759
+
1760
+ return {incomplete: true, index: i, token: undefined}
1761
+ }
1762
+
1667
1763
  /**
1668
1764
  * Runs schema cache invalidating sql.
1669
1765
  * @param {string} sql - SQL string.
1670
1766
  * @returns {boolean} - Whether the SQL should invalidate schema metadata.
1671
1767
  */
1672
1768
  _schemaCacheInvalidatingSql(sql) {
1673
- const normalized = sql
1674
- .trim()
1675
- .replace(/^\ufeff/, "")
1676
- .replace(/\/\*[\s\S]*?\*\//g, " ")
1677
- .replace(/--[^\n]*(\n|$)/g, " ")
1678
- .replace(/\s+/g, " ")
1679
- .toLowerCase()
1769
+ const first = this._readSqlToken(sql, 0, SCHEMA_INVALIDATION_SCAN_LIMIT)
1680
1770
 
1681
- if (!normalized) return false
1682
- if (/^(create|alter|drop|rename)\b/.test(normalized)) return true
1683
- if (/^comment\s+on\b/.test(normalized)) return true
1684
- if (/^exec(?:ute)?\s+sp_rename\b/.test(normalized)) return true
1685
- if (/^if\b[\s\S]*\bbegin\s+(create|alter|drop|rename)\b/.test(normalized)) return true
1771
+ if (first.incomplete) return true
1772
+
1773
+ const firstToken = first.token
1774
+
1775
+ if (!firstToken) return false
1776
+ if (/^(create|alter|drop|rename)$/.test(firstToken)) return true
1777
+
1778
+ if (firstToken === "comment") {
1779
+ const next = this._readSqlToken(sql, first.index, SCHEMA_INVALIDATION_SCAN_LIMIT)
1780
+
1781
+ return next.incomplete || next.token === "on"
1782
+ }
1783
+
1784
+ if (firstToken === "exec" || firstToken === "execute") {
1785
+ const next = this._readSqlToken(sql, first.index, SCHEMA_INVALIDATION_SCAN_LIMIT)
1786
+
1787
+ return next.incomplete || next.token === "sp_rename"
1788
+ }
1789
+
1790
+ if (firstToken === "if") {
1791
+ let index = first.index
1792
+
1793
+ while (true) {
1794
+ const result = this._readSqlToken(sql, index, SCHEMA_INVALIDATION_SCAN_LIMIT)
1795
+
1796
+ if (result.incomplete) return true
1797
+ if (!result.token) return false
1798
+ if (result.token === "begin") {
1799
+ const ddlResult = this._readSqlToken(sql, result.index, SCHEMA_INVALIDATION_SCAN_LIMIT)
1800
+
1801
+ return ddlResult.incomplete || /^(create|alter|drop|rename)$/.test(ddlResult.token || "")
1802
+ }
1803
+
1804
+ index = result.index
1805
+ }
1806
+ }
1686
1807
 
1687
1808
  return false
1688
1809
  }
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import BaseCommand from "../../../../cli/base-command.js"
4
+ import fs from "fs/promises"
4
5
  import picocolors from "picocolors"
5
6
  import TestFilesFinder from "../../../../testing/test-files-finder.js"
6
7
  import TestRunner from "../../../../testing/test-runner.js"
@@ -26,7 +27,7 @@ export default class VelociousCliCommandsTest extends BaseCommand {
26
27
  directories.push(`${this.directory()}/spec`)
27
28
  }
28
29
 
29
- const {includeTags, excludeTags, examplePatterns, filteredProcessArgs, groups, groupNumber} = parseFilters(this.processArgs || [])
30
+ const {includeTags, excludeTags, examplePatterns, filteredProcessArgs, groups, groupNumber, timingManifestPath} = parseFilters(this.processArgs || [])
30
31
  const testFilesFinder = new TestFilesFinder({directory, directories, processArgs: filteredProcessArgs})
31
32
  let testFiles = await testFilesFinder.findTestFiles()
32
33
 
@@ -35,7 +36,8 @@ export default class VelociousCliCommandsTest extends BaseCommand {
35
36
  throw new Error("Both --groups and --group-number must be provided together")
36
37
  }
37
38
 
38
- const splitter = new TestSuiteSplitter({groups, groupNumber, testFiles, baseDirectory: directory})
39
+ const timingManifest = await loadTimingManifest(timingManifestPath)
40
+ const splitter = new TestSuiteSplitter({groups, groupNumber, testFiles, baseDirectory: directory, timingManifest})
39
41
 
40
42
  testFiles = splitter.getGroupFiles()
41
43
  console.log(picocolors.cyan(`Running group ${groupNumber} of ${groups} (${testFiles.length} files)`))
@@ -138,6 +140,23 @@ export default class VelociousCliCommandsTest extends BaseCommand {
138
140
  }
139
141
  }
140
142
 
143
+ /**
144
+ * Loads an optional JSON timing manifest. Unreadable or malformed input intentionally falls back to heuristics.
145
+ * @param {string | undefined} timingManifestPath - Timing manifest path.
146
+ * @returns {Promise<ReturnType<typeof JSON.parse> | undefined>} - Parsed manifest when readable and valid JSON.
147
+ */
148
+ export async function loadTimingManifest(timingManifestPath) {
149
+ if (!timingManifestPath) return undefined
150
+
151
+ try {
152
+ return JSON.parse(await fs.readFile(timingManifestPath, "utf8"))
153
+ } catch (error) {
154
+ if (error instanceof Error) return undefined
155
+
156
+ throw error
157
+ }
158
+ }
159
+
141
160
  /**
142
161
  * Resolves how many slowest tests to report from the `VELOCIOUS_SLOW_TEST_COUNT`
143
162
  * env value: defaults to 10 when unset; 0 (or an unparseable value) disables the
@@ -72,6 +72,12 @@ import {RansackQueryError, normalizeRansackGroup, parseRansackSort} from "./util
72
72
  * @property {import("./frontend-model-resource/base-resource.js").default} [resource] - Resource providing query hooks.
73
73
  */
74
74
  /** @typedef {import("./database/query/model-class-query.js").default & Record<symbol, Set<string> | undefined>} FrontendModelQueryMetadata */
75
+ /**
76
+ * @callback FrontendModelSerializationResourceInstanceHook
77
+ * @param {import("./database/record/index.js").default} model - Model instance being serialized.
78
+ * @param {import("./frontend-model-resource/base-resource.js").default | null} resource - Resolved serialization resource instance, if any.
79
+ * @returns {void}
80
+ */
75
81
 
76
82
  /**
77
83
  * Runs normalize frontend model preload.
@@ -603,6 +609,17 @@ export default class FrontendModelController extends Controller {
603
609
  * own arguments rather than the route metadata. Only set on the shared-endpoint path.
604
610
  * @type {Record<string, ReturnType<typeof JSON.parse>> | undefined} */
605
611
  _frontendModelCustomCommandClientArguments = undefined
612
+ /**
613
+ * Request-scoped cache for serialization resource instances.
614
+ * Keyed by model class, then by whether the resource is for a related model
615
+ * (so self-referential relationships do not accidentally reuse root params).
616
+ * @type {Map<typeof import("./database/record/index.js").default, Map<boolean, import("./frontend-model-resource/base-resource.js").default>> | undefined} */
617
+ _frontendModelSerializationResourceInstances = undefined
618
+ /**
619
+ * Optional per-instance hook invoked for every serialization resource instance
620
+ * resolution. Intended for tests and benchmarks; absent in production.
621
+ * @type {FrontendModelSerializationResourceInstanceHook | null | undefined} */
622
+ _frontendModelSerializationResourceInstanceHook = undefined
606
623
 
607
624
  /**
608
625
  * Runs frontend model params.
@@ -628,15 +645,18 @@ export default class FrontendModelController extends Controller {
628
645
  async withFrontendModelParams(params, callback) {
629
646
  const previousOverride = this._frontendModelParamsOverride
630
647
  const previousParams = this._frontendModelParams
648
+ const previousSerializationResourceInstances = this._frontendModelSerializationResourceInstances
631
649
 
632
650
  this._frontendModelParamsOverride = params
633
651
  this._frontendModelParams = undefined
652
+ this._frontendModelSerializationResourceInstances = undefined
634
653
 
635
654
  try {
636
655
  return await callback()
637
656
  } finally {
638
657
  this._frontendModelParamsOverride = previousOverride
639
658
  this._frontendModelParams = previousParams
659
+ this._frontendModelSerializationResourceInstances = previousSerializationResourceInstances
640
660
  }
641
661
  }
642
662
 
@@ -2792,47 +2812,130 @@ export default class FrontendModelController extends Controller {
2792
2812
  return serializedAttributes
2793
2813
  }
2794
2814
 
2815
+ /**
2816
+ * Returns the request-scoped serialization resource instance cache.
2817
+ * @returns {Map<typeof import("./database/record/index.js").default, Map<boolean, import("./frontend-model-resource/base-resource.js").default>>} - Cache.
2818
+ */
2819
+ _frontendModelSerializationResourceInstancesMap() {
2820
+ if (!this._frontendModelSerializationResourceInstances) {
2821
+ this._frontendModelSerializationResourceInstances = new Map()
2822
+ }
2823
+
2824
+ return this._frontendModelSerializationResourceInstances
2825
+ }
2826
+
2827
+ /**
2828
+ * Looks up a cached serialization resource instance.
2829
+ * @param {typeof import("./database/record/index.js").default} modelClass - Model class.
2830
+ * @param {boolean} isRelated - Whether the resource is for a related (non-root) model.
2831
+ * @returns {import("./frontend-model-resource/base-resource.js").default | undefined} - Cached resource or undefined.
2832
+ */
2833
+ _cachedSerializationResourceInstance(modelClass, isRelated) {
2834
+ return this._frontendModelSerializationResourceInstancesMap().get(modelClass)?.get(isRelated)
2835
+ }
2836
+
2837
+ /**
2838
+ * Stores a serialization resource instance in the request-scoped cache.
2839
+ * @param {typeof import("./database/record/index.js").default} modelClass - Model class.
2840
+ * @param {boolean} isRelated - Whether the resource is for a related (non-root) model.
2841
+ * @param {import("./frontend-model-resource/base-resource.js").default} resource - Resource instance.
2842
+ * @returns {void}
2843
+ */
2844
+ _setCachedSerializationResourceInstance(modelClass, isRelated, resource) {
2845
+ const byClass = this._frontendModelSerializationResourceInstancesMap()
2846
+ let byRelated = byClass.get(modelClass)
2847
+
2848
+ if (!byRelated) {
2849
+ byRelated = new Map()
2850
+ byClass.set(modelClass, byRelated)
2851
+ }
2852
+
2853
+ byRelated.set(isRelated, resource)
2854
+ }
2855
+
2856
+ /**
2857
+ * Sets a per-instance hook invoked for every serialization resource instance
2858
+ * resolution. The hook is scoped to this controller; it never affects other
2859
+ * controller instances. Passing `null` clears the hook.
2860
+ * @param {FrontendModelSerializationResourceInstanceHook | null} hook - Hook callback or null.
2861
+ * @returns {() => void} - Cleanup function that restores the previous hook.
2862
+ */
2863
+ setSerializationResourceInstanceHook(hook) {
2864
+ const previousHook = this._frontendModelSerializationResourceInstanceHook
2865
+
2866
+ this._frontendModelSerializationResourceInstanceHook = hook
2867
+
2868
+ return () => {
2869
+ this._frontendModelSerializationResourceInstanceHook = previousHook
2870
+ }
2871
+ }
2872
+
2795
2873
  /**
2796
2874
  * Runs serialization resource instance for model.
2797
2875
  * @param {import("./database/record/index.js").default} model - Model instance.
2798
2876
  * @returns {import("./frontend-model-resource/base-resource.js").default | null} - Resource instance or null.
2799
2877
  */
2800
2878
  _serializationResourceInstanceForModel(model) {
2801
- const resource = this.frontendModelResourceInstance()
2879
+ const modelClass = /** @type {typeof import("./database/record/index.js").default} */ (model.constructor)
2880
+ const isRelated = modelClass !== this.frontendModelClass()
2881
+ const cachedResource = this._cachedSerializationResourceInstance(modelClass, isRelated)
2882
+
2883
+ if (cachedResource) {
2884
+ if (this._frontendModelSerializationResourceInstanceHook) {
2885
+ this._frontendModelSerializationResourceInstanceHook(model, cachedResource)
2886
+ }
2802
2887
 
2803
- if (resource.modelClass() === model.constructor) {
2804
- return resource
2888
+ return cachedResource
2805
2889
  }
2806
2890
 
2807
- const configuration = this.getConfiguration()
2808
- const backendProjects = configuration.getBackendProjects()
2809
- const modelClassName = /** @type {typeof import("./database/record/index.js").default} */ (model.constructor).getModelName()
2891
+ /** @type {import("./frontend-model-resource/base-resource.js").default | null} */
2892
+ let resource
2810
2893
 
2811
- for (const backendProject of backendProjects) {
2812
- const resources = frontendModelResourcesWithBuiltInsForBackendProject(backendProject)
2813
- const resourceDefinition = resources[modelClassName]
2814
- const resourceClass = resourceDefinition ? frontendModelResourceClassFromDefinition(resourceDefinition) : null
2815
-
2816
- if (resourceClass) {
2817
- return new resourceClass({
2818
- ability: this.currentAbility(),
2819
- // Propagate the controller so a related/preloaded model's serialization
2820
- // resource can use request context (e.g. `requestBaseUrl()` for signed
2821
- // download URLs). Without it, any `<attr>Attribute` method that reaches
2822
- // for the controller throws "requires a controller instance." when a
2823
- // relationship is serialized as a preload.
2824
- controller: this,
2825
- context: this.currentAbility()?.getContext() || {},
2826
- locals: this.currentAbility()?.getLocals() || {},
2827
- modelClass: /** @type {typeof import("./database/record/index.js").default} */ (model.constructor),
2828
- modelName: modelClassName,
2829
- params: {},
2830
- resourceConfiguration: resourceClass.resourceConfig()
2831
- })
2894
+ if (!isRelated) {
2895
+ resource = this.frontendModelResourceInstance()
2896
+
2897
+ this._setCachedSerializationResourceInstance(modelClass, false, resource)
2898
+ } else {
2899
+ const configuration = this.getConfiguration()
2900
+ const backendProjects = configuration.getBackendProjects()
2901
+ const modelClassName = modelClass.getModelName()
2902
+
2903
+ resource = null
2904
+
2905
+ for (const backendProject of backendProjects) {
2906
+ const resources = frontendModelResourcesWithBuiltInsForBackendProject(backendProject)
2907
+ const resourceDefinition = resources[modelClassName]
2908
+ const resourceClass = resourceDefinition ? frontendModelResourceClassFromDefinition(resourceDefinition) : null
2909
+
2910
+ if (resourceClass) {
2911
+ resource = new resourceClass({
2912
+ ability: this.currentAbility(),
2913
+ // Propagate the controller so a related/preloaded model's serialization
2914
+ // resource can use request context (e.g. `requestBaseUrl()` for signed
2915
+ // download URLs). Without it, any `<attr>Attribute` method that reaches
2916
+ // for the controller throws "requires a controller instance." when a
2917
+ // relationship is serialized as a preload.
2918
+ controller: this,
2919
+ context: this.currentAbility()?.getContext() || {},
2920
+ locals: this.currentAbility()?.getLocals() || {},
2921
+ modelClass,
2922
+ modelName: modelClassName,
2923
+ params: {},
2924
+ resourceConfiguration: resourceClass.resourceConfig()
2925
+ })
2926
+
2927
+ this._setCachedSerializationResourceInstance(modelClass, true, resource)
2928
+
2929
+ break
2930
+ }
2832
2931
  }
2833
2932
  }
2834
2933
 
2835
- return null
2934
+ if (this._frontendModelSerializationResourceInstanceHook) {
2935
+ this._frontendModelSerializationResourceInstanceHook(model, resource)
2936
+ }
2937
+
2938
+ return resource
2836
2939
  }
2837
2940
 
2838
2941
  /**
@@ -7,6 +7,7 @@
7
7
  * @property {string[]} filteredProcessArgs - Remaining process args with filter flags removed.
8
8
  * @property {number | undefined} groups - Total number of groups for test splitting.
9
9
  * @property {number | undefined} groupNumber - Which group to run (1-indexed).
10
+ * @property {string | undefined} timingManifestPath - JSON timing manifest path.
10
11
  */
11
12
  // @ts-check
12
13
 
@@ -15,6 +16,7 @@ const EXCLUDE_TAG_FLAGS = new Set(["--exclude-tag", "--skip-tag", "-x"])
15
16
  const EXAMPLE_FLAGS = new Set(["--example", "--name", "-e"])
16
17
  const GROUPS_FLAGS = new Set(["--groups"])
17
18
  const GROUP_NUMBER_FLAGS = new Set(["--group-number"])
19
+ const TIMING_MANIFEST_FLAGS = new Set(["--timing-manifest"])
18
20
 
19
21
  /**
20
22
  * Runs split tags.
@@ -79,6 +81,8 @@ export function parseFilters(processArgs) {
79
81
  * Defines groupNumber.
80
82
  * @type {number | undefined} */
81
83
  let groupNumber
84
+ /** @type {string | undefined} */
85
+ let timingManifestPath
82
86
 
83
87
  let inRestArgs = false
84
88
 
@@ -181,6 +185,21 @@ export function parseFilters(processArgs) {
181
185
  groupNumber = parseInt(arg.slice("--group-number=".length), 10)
182
186
  continue
183
187
  }
188
+
189
+ if (TIMING_MANIFEST_FLAGS.has(arg)) {
190
+ const nextValue = processArgs[i + 1]
191
+
192
+ if (nextValue && !nextValue.startsWith("-")) {
193
+ timingManifestPath = nextValue
194
+ i++
195
+ }
196
+ continue
197
+ }
198
+
199
+ if (arg.startsWith("--timing-manifest=")) {
200
+ timingManifestPath = arg.slice("--timing-manifest=".length)
201
+ continue
202
+ }
184
203
  }
185
204
 
186
205
  filteredProcessArgs.push(arg)
@@ -192,6 +211,7 @@ export function parseFilters(processArgs) {
192
211
  examplePatterns,
193
212
  filteredProcessArgs,
194
213
  groups,
195
- groupNumber
214
+ groupNumber,
215
+ timingManifestPath
196
216
  }
197
217
  }
@@ -46,8 +46,9 @@ export default class TestSuiteSplitter {
46
46
  * @param {number} args.groupNumber - Which group to return (1-indexed).
47
47
  * @param {string[]} args.testFiles - All discovered test file paths.
48
48
  * @param {string} [args.baseDirectory] - Base directory for relative path computation.
49
+ * @param {ReturnType<typeof JSON.parse>} [args.timingManifest] - Relative test paths mapped to durations.
49
50
  */
50
- constructor({groups, groupNumber, testFiles, baseDirectory, ...restArgs}) {
51
+ constructor({groups, groupNumber, testFiles, baseDirectory, timingManifest, ...restArgs}) {
51
52
  restArgsError(restArgs)
52
53
 
53
54
  if (!Number.isInteger(groups) || groups < 1) {
@@ -62,6 +63,7 @@ export default class TestSuiteSplitter {
62
63
  this._groupNumber = groupNumber
63
64
  this._testFiles = testFiles
64
65
  this._baseDirectory = baseDirectory || process.cwd()
66
+ this._timingManifest = this.normalizeTimingManifest(timingManifest)
65
67
  }
66
68
 
67
69
  /**
@@ -93,7 +95,14 @@ export default class TestSuiteSplitter {
93
95
  * @returns {number} - Weight value.
94
96
  */
95
97
  computeWeight(filePath) {
96
- const relativePath = path.relative(this._baseDirectory, filePath).split(path.sep).join("/")
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]
101
+
102
+ if (duration !== undefined) {
103
+ return duration
104
+ }
105
+
97
106
  let weight = DEFAULT_WEIGHT
98
107
 
99
108
  // Extract the type directory from the relative path.
@@ -116,6 +125,39 @@ export default class TestSuiteSplitter {
116
125
  return weight
117
126
  }
118
127
 
128
+ /**
129
+ * Keeps only usable positive finite duration entries keyed by normalized relative path.
130
+ * @param {ReturnType<typeof JSON.parse>} timingManifest - Parsed timing manifest.
131
+ * @returns {Record<string, number>} - Valid normalized duration weights.
132
+ */
133
+ normalizeTimingManifest(timingManifest) {
134
+ /** @type {Record<string, number>} */
135
+ const normalized = {}
136
+
137
+ if (!timingManifest || typeof timingManifest !== "object" || Array.isArray(timingManifest)) {
138
+ return normalized
139
+ }
140
+
141
+ for (const [filePath, duration] of Object.entries(timingManifest)) {
142
+ if (typeof duration === "number" && Number.isFinite(duration) && duration > 0) {
143
+ normalized[this.normalizeRelativePath(filePath)] = duration
144
+ }
145
+ }
146
+
147
+ return normalized
148
+ }
149
+
150
+ /**
151
+ * Normalizes a relative test path to the manifest's portable slash format.
152
+ * @param {string} filePath - Relative test path.
153
+ * @returns {string} - Normalized relative test path.
154
+ */
155
+ normalizeRelativePath(filePath) {
156
+ return filePath
157
+ .replaceAll("\\", "/")
158
+ .replace(/^\.\//, "")
159
+ }
160
+
119
161
  /**
120
162
  * Sorts files by weight descending, then by path for determinism.
121
163
  * @param {SplitterFileEntry[]} files - Weighted files.