@ultimat3/cli 29.0.0 → 29.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "29.0.0",
3
+ "version": "29.2.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,33 +39,33 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@babel/core": "^8.0.7",
42
- "@ultimat3/action": "29.0.0",
43
- "@ultimat3/admin": "29.0.0",
44
- "@ultimat3/ai": "29.0.0",
45
- "@ultimat3/auth": "29.0.0",
46
- "@ultimat3/cache": "29.0.0",
47
- "@ultimat3/core": "29.0.0",
48
- "@ultimat3/db": "29.0.0",
49
- "@ultimat3/entity": "29.0.0",
50
- "@ultimat3/flags": "29.0.0",
51
- "@ultimat3/http": "29.0.0",
52
- "@ultimat3/i18n": "29.0.0",
53
- "@ultimat3/jobs": "29.0.0",
54
- "@ultimat3/mail": "29.0.0",
55
- "@ultimat3/manifest": "29.0.0",
56
- "@ultimat3/mcp": "29.0.0",
57
- "@ultimat3/money": "29.0.0",
58
- "@ultimat3/notify": "29.0.0",
59
- "@ultimat3/policy": "29.0.0",
60
- "@ultimat3/pwa": "29.0.0",
61
- "@ultimat3/query": "29.0.0",
62
- "@ultimat3/realtime": "29.0.0",
63
- "@ultimat3/render": "29.0.0",
64
- "@ultimat3/schema": "29.0.0",
65
- "@ultimat3/seo": "29.0.0",
66
- "@ultimat3/storage": "29.0.0",
67
- "@ultimat3/testing": "29.0.0",
68
- "@ultimat3/time": "29.0.0",
42
+ "@ultimat3/action": "29.2.0",
43
+ "@ultimat3/admin": "29.2.0",
44
+ "@ultimat3/ai": "29.2.0",
45
+ "@ultimat3/auth": "29.2.0",
46
+ "@ultimat3/cache": "29.2.0",
47
+ "@ultimat3/core": "29.2.0",
48
+ "@ultimat3/db": "29.2.0",
49
+ "@ultimat3/entity": "29.2.0",
50
+ "@ultimat3/flags": "29.2.0",
51
+ "@ultimat3/http": "29.2.0",
52
+ "@ultimat3/i18n": "29.2.0",
53
+ "@ultimat3/jobs": "29.2.0",
54
+ "@ultimat3/mail": "29.2.0",
55
+ "@ultimat3/manifest": "29.2.0",
56
+ "@ultimat3/mcp": "29.2.0",
57
+ "@ultimat3/money": "29.2.0",
58
+ "@ultimat3/notify": "29.2.0",
59
+ "@ultimat3/policy": "29.2.0",
60
+ "@ultimat3/pwa": "29.2.0",
61
+ "@ultimat3/query": "29.2.0",
62
+ "@ultimat3/realtime": "29.2.0",
63
+ "@ultimat3/render": "29.2.0",
64
+ "@ultimat3/schema": "29.2.0",
65
+ "@ultimat3/seo": "29.2.0",
66
+ "@ultimat3/storage": "29.2.0",
67
+ "@ultimat3/testing": "29.2.0",
68
+ "@ultimat3/time": "29.2.0",
69
69
  "babel-preset-solid": "^1.9.16"
70
70
  }
71
71
  }
package/src/cmd-db.ts CHANGED
@@ -23,6 +23,7 @@ import type { CliCommand, CommandContext } from './command';
23
23
  import { stepFinding } from './db-finding';
24
24
  import { generateAppMigration, unrenderedJson, unrenderedLines } from './db-generate';
25
25
  import { liveObjectDrift } from './db-object-drift';
26
+ import { layTemplate } from './db-reset-template';
26
27
  import {
27
28
  afterWrittenMigration,
28
29
  refreshSchemaDump,
@@ -249,7 +250,8 @@ async function runMigrate(
249
250
  /**
250
251
  * Embedded only: `rm -rf` against a database this process does not own is not a reset, it is an
251
252
  * outage. The data directory goes before the migrator starts, so the run that follows is a fresh
252
- * database with an empty ledger rather than a re-apply over a live one.
253
+ * database rather than a re-apply over a live one — laid down from the migrated template when one
254
+ * can be had, its ledger already full, else an empty one the migrator fills.
253
255
  */
254
256
  async function runReset(ctx: CommandContext, root: string): Promise<CommandResult> {
255
257
  const services = resolveServices(root, ctx.env);
@@ -272,7 +274,11 @@ async function runReset(ctx: CommandContext, root: string): Promise<CommandResul
272
274
  embeddedDb: true,
273
275
  });
274
276
  }
275
- await rm(join(services.stateDir, 'pgdata'), { recursive: true, force: true });
277
+ const dataDir = join(services.stateDir, 'pgdata');
278
+ await rm(dataDir, { recursive: true, force: true });
279
+ // From the migrated template when it can be had (`db-reset-template.ts`); the migrate below runs
280
+ // either way, so a template that could not be laid down costs time, never a different database.
281
+ await layTemplate(root, dataDir);
276
282
  return runMigrate(ctx, root, msg('cli.db.reset.done'));
277
283
  }
278
284
 
