@quenty/nevermore-cli 4.50.0 → 4.51.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.
Files changed (90) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/commands/batch-command/batch-bundle-command.d.ts +12 -0
  3. package/dist/commands/batch-command/batch-bundle-command.d.ts.map +1 -0
  4. package/dist/commands/batch-command/batch-bundle-command.js +96 -0
  5. package/dist/commands/batch-command/batch-bundle-command.js.map +1 -0
  6. package/dist/commands/batch-command/batch-test-command.d.ts +1 -0
  7. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  8. package/dist/commands/batch-command/batch-test-command.js +103 -15
  9. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  10. package/dist/commands/batch-command/index.d.ts.map +1 -1
  11. package/dist/commands/batch-command/index.js +2 -0
  12. package/dist/commands/batch-command/index.js.map +1 -1
  13. package/dist/commands/tools-command/ci-post-test-results.d.ts +1 -0
  14. package/dist/commands/tools-command/ci-post-test-results.d.ts.map +1 -1
  15. package/dist/commands/tools-command/ci-post-test-results.js +13 -4
  16. package/dist/commands/tools-command/ci-post-test-results.js.map +1 -1
  17. package/dist/utils/batch/changed-packages-utils.d.ts +8 -0
  18. package/dist/utils/batch/changed-packages-utils.d.ts.map +1 -1
  19. package/dist/utils/batch/changed-packages-utils.js +9 -11
  20. package/dist/utils/batch/changed-packages-utils.js.map +1 -1
  21. package/dist/utils/job-context/batch-script-job-context.d.ts +12 -0
  22. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  23. package/dist/utils/job-context/batch-script-job-context.js +19 -0
  24. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  25. package/dist/utils/sourcemap/sourcemap-loader.d.ts +3 -3
  26. package/dist/utils/sourcemap/sourcemap-loader.d.ts.map +1 -1
  27. package/dist/utils/sourcemap/sourcemap-loader.js +6 -7
  28. package/dist/utils/sourcemap/sourcemap-loader.js.map +1 -1
  29. package/dist/utils/testing/batch-run.e2e.test.js +36 -0
  30. package/dist/utils/testing/batch-run.e2e.test.js.map +1 -1
  31. package/dist/utils/testing/parsers/annotation-workspace.d.ts +17 -0
  32. package/dist/utils/testing/parsers/annotation-workspace.d.ts.map +1 -0
  33. package/dist/utils/testing/parsers/annotation-workspace.js +47 -0
  34. package/dist/utils/testing/parsers/annotation-workspace.js.map +1 -0
  35. package/dist/utils/testing/parsers/index.d.ts +0 -1
  36. package/dist/utils/testing/parsers/index.d.ts.map +1 -1
  37. package/dist/utils/testing/parsers/index.js +0 -1
  38. package/dist/utils/testing/parsers/index.js.map +1 -1
  39. package/dist/utils/testing/parsers/jest-lua-parser.d.ts +14 -29
  40. package/dist/utils/testing/parsers/jest-lua-parser.d.ts.map +1 -1
  41. package/dist/utils/testing/parsers/jest-lua-parser.js +85 -100
  42. package/dist/utils/testing/parsers/jest-lua-parser.js.map +1 -1
  43. package/dist/utils/testing/parsers/jest-lua-parser.test.js +359 -56
  44. package/dist/utils/testing/parsers/jest-lua-parser.test.js.map +1 -1
  45. package/dist/utils/testing/parsers/roblox-path-resolver.d.ts +23 -17
  46. package/dist/utils/testing/parsers/roblox-path-resolver.d.ts.map +1 -1
  47. package/dist/utils/testing/parsers/roblox-path-resolver.js +89 -54
  48. package/dist/utils/testing/parsers/roblox-path-resolver.js.map +1 -1
  49. package/dist/utils/testing/parsers/roblox-path-resolver.test.js +76 -36
  50. package/dist/utils/testing/parsers/roblox-path-resolver.test.js.map +1 -1
  51. package/dist/utils/testing/runner/combined-project-generator.d.ts.map +1 -1
  52. package/dist/utils/testing/runner/combined-project-generator.js +5 -6
  53. package/dist/utils/testing/runner/combined-project-generator.js.map +1 -1
  54. package/dist/utils/testing/runner/prebuilt-batch-bundle.d.ts +26 -0
  55. package/dist/utils/testing/runner/prebuilt-batch-bundle.d.ts.map +1 -0
  56. package/dist/utils/testing/runner/prebuilt-batch-bundle.js +200 -0
  57. package/dist/utils/testing/runner/prebuilt-batch-bundle.js.map +1 -0
  58. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.d.ts +2 -0
  59. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.d.ts.map +1 -0
  60. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.js +181 -0
  61. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.js.map +1 -0
  62. package/dist/utils/testing/runner/test-project-slug.d.ts +11 -0
  63. package/dist/utils/testing/runner/test-project-slug.d.ts.map +1 -0
  64. package/dist/utils/testing/runner/test-project-slug.js +11 -0
  65. package/dist/utils/testing/runner/test-project-slug.js.map +1 -0
  66. package/dist/utils/testing/runner/test-runner.d.ts +8 -1
  67. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  68. package/dist/utils/testing/runner/test-runner.js +19 -6
  69. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  70. package/package.json +7 -6
  71. package/src/commands/batch-command/batch-bundle-command.ts +140 -0
  72. package/src/commands/batch-command/batch-test-command.ts +156 -16
  73. package/src/commands/batch-command/index.ts +2 -0
  74. package/src/commands/tools-command/ci-post-test-results.ts +18 -3
  75. package/src/utils/batch/changed-packages-utils.ts +21 -12
  76. package/src/utils/job-context/batch-script-job-context.ts +34 -3
  77. package/src/utils/sourcemap/sourcemap-loader.ts +7 -9
  78. package/src/utils/testing/batch-run.e2e.test.ts +42 -0
  79. package/src/utils/testing/parsers/annotation-workspace.ts +74 -0
  80. package/src/utils/testing/parsers/index.ts +0 -1
  81. package/src/utils/testing/parsers/jest-lua-parser.test.ts +433 -65
  82. package/src/utils/testing/parsers/jest-lua-parser.ts +131 -109
  83. package/src/utils/testing/parsers/roblox-path-resolver.test.ts +144 -61
  84. package/src/utils/testing/parsers/roblox-path-resolver.ts +117 -56
  85. package/src/utils/testing/runner/combined-project-generator.ts +9 -13
  86. package/src/utils/testing/runner/prebuilt-batch-bundle.test.ts +252 -0
  87. package/src/utils/testing/runner/prebuilt-batch-bundle.ts +267 -0
  88. package/src/utils/testing/runner/test-project-slug.ts +18 -0
  89. package/src/utils/testing/runner/test-runner.ts +43 -13
  90. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,140 @@
1
+ import { CommandModule } from 'yargs';
2
+ import { OutputHelper } from '@quenty/cli-output-helpers';
3
+ import { BaseReporter } from '@quenty/cli-output-helpers/reporting';
4
+ import { NevermoreGlobalArgs } from '../../args/global-args.js';
5
+ import {
6
+ BatchScriptJobContext,
7
+ LocalJobContext,
8
+ } from '../../utils/job-context/index.js';
9
+ import {
10
+ discoverAllTestableBatchTargetsAsync,
11
+ discoverChangedTestableBatchTargetsAsync,
12
+ type BatchTarget,
13
+ } from '../../utils/batch/changed-packages-utils.js';
14
+ import { prepareTestAsync } from '../../utils/testing/runner/test-runner.js';
15
+ import {
16
+ BUNDLE_FILE_SUFFIX,
17
+ writePrebuiltBatchBundleAsync,
18
+ } from '../../utils/testing/runner/prebuilt-batch-bundle.js';
19
+
20
+ interface BatchBundleArgs extends NevermoreGlobalArgs {
21
+ file: string;
22
+ base?: string;
23
+ all?: boolean;
24
+ limit?: number;
25
+ maxPlaceMb?: number;
26
+ }
27
+
28
+ export const batchBundleCommand: CommandModule<
29
+ NevermoreGlobalArgs,
30
+ BatchBundleArgs
31
+ > = {
32
+ command: 'bundle <file>',
33
+ describe:
34
+ 'Build the test places for changed packages into one bundle file, without an API key. ' +
35
+ 'Run it with "batch test --bundle <file>".',
36
+ builder: (yargs) => {
37
+ return yargs
38
+ .positional('file', {
39
+ describe: `File to write, conventionally <name>${BUNDLE_FILE_SUFFIX}`,
40
+ type: 'string',
41
+ demandOption: true,
42
+ })
43
+ .option('all', {
44
+ describe: 'Bundle all packages with test targets, not just changed',
45
+ type: 'boolean',
46
+ default: false,
47
+ })
48
+ .option('base', {
49
+ describe: 'Git ref to diff against for change detection',
50
+ type: 'string',
51
+ default: 'origin/main',
52
+ })
53
+ .option('limit', {
54
+ describe: 'Max number of packages to bundle (for local debugging)',
55
+ type: 'number',
56
+ })
57
+ .option('max-place-mb', {
58
+ describe:
59
+ 'Size budget in MB for one combined place. Larger batches are split into several places. (default: 90, under the Open Cloud upload limit)',
60
+ type: 'number',
61
+ }) as any;
62
+ },
63
+ handler: async (args) => {
64
+ try {
65
+ await _runAsync(args);
66
+ } catch (err) {
67
+ OutputHelper.error(err instanceof Error ? err.message : String(err));
68
+ process.exit(1);
69
+ }
70
+ },
71
+ };
72
+
73
+ async function _runAsync(args: BatchBundleArgs): Promise<void> {
74
+ let packages = args.all
75
+ ? await discoverAllTestableBatchTargetsAsync()
76
+ : await discoverChangedTestableBatchTargetsAsync(args.base!);
77
+ if (args.limit && args.limit > 0) {
78
+ packages = packages.slice(0, args.limit);
79
+ }
80
+
81
+ if (args.dryrun) {
82
+ OutputHelper.info(
83
+ `[DRYRUN] Would bundle ${packages.length} packages: ${packages
84
+ .map((p) => p.name)
85
+ .join(', ')}`
86
+ );
87
+ return;
88
+ }
89
+
90
+ await _writeBundleAsync(args, packages);
91
+ }
92
+
93
+ async function _writeBundleAsync(
94
+ args: BatchBundleArgs,
95
+ packages: BatchTarget[]
96
+ ): Promise<void> {
97
+ if (packages.length === 0) {
98
+ OutputHelper.warn('No testable packages found; writing an empty bundle.');
99
+ await writePrebuiltBatchBundleAsync(args.file, [], new Map());
100
+ return;
101
+ }
102
+
103
+ // No client: building never uploads, so there is no key to need.
104
+ const batchContext = new BatchScriptJobContext(
105
+ new LocalJobContext(new BaseReporter()),
106
+ packages,
107
+ {
108
+ maxChunkBytes:
109
+ args.maxPlaceMb !== undefined
110
+ ? args.maxPlaceMb * 1024 * 1024
111
+ : undefined,
112
+ }
113
+ );
114
+
115
+ try {
116
+ // Prepared one at a time so every package's deploy-metadata stamp lands in
117
+ // the shared place before it is packed, and the last package's stamp wins.
118
+ // A live aggregated run stamps only after the shared place has uploaded,
119
+ // so its specs never see a stamp at all.
120
+ const hasJestConfig = new Map<string, boolean>();
121
+ for (const pkg of packages) {
122
+ const prepared = await prepareTestAsync(batchContext, {
123
+ packagePath: pkg.path,
124
+ packageName: pkg.name,
125
+ target: pkg.target,
126
+ });
127
+ hasJestConfig.set(pkg.name, prepared.hasJestConfig);
128
+ }
129
+
130
+ const chunks = await batchContext.getBuiltChunksAsync();
131
+ await writePrebuiltBatchBundleAsync(args.file, chunks, hasJestConfig);
132
+ OutputHelper.info(
133
+ `Wrote ${packages.length} package${packages.length === 1 ? '' : 's'} in ${
134
+ chunks.length
135
+ } place${chunks.length === 1 ? '' : 's'} to ${args.file}.`
136
+ );
137
+ } finally {
138
+ await batchContext.disposeAsync();
139
+ }
140
+ }
@@ -11,6 +11,8 @@ import { getApiKeyAsync } from '@quenty/nevermore-cli-helpers';
11
11
  import { runBatchAsync } from '../../utils/batch/batch-runner.js';