@@ -0,0 +1,47 @@
1
+ // `x db reset`'s embedded fast path: the emptied data directory is laid down from the migrated
2
+ // TEMPLATE (`test-database.ts`, cached under `.x/test-db`) instead of an `initdb` and every
3
+ // migration replayed one by one.
4
+
5
+ // The migrate that follows still runs and finds every migration in the ledger — so a template
6
+ // that is missing something is completed by it, never trusted over it. Measured on
7
+ // bank-integrations (33 migrations, a loaded 12-core box, 3 runs each): `x db reset` 8.7 s → 7.5 s.
8
+ // What is left is the CLI's boot, the migrator's no-op pass and the schema dump's own replay (~3 s,
9
+ // `db-schema-refresh.ts`). The e2e step's app boots through exactly this reset, once per gate.
10
+
11
+ // why: Bun has no recursive remove; a failed lay-down must leave the directory as `rm` left it.
12
+ import { rm } from 'node:fs/promises';
13
+ import { raw } from '@ultimat3/db';
14
+ import { appExtensions } from './migration-extensions';
15
+ import { migratedDatabase } from './test-database';
16
+
17
+ export interface TemplateLayDeps {
18
+ /** Boots a database from the template at `dataDir`; `test-database.ts`'s by default. */
19
+ readonly open?: typeof migratedDatabase;
20
+ readonly extensions?: (root: string) => Promise<readonly string[]>;
21
+ }
22
+
23
+ /**
24
+ * `true` when `dataDir` now holds the migrated template. Best effort and never a throw: anything
25
+ * that fails removes what it wrote, and the caller's migrate builds the database the slow way.
26
+ */
27
+ export async function layTemplate(
28
+ root: string,
29
+ dataDir: string,
30
+ deps: TemplateLayDeps = {},
31
+ ): Promise<boolean> {
32
+ const open = deps.open ?? migratedDatabase;
33
+ try {
34
+ const extensions = await (deps.extensions ?? appExtensions)(root);
35
+ const client = await open({ root, dataDir, extensions });
36
+ try {
37
+ // The boot is lazy: one statement is what makes PGlite restore the template onto the disk.
38
+ await client.execute(raw('select 1'));
39
+ } finally {
40
+ await client.close();
41
+ }
42
+ return true;
43
+ } catch {
44
+ await rm(dataDir, { recursive: true, force: true }).catch(() => undefined);
45
+ return false;
46
+ }
47
+ }
package/src/messages.ts CHANGED
@@ -332,6 +332,7 @@ const CATALOG = {
332
332
  // counts — so the line says how many and `x test <type>` prints the names.
333
333
  'cli.verify.allSkipped': 'found {skipped} test(s) and every one skipped itself',
334
334
  'cli.verify.warnings': '{count} warning(s) — --verbose or --json lists them',
335
+ 'cli.verify.slowest': 'slowest: {files}',
335
336
  'cli.verify.isrQueryUndeclared':
336
337
  "{file}: render 'isr' with no revalidate.query — its stored pages are keyed on the whole query string, so any visitor can mint one per request. Declare revalidate: { query: [] } (or the parameters the page varies on)",
337
338
  'cli.verify.isrQueryOmitted':
package/src/output.ts CHANGED
@@ -42,6 +42,15 @@ export interface Finding {
42
42
  readonly meta?: { readonly [key: string]: JsonValue };
43
43
  }
44
44
 
45
+ /** One test file of a run and how long Bun took over it (`test-timings.ts` measures it). */
46
+ export interface SlowFile {
47
+ readonly file: string;
48
+ readonly ms: number;
49
+ }
50
+
51
+ /** How many of a step's slowest files the human line names; `--json` carries all it has. */
52
+ export const SLOWEST_SHOWN = 3;
53
+
45
54
  export interface StepResult {
46
55
  readonly name: string;
47
56
  readonly ok: boolean;
@@ -65,6 +74,11 @@ export interface StepResult {
65
74
  readonly workers?: number;
66
75
  /** Why the width is what it is — `4 workers (budget 4.0 GB)`. */
67
76
  readonly widthReason?: string;
77
+ /**
78
+ * A test step's slowest files, slowest first — where its time went, on a pass too: a suite that
79
+ * grew a 20 s file is a slower gate for everyone, and nothing else said which file it was.
80
+ */
81
+ readonly slowest?: readonly SlowFile[];
68
82
  /** Under `x verify --shard`: this step's slice of its corpus. */
69
83
  readonly shard?: ShardFacts;
70
84
  /**
@@ -210,6 +224,10 @@ export function renderUltimateError(error: UltimateErrorShape, indent = ''): str
210
224
  return renderFinding(finding, indent);
211
225
  }
212
226
 
227
+ /** `16.8s`, or `840ms` under a second: a slow file's duration as a reader scans it. */
228
+ const seconds = (ms: number): string =>
229
+ ms < 1000 ? `${String(Math.round(ms))}ms` : `${(ms / 1000).toFixed(1)}s`;
230
+
213
231
  const mark = (step: StepResult): string => {
214
232
  if (step.skipped === true) return '-';
215
233
  return step.ok ? '✓' : '✗';
@@ -255,6 +273,13 @@ export function renderHuman(result: CommandResult, verbose = false): string {
255
273
  // Escaped like every other line text reaches fd 1 through: a warning names a file path. A
256
274
  // green terminal run shows the count, not the list: `filesize`'s band alone is ~150 lines,
257
275
  // and a wall of advice on every run is read by nobody. `--json` always carries the list.
276
+ const slowest = (step.slowest ?? []).slice(0, SLOWEST_SHOWN);
277
+ if (slowest.length > 0) {
278
+ const files = slowest
279
+ .map((slow) => `${singleLine(slow.file)} ${seconds(slow.ms)}`)
280
+ .join(' · ');
281
+ out.push(` ${msg('cli.verify.slowest', { files })}`);
282
+ }
258
283
  const warnings = step.warnings ?? [];
259
284
  if (verbose) for (const warning of warnings) out.push(` ! ${singleLine(warning)}`);
260
285
  else if (warnings.length > 0) {
@@ -292,6 +317,9 @@ export function renderJson(result: CommandResult, verbose = false): string {
292
317
  : { warnings: [...step.warnings] }),
293
318
  ...(step.workers === undefined ? {} : { workers: step.workers }),
294
319
  ...(step.widthReason === undefined ? {} : { widthReason: step.widthReason }),
320
+ ...(step.slowest === undefined || step.slowest.length === 0
321
+ ? {}
322
+ : { slowest: step.slowest.map((slow) => ({ file: slow.file, ms: slow.ms })) }),
295
323
  ...(step.shard === undefined
296
324
  ? {}
297
325
  : {
@@ -11,10 +11,14 @@ import type { Ctx, NavigationSurface } from '@ultimat3/core';
11
11
  import { CLIENT_NAVIGATION_SCOPE_HEADER } from '@ultimat3/core';
12
12
  import type { Route, RouteNavigation, UltimateRequest } from '@ultimat3/http';
13
13
  import { locationFor, navigationPurpose, relocate } from '@ultimat3/http';
14
+ import type { Translator } from '@ultimat3/i18n';
14
15
  import type { ClientNavigationHead, HeadTag, RouteEntry } from '@ultimat3/render';
15
16
  import {
16
17
  clientNavigationTags,
17
18
  documentCarriesScope,
19
+ modalRouteList,
20
+ navigationModeOf,
21
+ navigationPrefetches,
18
22
  RouteNavigationInvalidError,
19
23
  routeEntries,
20
24
  } from '@ultimat3/render';
@@ -36,6 +40,11 @@ export interface NavigationDocumentHead {
36
40
  readonly buildId: string;
37
41
  /** `app.config.ts`'s `name`: two apps on one origin (web and admin) are two routers. */
38
42
  readonly app: string;
43
+ /**
44
+ * Per opted-in surface, its modal routes as `modalRouteList` orders them — what lets a click
45
+ * open the dialog before the answer. Absent or empty: the documents name none.
46
+ */
47
+ readonly modalRoutes?: ReadonlyMap<string, readonly string[]>;
39
48
  }
40
49
 
41
50
  export interface PageNavigation {
@@ -71,7 +80,7 @@ export function assertRouteNavigation(
71
80
  entries: readonly RouteEntry[] = routeEntries(),
72
81
  ): void {
73
82
  for (const entry of entries) {
74
- const mode = entry.config.navigation;
83
+ const mode = navigationModeOf(entry.config.navigation);
75
84
  if (mode === undefined || surfaces.some((surface) => surface === entry.surface)) continue;
76
85
  throw new RouteNavigationInvalidError(
77
86
  `${entry.file} declares navigation: '${mode}', and its surface (${entry.surface}) is not in app.config.ts navigation.client`,
@@ -79,6 +88,28 @@ export function assertRouteNavigation(
79
88
  }
80
89
  }
81
90
 
91
+ /**
92
+ * Each opted-in surface's modal routes, from the whole table: a page of ANY surface may outrank a
93
+ * modal pattern on the shared URL space, and the router must not open a frame the server will
94
+ * answer with that page.
95
+ */
96
+ export function modalRoutesOf(
97
+ surfaces: readonly NavigationSurface[],
98
+ entries: readonly RouteEntry[] = routeEntries(),
99
+ ): ReadonlyMap<string, readonly string[]> {
100
+ return new Map(
101
+ surfaces.map((surface) => [
102
+ surface,
103
+ modalRouteList(
104
+ entries.map((entry) => ({
105
+ path: entry.path,
106
+ modal: entry.surface === surface && navigationModeOf(entry.config.navigation) === 'modal',
107
+ })),
108
+ ),
109
+ ]),
110
+ );
111
+ }
112
+
82
113
  /**
83
114
  * Built at boot, never on the watcher tick — the router is framework code, and a tab that loaded
84
115
  * it keeps it across every soft navigation, so a URL that changed per save would be one no tab
@@ -109,6 +140,7 @@ export async function pageNavigation(
109
140
  scriptUrl: script.url,
110
141
  buildId,
111
142
  app: declared.app,
143
+ modalRoutes: modalRoutesOf(declared.surfaces),
112
144
  },
113
145
  };
114
146
  }
@@ -117,13 +149,25 @@ export async function pageNavigation(
117
149
  export const surfaceKey = (head: NavigationDocumentHead, surface: string): string =>
118
150
  `${head.app}:${surface}`;
119
151
 
120
- /** The head for one document, or `undefined` when its surface did not opt in. */
152
+ /**
153
+ * The head for one document, or `undefined` when its surface did not opt in. With `t`, and modal
154
+ * routes on the surface, it names them and the frame's two words in the document's own locale
155
+ * (the framework catalog's `ui.close` and `ui.loading`, which an app may override).
156
+ */
121
157
  export function navigationHeadFor(
122
158
  head: NavigationDocumentHead | undefined,
123
159
  surface: string,
160
+ t?: Translator,
124
161
  ): ClientNavigationHead | undefined {
125
162
  if (head === undefined || !head.surfaces.has(surface)) return undefined;
126
- return { surface: surfaceKey(head, surface), buildId: head.buildId, scriptUrl: head.scriptUrl };
163
+ const base = {
164
+ surface: surfaceKey(head, surface),
165
+ buildId: head.buildId,
166
+ scriptUrl: head.scriptUrl,
167
+ };
168
+ const routes = head.modalRoutes?.get(surface) ?? [];
169
+ if (t === undefined || routes.length === 0) return base;
170
+ return { ...base, modals: { routes, close: t('ui.close'), loading: t('ui.loading') } };
127
171
  }
128
172
 
129
173
  /**
@@ -137,15 +181,15 @@ export function navigationHeadFor(
137
181
  export function navigationTagsOf(
138
182
  head: NavigationDocumentHead | undefined,
139
183
  entry: Pick<RouteEntry, 'surface' | 'config'>,
184
+ t?: Translator,
140
185
  ): readonly HeadTag[] {
141
- if (entry.config.navigation === 'document') return [];
142
- const tags = navigationHeadFor(head, entry.surface);
186
+ const mode = navigationModeOf(entry.config.navigation);
187
+ if (mode === 'document') return [];
188
+ const tags = navigationHeadFor(head, entry.surface, t);
143
189
  if (tags === undefined) return [];
144
190
  // A modal says so in its own document: the router shows it over the page beneath, never decided
145
191
  // from a link — a redirect's target and a pasted `#/…` address are answered alike.
146
- return clientNavigationTags(
147
- entry.config.navigation === 'modal' ? { ...tags, modal: true } : tags,
148
- );
192
+ return clientNavigationTags(mode === 'modal' ? { ...tags, modal: true } : tags);
149
193
  }
150
194
 
151
195
  /** `{ navigation }` for `metaOf`'s spread, or nothing. */
@@ -167,10 +211,10 @@ export function routeNavigationOf(
167
211
  head: NavigationDocumentHead | undefined,
168
212
  ): RouteNavigation | undefined {
169
213
  if (head === undefined || !head.surfaces.has(entry.surface)) return undefined;
170
- if (entry.config.navigation === 'document') return undefined;
214
+ if (navigationModeOf(entry.config.navigation) === 'document') return undefined;
171
215
  return {
172
216
  surface: surfaceKey(head, entry.surface),
173
- prefetch: entry.config.navigation === 'prefetch',
217
+ prefetch: navigationPrefetches(entry.config.navigation),
174
218
  };
175
219
  }
176
220
 
@@ -12,6 +12,8 @@ import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
12
12
  import type { RouteDescriptor, RouteEntry } from '@ultimat3/render';
13
13
  import {
14
14
  describePages,
15
+ navigationModeOf,
16
+ navigationPrefetches,
15
17
  renderHead,
16
18
  routeEntries,
17
19
  speculationPattern,
@@ -37,8 +39,9 @@ export async function loadSpeculation(root: string): Promise<SpeculationConfig>
37
39
  * no policy, no `no-store`. Such a page ran its `load` at build time, for nobody. An `ssr` page
38
40
  * there has no way to declare that its GET records something (`navigation` is refused on a
39
41
  * surface that did not opt in), so it is never a candidate.
40
- * - On a client-routed surface: only a page that declared `navigation: 'prefetch'` — the app's own
41
- * statement that its GET is a pure read, the same one the router's prefetch obeys.
42
+ * - On a client-routed surface: only a page that declared `navigation: 'prefetch'` (or a modal
43
+ * that declared `prefetch: 'intent'`) — the app's own statement that its GET is a pure read,
44
+ * the same one the router's prefetch obeys.
42
45
  * - Never an `api/` route, a `navigation: 'document'` page, or an `offline: 'network-only'` one.
43
46
  */
44
47
  export function speculationCandidate(
@@ -47,8 +50,9 @@ export function speculationCandidate(
47
50
  client: ReadonlySet<string>,
48
51
  ): boolean {
49
52
  if (entry.surface !== 'site' && entry.surface !== 'app') return false;
50
- if (entry.config.navigation === 'document' || described.offline === 'network-only') return false;
51
- if (client.has(entry.surface)) return entry.config.navigation === 'prefetch';
53
+ const mode = navigationModeOf(entry.config.navigation);
54
+ if (mode === 'document' || described.offline === 'network-only') return false;
55
+ if (client.has(entry.surface)) return navigationPrefetches(entry.config.navigation);
52
56
  return (described.mode === 'static' || described.mode === 'isr') && !described.personal;
53
57
  }
54
58
 
@@ -120,7 +120,7 @@ const headFor = async (
120
120
  ): Promise<string> => {
121
121
  const meta = metaContextFor(ctx, data);
122
122
  const url = new URL(ctx.url);
123
- const router = navigationTagsOf(options.navigation, entry);
123
+ const router = navigationTagsOf(options.navigation, entry, meta.t);
124
124
  return (
125
125
  renderHead(
126
126
  headFromMeta(
@@ -59,6 +59,7 @@ import { runBatches, testBatches } from './test-batches';
59
59
  import { tidyBeforeTestRun } from './test-housekeeping';
60
60
  import { testPasses } from './test-passes';
61
61
  import type { TestFile } from './test-select';
62
+ import { loadTimings, SLOWEST_KEPT, slowestOf } from './test-timings';
62
63
  import { BATCH_FILES_PER_WORKER, SHARED_BATCH_FILES_PER_WORKER } from './test-workers';
63
64
  import type { TestType } from './verify-tests';
64
65
 
@@ -334,6 +335,19 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
334
335
  }
335
336
  const durationMs = Math.round(performance.now() - started);
336
337
  const fileCount = options.files.length;
338
+ // Where the time went, from the cache every pass's `--update-timings` just rewrote: a file this
339
+ // selection ran and nothing timed (a `--watch` run, a cache that could not be written) is left out.
340
+ const timings = await loadTimings(join(options.root, TEST_TIMINGS_FILE));
341
+ const slowest = slowestOf(
342
+ timings,
343
+ options.files.map((file) => file.path),
344
+ SLOWEST_KEPT,
345
+ );
346
+ // Each pass's line names its own slowest, so a mixed run's serial half is not hidden by the rest.
347
+ const timedSteps = steps.map((step, index) => {
348
+ const own = slowestOf(timings, passes[index]?.files.map((file) => file.path) ?? []);
349
+ return own.length === 0 ? step : { ...step, slowest: own };
350
+ });
337
351
  // The width the RUN reached, which is the widest pass: a mixed selection whose serial half ran
338
352
  // one at a time did not become a one-worker run, and reporting it as one would misname the
339
353
  // reproduce a reader is handed.
@@ -357,6 +371,8 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
357
371
  ...(spent.length > 1 ? { passes: spent } : {}),
358
372
  // Only when some pass was split, for the same reason: an unbatched run's JSON is unchanged.
359
373
  ...(batchCount > spent.length ? { batches: batchCount } : {}),
374
+ // Absent when nothing was timed, so an untimed run's JSON is byte-identical to before.
375
+ ...(slowest.length === 0 ? {} : { slowest: slowest.map((slow) => ({ ...slow })) }),
360
376
  ok,
361
377
  exitCode,
362
378
  reproduce: reproduceFor(plan),
@@ -376,7 +392,7 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
376
392
  failed: steps.filter((step) => !step.ok).length,
377
393
  workers,
378
394
  }),
379
- steps,
395
+ steps: timedSteps,
380
396
  ...(sample === undefined
381
397
  ? {}
382
398
  : { lines: [msg('cli.test.sampled', { ...sample, type: type ?? 'all' })] }),
@@ -0,0 +1,127 @@
1
+ // The per-file test durations a run learns and the next one spends: the local cache read leniently,
2
+ // merged and written back atomically, a step's slowest files named, and work ordered longest-first.
3
+
4
+ // A cache only ever orders work and names what was slow — never which files run or what a step
5
+ // answers — so a missing, stale or broken one costs seconds, never a verdict.
6
+
7
+ // why: Bun has no mkdir or rename; the cache is written to a temp name and renamed into place.
8
+ import { mkdir, rename, rm } from 'node:fs/promises';
9
+ // why: Bun ships no path-join primitive.
10
+ import { dirname } from 'node:path';
11
+ import type { SlowFile } from './output';
12
+ import type { Timings } from './verify-shard';
13
+
14
+ /** How many of a run's slowest files a step carries (`--json`); the human line shows fewer. */
15
+ export const SLOWEST_KEPT = 10;
16
+
17
+ const finiteMs = (value: unknown): value is number =>
18
+ typeof value === 'number' && Number.isFinite(value) && value >= 0;
19
+
20
+ /** The cache as `{ path: ms }`; absent, unreadable or of another shape is `{}` — never a throw. */
21
+ export async function loadTimings(path: string): Promise<Timings> {
22
+ try {
23
+ const parsed: unknown = JSON.parse(await Bun.file(path).text());
24
+ const files =
25
+ typeof parsed === 'object' && parsed !== null && 'files' in parsed
26
+ ? (parsed as { files: unknown }).files
27
+ : parsed;
28
+ if (typeof files !== 'object' || files === null || Array.isArray(files)) return {};
29
+ return Object.fromEntries(Object.entries(files).filter(([, ms]) => finiteMs(ms)));
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ /**
36
+ * `fresh` merged over the cache and written back in Bun's own shape (`{ version: 1, files }`), so
37
+ * `bun test --timings` reads what this wrote and the other way round. Best effort: a cache that
38
+ * cannot be written costs the next run its ordering, which is all it ever bought.
39
+ */
40
+ export async function recordTimings(path: string, fresh: Timings): Promise<void> {
41
+ if (Object.keys(fresh).length === 0) return;
42
+ const files = { ...(await loadTimings(path)), ...fresh };
43
+ const partial = `${path}.${String(process.pid)}.${crypto.randomUUID()}.partial`;
44
+ try {
45
+ await mkdir(dirname(path), { recursive: true });
46
+ await Bun.write(partial, `${JSON.stringify({ version: 1, files }, null, 2)}\n`);
47
+ await rename(partial, path);
48
+ } catch {
49
+ await rm(partial, { force: true }).catch(() => undefined);
50
+ }
51
+ }
52
+
53
+ const attribute = (tag: string, name: string): string | undefined => {
54
+ const match = new RegExp(`\\s${name}="([^"]*)"`).exec(tag);
55
+ return match === null ? undefined : match[1];
56
+ };
57
+
58
+ /** XML's five entities, the only ones Bun's junit writer emits in an attribute. */
59
+ const unescapeXml = (text: string): string =>
60
+ text
61
+ .replaceAll('&quot;', '"')
62
+ .replaceAll('&apos;', "'")
63
+ .replaceAll('&lt;', '<')
64
+ .replaceAll('&gt;', '>')
65
+ .replaceAll('&amp;', '&');
66
+
67
+ /**
68
+ * Per-file durations from a `bun test --reporter=junit` report: each file is one `<testsuite>`
69
+ * whose `file` (else `name`) is the path as the run was given it and whose `time` is seconds.
70
+ * Nested suites (a `describe`) carry no `file` and are skipped. A broken report yields `{}`.
71
+ */
72
+ export function junitDurations(xml: string): Timings {
73
+ // A Map, not a `Record`: a test file named `constructor` must not read `Object.prototype`.
74
+ const out = new Map<string, number>();
75
+ for (const match of xml.matchAll(/<testsuite\s[^>]*>/g)) {
76
+ const tag = match[0];
77
+ const file = attribute(tag, 'file');
78
+ const seconds = Number(attribute(tag, 'time'));
79
+ if (file === undefined || file === '' || !finiteMs(seconds)) continue;
80
+ const path = unescapeXml(file);
81
+ out.set(path, Math.round(seconds * 1000) + (out.get(path) ?? 0));
82
+ }
83
+ return Object.fromEntries(out);
84
+ }
85
+
86
+ /** The `n` slowest of `files` by `timings`, slowest first (ties by path); unknown files left out. */
87
+ export function slowestOf(
88
+ timings: Timings,
89
+ files: readonly string[],
90
+ n: number = SLOWEST_KEPT,
91
+ ): readonly SlowFile[] {
92
+ return [...new Set(files)]
93
+ .flatMap((file) => {
94
+ const ms = Object.hasOwn(timings, file) ? timings[file] : undefined;
95
+ return finiteMs(ms) ? [{ file, ms }] : [];
96
+ })
97
+ .sort((a, b) => b.ms - a.ms || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0))
98
+ .slice(0, Math.max(0, n));
99
+ }
100
+
101
+ /**
102
+ * Indices of `costs` longest first (LPT): a pool that starts its longest piece of work last finishes
103
+ * that long after everything else. An unknown cost (`undefined`) is the median of the known ones,
104
+ * so new work neither jumps the queue nor waits behind all of it; ties keep their order.
105
+ */
106
+ export function longestFirst(costs: readonly (number | undefined)[]): readonly number[] {
107
+ const known = costs.filter((cost): cost is number => finiteMs(cost)).sort((a, b) => a - b);
108
+ const median = known.length === 0 ? 0 : (known[Math.floor(known.length / 2)] as number);
109
+ const costOf = (index: number): number => {
110
+ const cost = costs[index];
111
+ return finiteMs(cost) ? cost : median;
112
+ };
113
+ return costs.map((_, index) => index).sort((a, b) => costOf(b) - costOf(a) || a - b);
114
+ }
115
+
116
+ /** A group of files' recorded cost: the sum of what is known, `undefined` when nothing is. */
117
+ export function costOf(timings: Timings, files: readonly string[]): number | undefined {
118
+ let total = 0;
119
+ let known = false;
120
+ for (const file of files) {
121
+ const ms = Object.hasOwn(timings, file) ? timings[file] : undefined;
122
+ if (!finiteMs(ms)) continue;
123
+ total += ms;
124
+ known = true;
125
+ }
126
+ return known ? total : undefined;
127
+ }
@@ -9,6 +9,13 @@
9
9
  //
10
10
  // It costs nothing measurable: four slice processes took 9.7 s against 12.4 s for `--parallel=4`
11
11
  // over the same files, on a loaded 12-core box.
12
+ //
13
+ // ORDER, NOT MEMBERSHIP, FOLLOWS THE CLOCK. Each slice also writes a junit report; its per-file
14
+ // durations go into the run's timings cache (`TEST_TIMINGS_FILE`, `test-timings.ts`) and the NEXT
15
+ // run starts the slices longest-first from it. The slices themselves stay a pure function of the
16
+ // file list — the coverage a slice measures never depends on a cache. Measured on bank-integrations
17
+ // (1 192 files, 102 slices, 8 processes): sorted order 64.1 s, longest-first 56.3 s, the floor
18
+ // 56.1 s — the pool used to start its 50 s slice last.
12
19
 
13
20
  // why: Bun ships no recursive delete and no path-join primitive; the lcov parts live under `.x/`.
14
21
  import { rm } from 'node:fs/promises';
@@ -24,6 +31,14 @@ import { countsOf } from './test-counts';
24
31
  import { testEnvOverrides } from './test-dotenv';
25
32
  import { failureOf } from './test-shards';
26
33
  import { SLOT_HELD_ENV } from './test-slots';
34
+ import {
35
+ costOf,
36
+ junitDurations,
37
+ loadTimings,
38
+ longestFirst,
39
+ recordTimings,
40
+ slowestOf,
41
+ } from './test-timings';
27
42
  import type { MachinePace } from './verify-pace';
28
43
  import { defaultTimeoutArgs, paceEnv } from './verify-pace';
29
44
  import type { StepOutcome } from './verify-step';
@@ -87,6 +102,11 @@ export interface CoveredRunOptions {
87
102
  readonly sliceFiles?: number;
88
103
  /** The machine's pace (`verify-pace.ts`): each test's default and declared deadline stretch by it. */
89
104
  readonly pace?: MachinePace;
105
+ /**
106
+ * The timings cache (`TEST_TIMINGS_FILE` under the root): the slices start longest-first from it,
107
+ * and every file's duration this run measured is written back. Absent: sorted order, nothing kept.
108
+ */
109
+ readonly timings?: string;
90
110
  }
91
111
 
92
112
  export interface CoveredRun extends StepOutcome {
@@ -94,12 +114,19 @@ export interface CoveredRun extends StepOutcome {
94
114
  readonly coverage: CoverageMap;
95
115
  }
96
116
 
97
- /** One slice's argv. An explicit file list, never a re-glob, for `testArgs`' reason. */
117
+ /** The slice's junit report, beside its lcov: one `<testsuite>` per file, with its duration. */
118
+ export const JUNIT_FILE = 'junit.xml';
119
+
120
+ /**
121
+ * One slice's argv. An explicit file list, never a re-glob, for `testArgs`' reason. `junit` adds
122
+ * the report the per-file durations are read from — Bun still prints to the terminal beside it.
123
+ */
98
124
  export const coveredArgs = (
99
125
  files: readonly string[],
100
126
  dir: string,
101
127
  isolate: boolean,
102
128
  pace?: MachinePace,
129
+ junit = false,
103
130
  ): readonly string[] => [
104
131
  'bun',
105
132
  'test',
@@ -108,6 +135,7 @@ export const coveredArgs = (
108
135
  '--coverage',
109
136
  '--coverage-reporter=lcov',
110
137
  `--coverage-dir=${dir}`,
138
+ ...(junit ? ['--reporter=junit', `--reporter-outfile=${join(dir, JUNIT_FILE)}`] : []),
111
139
  ...files,
112
140
  ];
113
141
 
@@ -125,11 +153,14 @@ export async function runCovered(options: CoveredRunOptions): Promise<CoveredRun
125
153
  ...paceEnv(options.pace),
126
154
  };
127
155
  const results = new Array<ExecResult>(slices.length);
156
+ // Longest first by what earlier runs measured; the slices and their indices are unchanged.
157
+ const known = options.timings === undefined ? {} : await loadTimings(options.timings);
158
+ const order = longestFirst(slices.map((slice) => costOf(known, slice)));
128
159
  let next = 0;
129
160
  // Each pool worker names its own numbered test database, as `--parallel`'s workers do.
130
161
  const worker = async (slot: number): Promise<void> => {
131
- while (next < slices.length) {
132
- const index = next;
162
+ while (next < order.length) {
163
+ const index = order[next] as number;
133
164
  next += 1;
134
165
  results[index] = await options.runner(
135
166
  coveredArgs(
@@ -137,15 +168,24 @@ export async function runCovered(options: CoveredRunOptions): Promise<CoveredRun
137
168
  join(base, String(index)),
138
169
  isolate,
139
170
  options.pace,
171
+ options.timings !== undefined,
140
172
  ),
141
173
  { cwd: options.root, env: { ...overrides, ULTIMATE_TEST_WORKER: String(slot) } },
142
174
  );
143
175
  }
144
176
  };
145
177
  let maps: CoverageMap[];
178
+ let measured: Record<string, number> = {};
146
179
  try {
147
180
  await Promise.all(Array.from({ length: width }, (_, slot) => worker(slot)));
148
181
  maps = await Promise.all(slices.map((_, index) => readPart(join(base, String(index)))));
182
+ if (options.timings !== undefined) {
183
+ const reports = await Promise.all(
184
+ slices.map((_, index) => readReport(join(base, String(index)))),
185
+ );
186
+ measured = Object.assign({}, ...reports.map(junitDurations));
187
+ await recordTimings(options.timings, measured);
188
+ }
149
189
  } finally {
150
190
  lease?.release();
151
191
  await rm(base, { recursive: true, force: true });
@@ -183,10 +223,17 @@ export async function runCovered(options: CoveredRunOptions): Promise<CoveredRun
183
223
  ...(options.widthReason === undefined ? {} : { widthReason: options.widthReason }),
184
224
  tests: countsOf(results),
185
225
  ...(first === undefined ? {} : { output }),
226
+ ...(Object.keys(measured).length === 0 ? {} : { slowest: slowestOf(measured, options.files) }),
186
227
  coverage: mergeCoverage(maps),
187
228
  };
188
229
  }
189
230
 
231
+ /** A slice's junit report, or `''` when it wrote none (it died first): no durations, no throw. */
232
+ const readReport = (dir: string): Promise<string> =>
233
+ Bun.file(join(dir, JUNIT_FILE))
234
+ .text()
235
+ .catch(() => '');
236
+
190
237
  /**
191
238
  * One slice's lcov as a map, kept to the app's own tree: a slice loads a thousand framework files
192
239
  * for every hundred of the app's, and none of those is what the floor measures. No file is no
package/src/verify-run.ts CHANGED
@@ -295,6 +295,9 @@ async function runStep(
295
295
  : { warnings: outcome.warnings }),
296
296
  ...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
297
297
  ...(outcome.widthReason === undefined ? {} : { widthReason: outcome.widthReason }),
298
+ ...(outcome.slowest === undefined || outcome.slowest.length === 0
299
+ ? {}
300
+ : { slowest: outcome.slowest }),
298
301
  ...(outcome.shard === undefined ? {} : { shard: outcome.shard }),
299
302
  ...(tests === undefined ? {} : { tests }),
300
303
  },
@@ -6,7 +6,7 @@ import { ERROR_DOCS_URL } from '@ultimat3/core';
6
6
  import type { CoverageMap } from './coverage-lcov';
7
7
  import type { ExecResult, Runner } from './exec';
8
8
  import { execOutput } from './exec';
9
- import type { Finding, ShardFacts, StepResult } from './output';
9
+ import type { Finding, ShardFacts, SlowFile, StepResult } from './output';
10
10
  import type { TestCounts } from './test-counts';
11
11
  import type { StepTimeouts } from './verify-deadline';
12
12
  import type { MachinePace } from './verify-pace';
@@ -159,6 +159,8 @@ export interface StepOutcome {
159
159
  readonly workers?: number;
160
160
  /** Why `workers` is what it is — `4 workers (budget 4.0 GB)`. Printed on the step line. */
161
161
  readonly widthReason?: string;
162
+ /** The step's slowest test files, slowest first (`test-timings.ts`): where its time went. */
163
+ readonly slowest?: readonly SlowFile[];
162
164
  /** The CI slice this step ran, under `--shard`: which files, and a hash of the whole list. */
163
165
  readonly shard?: ShardFacts;
164
166
  /**
@@ -13,6 +13,7 @@ import { testEnvOverrides } from './test-dotenv';
13
13
  import { tidyBeforeTestRun } from './test-housekeeping';
14
14
  import type { TestFile } from './test-select';
15
15
  import { failureOf, TEST_TIMINGS_FILE, testArgs } from './test-shards';
16
+ import { loadTimings, slowestOf } from './test-timings';
16
17
  import { BATCH_FILES_PER_WORKER, SHARED_BATCH_FILES_PER_WORKER } from './test-workers';
17
18
  import type { MachinePace } from './verify-pace';
18
19
  import { defaultTimeoutArgs, paceEnv } from './verify-pace';
@@ -101,6 +102,8 @@ export async function runParallel(options: ParallelRunOptions): Promise<StepOutc
101
102
  ...(options.isolate === true ? { isolate: true } : {}),
102
103
  }),
103
104
  ];
105
+ // Where the time went: Bun rewrote the timings cache (`--update-timings`) as the batches ended.
106
+ const slowest = slowestOf(await loadTimings(join(options.root, TEST_TIMINGS_FILE)), files);
104
107
  // The width the run REACHED: a batch the machine pool narrowed ran narrower than planned, and a
105
108
  // step line that printed the plan would name a width that never ran.
106
109
  const reached = Math.max(1, ...result.widths);
@@ -119,6 +122,7 @@ export async function runParallel(options: ParallelRunOptions): Promise<StepOutc
119
122
  // Per batch: each process printed its own summary, and one parse of the joined text would
120
123
  // read only the last one.
121
124
  tests: countsOf(result.results),
125
+ ...(slowest.length === 0 ? {} : { slowest }),
122
126
  // Only on failure: a green run's summary is already the step table's, and the reader of a red
123
127
  // gate needs the assertion diff.
124
128
  ...(result.ok ? {} : { output: result.output }),
@@ -21,6 +21,7 @@ import { testEnvOverrides } from './test-dotenv';
21
21
  import { IGNORED_TEST_PATTERNS } from './test-ignores';
22
22
  import type { TestFile } from './test-select';
23
23
  import { discoverTests } from './test-select';
24
+ import { TEST_TIMINGS_FILE } from './test-shards';
24
25
  import { machineLease } from './test-slots';
25
26
  import type { WorkerPlan } from './test-workers';
26
27
  import { availableCpus, SERIAL_TYPES, totalMemory, workerPlan } from './test-workers';
@@ -317,6 +318,8 @@ const coveredUnit = async (
317
318
  ...(run.lease === undefined ? {} : { lease: run.lease }),
318
319
  ...(ctx.env === undefined ? {} : { env: ctx.env }),
319
320
  ...(run.pace === undefined ? {} : { pace: run.pace }),
321
+ // Orders the slices longest-first and learns this run's per-file durations (`test-timings.ts`).
322
+ timings: join(ctx.root, TEST_TIMINGS_FILE),
320
323
  });
321
324
  if (!outcome.ok) return run.shard === undefined ? outcome : { ...outcome, shard: run.shard };
322
325
  if (run.shard !== undefined) {