12
12
  import { renderBatchOutcome } from '../../utils/testing/parsers/batch-log-renderer.js';
13
13
  import { emitDiagnostic } from '../../utils/testing/parsers/batch-log-parser.js';
14
+ import { type DeployTarget } from '@quenty/nevermore-deploy';
15
+ import { type BuildContext } from '@quenty/nevermore-template-helpers';
14
16
  import { createBasePlaceResolver } from '../../utils/build/base-place-resolver-factory.js';
15
17
  import {
16
18
  type JobContext,
@@ -37,9 +39,13 @@ import {
37
39
  createTestCommentConfig,
38
40
  } from '../../utils/testing/reporting/index.js';
39
41
  import {
42
+ executePreparedTestAsync,
40
43
  runSingleTestAsync,
44
+ type SingleTestOptions,
41
45
  type SingleTestResult,
42
46
  } from '../../utils/testing/runner/test-runner.js';
47
+ import { type CombinedPlaceChunk } from '../../utils/testing/runner/combined-project-generator.js';
48
+ import { readPrebuiltBatchBundleAsync } from '../../utils/testing/runner/prebuilt-batch-bundle.js';
43
49
  import { isCI } from '../../utils/nevermore-cli-utils.js';
44
50
 
45
51
  interface BatchTestArgs extends NevermoreGlobalArgs {
@@ -56,6 +62,7 @@ interface BatchTestArgs extends NevermoreGlobalArgs {
56
62
  batchUniverseId?: number;
57
63
  maxPlaceMb?: number;
58
64
  timeout?: number;
65
+ bundle?: string;
59
66
  }
60
67
 
61
68
  export const batchTestCommand: CommandModule<
@@ -129,6 +136,14 @@ export const batchTestCommand: CommandModule<
129
136
  'Max script execution time in seconds for the whole batch. Sent to the Open Cloud API so Roblox cancels server-side on overrun. Max 300s (API limit). (default: 300)',
130
137
  type: 'number',
131
138
  })
139
+ .option('bundle', {
140
+ describe:
141
+ 'Run the places in a *.nevermore.tests.tar written by "batch bundle" instead of finding ' +
142
+ 'and building packages. The bundle never chooses where it is uploaded: ' +
143
+ 'that is --batch-place-id/--batch-universe-id, or else the test place this ' +
144
+ 'checkout gives one of its packages.',
145
+ type: 'string',
146
+ })
132
147
  // Accepted only to fail with a useful message. A batch runs one shared
133
148
  // script across packages, so there is nothing for arbitrary Luau to mean
134
149
  // here — but yargs strict mode would otherwise reject it with a bare
@@ -145,6 +160,18 @@ export const batchTestCommand: CommandModule<
145
160
  ' cd <package> && nevermore test --cloud --script-text \'print("hi")\''
146
161
  );
147
162
  }
163
+ if (args.bundle !== undefined) {
164
+ if (args.all || args.limit !== undefined) {
165
+ throw new Error(
166
+ '--bundle runs every package in the bundle; drop --all and --limit.'
167
+ );
168
+ }
169
+ if (args.aggregated === false) {
170
+ throw new Error(
171
+ '--bundle runs an aggregated batch; drop --no-aggregated.'
172
+ );
173
+ }
174
+ }
148
175
  return true;
149
176
  })
150
177
  );
@@ -160,12 +187,38 @@ export const batchTestCommand: CommandModule<
160
187
  };
161
188
 
162
189
  async function _runAsync(args: BatchTestArgs): Promise<void> {
163
- let packages = args.all
164
- ? await discoverAllTestableBatchTargetsAsync()
165
- : await discoverChangedTestableBatchTargetsAsync(args.base!);
190
+ const prebuilt =
191
+ args.bundle !== undefined
192
+ ? await _loadPrebuiltRunAsync(args, args.bundle)
193
+ : undefined;
194
+ let exitCode: number | undefined;
195
+ try {
196
+ exitCode = await _runPackagesAsync(args, prebuilt);
197
+ } finally {
198
+ await prebuilt?.buildContext.cleanupAsync();
199
+ }
200
+
201
+ // Node.js fetch (undici) keeps TCP connections alive in a global pool,
202
+ // which prevents the event loop from draining. Explicit exit required.
203
+ if (exitCode !== undefined) {
204
+ process.exit(exitCode);
205
+ }
206
+ }
166
207
 
167
- if (args.limit && args.limit > 0) {
168
- packages = packages.slice(0, args.limit);
208
+ async function _runPackagesAsync(
209
+ args: BatchTestArgs,
210
+ prebuilt: PrebuiltRun | undefined
211
+ ): Promise<number | undefined> {
212
+ let packages: BatchTarget[];
213
+ if (prebuilt) {
214
+ packages = prebuilt.packages;
215
+ } else {
216
+ packages = args.all
217
+ ? await discoverAllTestableBatchTargetsAsync()
218
+ : await discoverChangedTestableBatchTargetsAsync(args.base!);
219
+ if (args.limit && args.limit > 0) {
220
+ packages = packages.slice(0, args.limit);
221
+ }
169
222
  }
170
223
 
171
224
  if (packages.length === 0) {
@@ -173,7 +226,7 @@ async function _runAsync(args: BatchTestArgs): Promise<void> {
173
226
  'No testable packages found. Packages need a deploy.nevermore.json with a "test" target.\n' +
174
227
  'Run "nevermore deploy init" inside a package to set one up.'
175
228
  );
176
- return;
229
+ return undefined;
177
230
  }
178
231
 
179
232
  if (args.dryrun) {
@@ -181,7 +234,7 @@ async function _runAsync(args: BatchTestArgs): Promise<void> {
181
234
  OutputHelper.info(
182
235
  `[DRYRUN] Would test ${packages.length} packages: ${names}`
183
236
  );
184
- return;
237
+ return undefined;
185
238
  }
186
239
 
187
240
  const cloud = args.cloud ?? false;
@@ -273,8 +326,8 @@ async function _runAsync(args: BatchTestArgs): Promise<void> {
273
326
 
274
327
  const batchContext = aggregated
275
328
  ? new BatchScriptJobContext(innerContext, packages, {
276
- batchPlaceId: args.batchPlaceId,
277
- batchUniverseId: args.batchUniverseId,
329
+ batchPlaceId: prebuilt?.target.placeId ?? args.batchPlaceId,
330
+ batchUniverseId: prebuilt?.target.universeId ?? args.batchUniverseId,
278
331
  batchTimeoutMs: timeoutMs,
279
332
  // Only an upload is size-limited, so a local run stays one place
280
333
  // unless asked to split.
@@ -285,6 +338,10 @@ async function _runAsync(args: BatchTestArgs): Promise<void> {
285
338
  ? undefined
286
339
  : Infinity,
287
340
  reporter,
341
+ prebuilt: prebuilt && {
342
+ chunks: prebuilt.chunks,
343
+ buildContext: prebuilt.buildContext,
344
+ },
288
345
  })
289
346
  : undefined;
290
347
  const context: JobContext = batchContext ?? innerContext;
@@ -326,7 +383,8 @@ async function _runAsync(args: BatchTestArgs): Promise<void> {
326
383
  pkg,
327
384
  context,
328
385
  timeoutMs,
329
- aggregated
386
+ aggregated,
387
+ prebuilt?.hasJestConfig.get(pkg.name)
330
388
  );
331
389
 
332
390
  return {
@@ -360,19 +418,17 @@ async function _runAsync(args: BatchTestArgs): Promise<void> {
360
418
  }
361
419
 
362
420
  await reporter.stopAsync();
363
-
364
- // Node.js fetch (undici) keeps TCP connections alive in a global pool,
365
- // which prevents the event loop from draining. Explicit exit required.
366
- process.exit(exitCode);
421
+ return exitCode;
367
422
  }
368
423
 
369
424
  async function _runWithRetryAsync(
370
425
  pkg: BatchTarget,
371
426
  context: JobContext,
372
427
  timeoutMs: number,
373
- aggregated: boolean
428
+ aggregated: boolean,
429
+ prebuiltHasJestConfig: boolean | undefined
374
430
  ): Promise<SingleTestResult> {
375
- const opts = {
431
+ const opts: SingleTestOptions = {
376
432
  packagePath: pkg.path,
377
433
  packageName: pkg.name,
378
434
  timeoutMs,
@@ -382,11 +438,95 @@ async function _runWithRetryAsync(
382
438
  suppressCountsProvenance: aggregated,
383
439
  };
384
440
 
441
+ if (prebuiltHasJestConfig !== undefined) {
442
+ return _retryOnTransientAsync(pkg.name, async () =>
443
+ executePreparedTestAsync(
444
+ context,
445
+ {
446
+ builtPlace: await context.buildPlaceAsync(opts),
447
+ scriptContent: '',
448
+ hasJestConfig: prebuiltHasJestConfig,
449
+ },
450
+ opts
451
+ )
452
+ );
453
+ }
454
+
385
455
  return _retryOnTransientAsync(pkg.name, () =>
386
456
  runSingleTestAsync(context, opts)
387
457
  );
388
458
  }
389
459
 
460
+ interface PrebuiltRun {
461
+ target: DeployTarget;
462
+ packages: BatchTarget[];
463
+ chunks: CombinedPlaceChunk[];
464
+ hasJestConfig: Map<string, boolean>;
465
+ buildContext: BuildContext;
466
+ }
467
+
468
+ async function _loadPrebuiltRunAsync(
469
+ args: BatchTestArgs,
470
+ bundleFile: string
471
+ ): Promise<PrebuiltRun> {
472
+ const bundle = await readPrebuiltBatchBundleAsync(bundleFile);
473
+ let target: DeployTarget;
474
+ try {
475
+ target = await _resolvePrebuiltTargetAsync(
476
+ args,
477
+ new Set(bundle.packages.map((p) => p.name))
478
+ );
479
+ } catch (err) {
480
+ await bundle.buildContext.cleanupAsync();
481
+ throw err;
482
+ }
483
+
484
+ OutputHelper.info(
485
+ `Running ${bundle.packages.length} prebuilt package${
486
+ bundle.packages.length === 1 ? '' : 's'
487
+ } in ${bundle.chunks.length} place${
488
+ bundle.chunks.length === 1 ? '' : 's'
489
+ } on place ${target.placeId}.`
490
+ );
491
+
492
+ return {
493
+ target,
494
+ packages: bundle.packages.map((p) => ({
495
+ name: p.name,
496
+ packageName: p.name,
497
+ path: process.cwd(),
498
+ target,
499
+ })),
500
+ chunks: bundle.chunks,
501
+ hasJestConfig: new Map(
502
+ bundle.packages.map((p) => [p.name, p.hasJestConfig])
503
+ ),
504
+ buildContext: bundle.buildContext,
505
+ };
506
+ }
507
+
508
+ /**
509
+ * Where a bundle uploads to, picked the way a normal batch run picks it: the
510
+ * test place of one of its packages, read from this checkout rather than from
511
+ * the bundle.
512
+ */
513
+ async function _resolvePrebuiltTargetAsync(
514
+ args: BatchTestArgs,
515
+ bundledNames: Set<string>
516
+ ): Promise<DeployTarget> {
517
+ const known = await discoverAllTestableBatchTargetsAsync();
518
+ const base = known.find((t) => bundledNames.has(t.name)) ?? known[0];
519
+ const universeId = args.batchUniverseId ?? base?.target.universeId;
520
+ const placeId = args.batchPlaceId ?? base?.target.placeId;
521
+ if (!universeId || !placeId) {
522
+ throw new Error(
523
+ 'No test place to upload the bundle to: this checkout has no testable ' +
524
+ 'package. Pass --batch-place-id and --batch-universe-id.'
525
+ );
526
+ }
527
+ return { universeId, placeId, project: '' };
528
+ }
529
+
390
530
  /**
391
531
  * Run something once more if it failed for a reason that tends not to repeat.
392
532
  *
@@ -1,5 +1,6 @@
1
1
  import { Argv, CommandModule } from 'yargs';
2
2
  import { NevermoreGlobalArgs } from '../../args/global-args.js';
3
+ import { batchBundleCommand } from './batch-bundle-command.js';
3
4
  import { batchDeployCommand } from './batch-deploy-command.js';
4
5
  import { batchTestCommand } from './batch-test-command.js';
5
6
 
@@ -8,6 +9,7 @@ export class BatchCommand<T> implements CommandModule<T, NevermoreGlobalArgs> {
8
9
  public describe = 'Run operations across multiple packages';
9
10
 
10
11
  public builder = (args: Argv<T>) => {
12
+ args.command(batchBundleCommand as any);
11
13
  args.command(batchDeployCommand as any);
12
14
  args.command(batchTestCommand as any);
13
15
  return args as Argv<NevermoreGlobalArgs>;
@@ -2,6 +2,7 @@ import { CommandModule } from 'yargs';
2
2
  import { OutputHelper } from '@quenty/cli-output-helpers';
3
3
  import {
4
4
  type Diagnostic,
5
+ dedupeDiagnostics,
5
6
  emitAnnotations,
6
7
  writeAnnotationSummaryAsync,
7
8
  } from '@quenty/cli-output-helpers/reporting';
@@ -16,6 +17,7 @@ import {
16
17
  } from '../../utils/testing/reporting/index.js';
17
18
  import { parseJestLuaOutput } from '../../utils/testing/parsers/index.js';
18
19
  import { tryLoadSourcemapResolver } from '../../utils/sourcemap/index.js';
20
+ import { tryLoadAnnotationWorkspaceAsync } from '../../utils/testing/parsers/annotation-workspace.js';
19
21
 
20
22
  type ErrorReporter = Reporter & {
21
23
  setError(error: string): void;
@@ -25,6 +27,7 @@ type ErrorReporter = Reporter & {
25
27
  interface CiPostTestResultsArgs extends NevermoreGlobalArgs {
26
28
  input: string;
27
29
  runOutcome?: string;
30
+ sourcemap?: string;
28
31
  }
29
32
 
30
33
  /** Create the standard set of GitHub reporters for posting results. */
@@ -59,6 +62,12 @@ export const ciPostTestResultsCommand: CommandModule<
59
62
  'When the results file is missing and run-outcome is "success", ' +
60
63
  'posts a neutral "no tests" comment instead of an error.',
61
64
  type: 'string',
65
+ })
66
+ .option('sourcemap', {
67
+ describe:
68
+ 'Sourcemap that test failure annotations resolve files against ' +
69
+ '(default: sourcemap.json in the current directory)',
70
+ type: 'string',
62
71
  });
63
72
  },
64
73
  handler: async (args) => {
@@ -98,15 +107,21 @@ export const ciPostTestResultsCommand: CommandModule<
98
107
  }
99
108
 
100
109
  // Emit test failure annotations
101
- const sourcemapResolver = tryLoadSourcemapResolver(process.cwd());
102
- const allDiagnostics: Diagnostic[] = [];
110
+ const sourcemapResolver = tryLoadSourcemapResolver(
111
+ process.cwd(),
112
+ args.sourcemap
113
+ );
114
+ const workspace = await tryLoadAnnotationWorkspaceAsync(process.cwd());
115
+ const parsedDiagnostics: Diagnostic[] = [];
103
116
  for (const failure of state.getFailures()) {
104
117
  const diagnostics = parseJestLuaOutput(failure.logs, {
105
118
  packageName: failure.packageName,
106
119
  sourcemapResolver,
120
+ workspace,
107
121
  });
108
- allDiagnostics.push(...diagnostics);
122
+ parsedDiagnostics.push(...diagnostics);
109
123
  }
124
+ const allDiagnostics = dedupeDiagnostics(parsedDiagnostics);
110
125
 
111
126
  if (allDiagnostics.length > 0) {
112
127
  OutputHelper.info(
@@ -10,6 +10,11 @@ import {
10
10
  toManifestPlaceInfo,
11
11
  } from '@quenty/nevermore-deploy';
12
12
 
13
+ export interface WorkspacePackageInfo {
14
+ name: string;
15
+ path: string;
16
+ }
17
+
13
18
  export interface TargetPackage {
14
19
  name: string;
15
20
  path: string;
@@ -50,16 +55,19 @@ export interface BatchTarget {
50
55
  export async function discoverAllTargetPackagesAsync(
51
56
  targetName: string
52
57
  ): Promise<TargetPackage[]> {
53
- const { stdout } = await execa('pnpm', [
54
- 'ls',
55
- '--json',
56
- '-r',
57
- '--depth',
58
- '-1',
59
- ]);
58
+ return filterByTargetAsync(await listWorkspacePackagesAsync(), targetName);
59
+ }
60
60
 
61
- const packages = JSON.parse(stdout) as Array<{ name: string; path: string }>;
62
- return _filterByTargetAsync(packages, targetName);
61
+ /** Every package in the pnpm workspace, with absolute paths. */
62
+ export async function listWorkspacePackagesAsync(
63
+ cwd?: string
64
+ ): Promise<WorkspacePackageInfo[]> {
65
+ const { stdout } = await execa(
66
+ 'pnpm',
67
+ ['ls', '--json', '-r', '--depth', '-1'],
68
+ { cwd }
69
+ );
70
+ return JSON.parse(stdout) as WorkspacePackageInfo[];
63
71
  }
64
72
 
65
73
  /**
@@ -92,7 +100,7 @@ export async function discoverChangedTargetPackagesAsync(
92
100
  return [];
93
101
  }
94
102
 
95
- return _filterByTargetAsync(packages, targetName);
103
+ return filterByTargetAsync(packages, targetName);
96
104
  }
97
105
 
98
106
  /**
@@ -181,8 +189,9 @@ function _requireScriptTemplate(targets: BatchTarget[]): BatchTarget[] {
181
189
  return withTemplate;
182
190
  }
183
191
 
184
- async function _filterByTargetAsync(
185
- packages: Array<{ name: string; path: string }>,
192
+ /** Keep the packages whose deploy config has `targetName`, with its places. */
193
+ export async function filterByTargetAsync(
194
+ packages: WorkspacePackageInfo[],
186
195
  targetName: string
187
196
  ): Promise<TargetPackage[]> {
188
197
  const results: TargetPackage[] = [];
@@ -42,6 +42,8 @@ class BatchDeployment implements Deployment {
42
42
  ) {}
43
43
  }
44
44
 
45
+ type BatchBuild = Pick<CombinedProjectResult, 'chunks' | 'primaryTarget'>;
46
+
45
47
  /** One combined place's upload and execution, each started at most once. */
46
48
  interface ChunkRun {
47
49
  chunk: CombinedPlaceChunk;
@@ -98,7 +100,7 @@ export class BatchScriptJobContext implements JobContext {
98
100
  private _reporter?: Reporter;
99
101
 
100
102
  // Lazy-promise state
101
- private _combinedBuildPromise?: Promise<CombinedProjectResult>;
103
+ private _combinedBuildPromise?: Promise<BatchBuild>;
102
104
  private _chunkRuns?: ChunkRun[];
103
105
  private _executionPromise?: Promise<BatchExecutionOutcome>;
104
106
 
@@ -119,6 +121,12 @@ export class BatchScriptJobContext implements JobContext {
119
121
  */
120
122
  maxChunkBytes?: number;
121
123
  reporter?: Reporter;
124
+ /**
125
+ * Places already built by `batch bundle`, run instead of building. They
126
+ * upload to `batchPlaceId` in `batchUniverseId`, which are then required.
127
+ * The context owns `buildContext` and cleans it up like its own build.
128
+ */
129
+ prebuilt?: { chunks: CombinedPlaceChunk[]; buildContext: BuildContext };
122
130
  }
123
131
  ) {
124
132
  this._inner = inner;
@@ -130,6 +138,25 @@ export class BatchScriptJobContext implements JobContext {
130
138
  this._maxChunkBytes = options?.maxChunkBytes;
131
139
  this._reporter = options?.reporter;
132
140
 
141
+ if (options?.prebuilt) {
142
+ if (!this._batchPlaceId || !this._batchUniverseId) {
143
+ throw new Error(
144
+ 'Prebuilt places need batchPlaceId and batchUniverseId to upload to'
145
+ );
146
+ }
147
+ const { chunks, buildContext } = options.prebuilt;
148
+ this._combinedBuildContext = buildContext;
149
+ this._chunkRuns = chunks.map((chunk) => ({ chunk }));
150
+ this._combinedBuildPromise = Promise.resolve({
151
+ chunks,
152
+ primaryTarget: {
153
+ placeId: this._batchPlaceId,
154
+ universeId: this._batchUniverseId,
155
+ project: '',
156
+ },
157
+ });
158
+ }
159
+
133
160
  const withBasePlace = batchTargets.filter((t) => t.target.basePlace);
134
161
  if (withBasePlace.length > 0) {
135
162
  OutputHelper.warn(
@@ -203,6 +230,10 @@ export class BatchScriptJobContext implements JobContext {
203
230
  return this._getBatchExecutionAsync();
204
231
  }
205
232
 
233
+ async getBuiltChunksAsync(): Promise<CombinedPlaceChunk[]> {
234
+ return (await this._getChunkRunsAsync()).map((run) => run.chunk);
235
+ }
236
+
206
237
  async getLogsAsync(deployment: Deployment): Promise<JobLogs> {
207
238
  const batchDeployment = deployment as BatchDeployment;
208
239
  const { results, stats } = await this._getBatchExecutionAsync();
@@ -249,14 +280,14 @@ export class BatchScriptJobContext implements JobContext {
249
280
 
250
281
  // ---- Lazy-promise internals ----
251
282
 
252
- private _getCombinedBuildAsync(): Promise<CombinedProjectResult> {
283
+ private _getCombinedBuildAsync(): Promise<BatchBuild> {
253
284
  if (!this._combinedBuildPromise) {
254
285
  this._combinedBuildPromise = this._doCombinedBuildAsync();
255
286
  }
256
287
  return this._combinedBuildPromise;
257
288
  }
258
289
 
259
- private async _doCombinedBuildAsync(): Promise<CombinedProjectResult> {
290
+ private async _doCombinedBuildAsync(): Promise<BatchBuild> {
260
291
  OutputHelper.verbose('Building combined batch place...');
261
292
 
262
293
  // Set all packages to "waiting" — they're queued for building
@@ -4,17 +4,16 @@ import type { SourcemapNode } from './sourcemap-types.js';
4
4
  import { SourcemapResolver } from './sourcemap-resolver.js';
5
5
 
6
6
  /**
7
- * Try to load a `SourcemapResolver` from `sourcemap.json` in the given
8
- * directory.
7
+ * Try to load a `SourcemapResolver` for `repoRoot`, from its `sourcemap.json`
8
+ * unless another file is given.
9
9
  *
10
10
  * Returns `undefined` if the file doesn't exist or can't be parsed — callers
11
11
  * should fall back to heuristic resolution.
12
12
  */
13
13
  export function tryLoadSourcemapResolver(
14
- repoRoot: string
14
+ repoRoot: string,
15
+ sourcemapPath = path.join(repoRoot, 'sourcemap.json')
15
16
  ): SourcemapResolver | undefined {
16
- const sourcemapPath = path.join(repoRoot, 'sourcemap.json');
17
-
18
17
  let content: string;
19
18
  try {
20
19
  content = fs.readFileSync(sourcemapPath, 'utf-8');
@@ -22,12 +21,11 @@ export function tryLoadSourcemapResolver(
22
21
  return undefined;
23
22
  }
24
23
 
25
- let root: SourcemapNode;
24
+ // A fork's build supplies this file, so a malformed tree is not fatal.
26
25
  try {
27
- root = JSON.parse(content) as SourcemapNode;
26
+ const root = JSON.parse(content) as SourcemapNode;
27
+ return SourcemapResolver.fromSourcemap(root, repoRoot);
28
28
  } catch {
29
29
  return undefined;
30
30
  }
31
-
32
- return SourcemapResolver.fromSourcemap(root, repoRoot);
33
31
  }