@quenty/nevermore-cli 4.47.2 → 4.49.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 (54) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/build-scripts/combine-test-places.lua +13 -11
  3. package/dist/commands/batch-command/batch-test-command.d.ts +1 -0
  4. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  5. package/dist/commands/batch-command/batch-test-command.js +14 -3
  6. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  7. package/dist/commands/test-command/test-command.d.ts.map +1 -1
  8. package/dist/commands/test-command/test-command.js +5 -0
  9. package/dist/commands/test-command/test-command.js.map +1 -1
  10. package/dist/utils/build/place-recompression.d.ts +25 -0
  11. package/dist/utils/build/place-recompression.d.ts.map +1 -0
  12. package/dist/utils/build/place-recompression.js +166 -0
  13. package/dist/utils/build/place-recompression.js.map +1 -0
  14. package/dist/utils/build/place-recompression.test.d.ts +2 -0
  15. package/dist/utils/build/place-recompression.test.d.ts.map +1 -0
  16. package/dist/utils/build/place-recompression.test.js +68 -0
  17. package/dist/utils/build/place-recompression.test.js.map +1 -0
  18. package/dist/utils/job-context/batch-script-job-context.d.ts +20 -8
  19. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  20. package/dist/utils/job-context/batch-script-job-context.js +94 -22
  21. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  22. package/dist/utils/open-cloud/open-cloud-client.d.ts +12 -0
  23. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  24. package/dist/utils/open-cloud/open-cloud-client.js +37 -1
  25. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  26. package/dist/utils/testing/batch-run.e2e.test.js +78 -13
  27. package/dist/utils/testing/batch-run.e2e.test.js.map +1 -1
  28. package/dist/utils/testing/runner/combined-project-generator.d.ts +33 -5
  29. package/dist/utils/testing/runner/combined-project-generator.d.ts.map +1 -1
  30. package/dist/utils/testing/runner/combined-project-generator.js +116 -19
  31. package/dist/utils/testing/runner/combined-project-generator.js.map +1 -1
  32. package/dist/utils/testing/runner/combined-project-generator.test.d.ts +2 -0
  33. package/dist/utils/testing/runner/combined-project-generator.test.d.ts.map +1 -0
  34. package/dist/utils/testing/runner/combined-project-generator.test.js +26 -0
  35. package/dist/utils/testing/runner/combined-project-generator.test.js.map +1 -0
  36. package/dist/utils/testing/runner/test-runner.d.ts +3 -0
  37. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  38. package/dist/utils/testing/runner/test-runner.js +2 -1
  39. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  40. package/dist/utils/testing/runner/test-runner.test.js +14 -0
  41. package/dist/utils/testing/runner/test-runner.test.js.map +1 -1
  42. package/package.json +2 -2
  43. package/src/commands/batch-command/batch-test-command.ts +17 -3
  44. package/src/commands/test-command/test-command.ts +5 -0
  45. package/src/utils/build/place-recompression.test.ts +104 -0
  46. package/src/utils/build/place-recompression.ts +236 -0
  47. package/src/utils/job-context/batch-script-job-context.ts +140 -31
  48. package/src/utils/open-cloud/open-cloud-client.ts +58 -1
  49. package/src/utils/testing/batch-run.e2e.test.ts +93 -13
  50. package/src/utils/testing/runner/combined-project-generator.test.ts +30 -0
  51. package/src/utils/testing/runner/combined-project-generator.ts +198 -30
  52. package/src/utils/testing/runner/test-runner.test.ts +20 -0
  53. package/src/utils/testing/runner/test-runner.ts +5 -0
  54. package/tsconfig.tsbuildinfo +1 -1
@@ -13,6 +13,20 @@ import {
13
13
  type ParsedTestLogs,
14
14
  } from '../testing/test-log-parser.js';
15
15
  import { type LogFetchStats } from '../testing/log-fetch-stats.js';
16
+ import { recompressPlace } from '../build/place-recompression.js';
17
+
18
+ /**
19
+ * The largest place file the place version endpoint accepts, measured: 150 MiB
20
+ * exactly uploads, one byte more is refused from `Content-Length` before the
21
+ * body is sent, as `400 InvalidRequest: "Request upload was interrupted before
22
+ * completion."` — and somewhere past ~200 MB as `413 Payload Too Large`.
23
+ * Neither says anything about size, so it is checked here first.
24
+ *
25
+ * Not the 100 MB Roblox documents as supported
26
+ * (https://create.roblox.com/docs/projects/place-files); that is where Studio
27
+ * saving "might fail", not where the API cuts off.
28
+ */
29
+ export const PLACE_UPLOAD_MAX_BYTES = 150 * 1024 * 1024;
16
30
 
17
31
  /**
18
32
  * One engine message, with the severity Open Cloud reported for it.
@@ -276,8 +290,20 @@ export class OpenCloudClient {
276
290
  );
277
291
 
278
292
  const apiKey = await this._resolveApiKeyAsync();
279
- const fileBuffer = await fs.readFile(rbxlPath);
293
+ const fileBuffer = _recompressForUpload(
294
+ rbxlPath,
295
+ await fs.readFile(rbxlPath)
296
+ );
280
297
  const totalBytes = fileBuffer.length;
298
+ if (totalBytes > PLACE_UPLOAD_MAX_BYTES) {
299
+ throw new Error(
300
+ `Upload failed: ${path.basename(rbxlPath)} is ${_formatMegabytes(
301
+ totalBytes
302
+ )}, over the Open Cloud ${_formatMegabytes(
303
+ PLACE_UPLOAD_MAX_BYTES
304
+ )} place upload limit`
305
+ );
306
+ }
281
307
  const versionType = publish ? 'Published' : 'Saved';
282
308
  const url = `https://apis.roblox.com/universes/v1/${universeId}/places/${placeId}/versions?versionType=${versionType}`;
283
309
 
@@ -1097,3 +1123,34 @@ async function _readBodyWithProgressAsync(
1097
1123
 
1098
1124
  return Buffer.concat(chunks);
1099
1125
  }
1126
+
1127
+ /**
1128
+ * The bytes to upload for a place file: recompressed when that is possible,
1129
+ * the file as read when it is not. An upload never fails for want of zstd —
1130
+ * it only goes up larger.
1131
+ */
1132
+ function _recompressForUpload(rbxlPath: string, fileBuffer: Buffer): Buffer {
1133
+ const name = path.basename(rbxlPath);
1134
+ const result = recompressPlace(fileBuffer);
1135
+
1136
+ if (result.kind === 'recompressed') {
1137
+ OutputHelper.verbose(
1138
+ `Recompressed ${name}: ${_formatMegabytes(
1139
+ result.originalBytes
1140
+ )} -> ${_formatMegabytes(result.data.length)}`
1141
+ );
1142
+ } else if (result.kind === 'unsupported') {
1143
+ OutputHelper.warn(
1144
+ `Uploading ${name} as built (${_formatMegabytes(
1145
+ fileBuffer.length
1146
+ )}) without recompressing it: ${result.reason}`
1147
+ );
1148
+ }
1149
+
1150
+ return result.data;
1151
+ }
1152
+
1153
+ /** In Roblox's MB, which are 1024 * 1024 bytes. */
1154
+ function _formatMegabytes(bytes: number): string {
1155
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
1156
+ }
@@ -14,12 +14,13 @@
14
14
  * read and on what each package's result came out as.
15
15
  */
16
16
 
17
- import { describe, it, expect, vi } from 'vitest';
17
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
18
18
  import { OutputHelper } from '@quenty/cli-output-helpers';
19
19
  import { BatchScriptJobContext } from '../job-context/batch-script-job-context.js';
20
20
  import {
21
21
  type BuiltPlace,
22
22
  type Deployment,
23
+ type DeployPlaceOptions,
23
24
  type JobContext,
24
25
  type JobLogs,
25
26
  type RunScriptOptions,
@@ -29,19 +30,33 @@ import { type TaskLogMessage } from '../open-cloud/open-cloud-client.js';
29
30
  import { type BatchTarget } from '../batch/changed-packages-utils.js';
30
31
  import { renderBatchOutcome } from './parsers/batch-log-renderer.js';
31
32
 
32
- const { SLUG_MAP } = vi.hoisted(() => ({
33
- SLUG_MAP: new Map([
33
+ const { SLUG_MAP, combined } = vi.hoisted(() => {
34
+ const SLUG_MAP = new Map([
34
35
  ['@quenty/alpha', 'alpha'],
35
36
  ['@quenty/beta', 'beta'],
36
- ]),
37
- }));
37
+ ]);
38
+ return {
39
+ SLUG_MAP,
40
+ // Which packages share a place. One place unless a test splits them.
41
+ combined: {
42
+ chunks: [[...SLUG_MAP]] as Array<Array<[string, string]>>,
43
+ },
44
+ };
45
+ });
38
46
 
39
47
  // The only mocked collaborator: building the combined place shells out to rojo
40
48
  // and lune, which is a different subject from what this test is about. What it
41
- // produces that matters downstream is the slug map.
42
- vi.mock('./runner/combined-project-generator.js', () => ({
49
+ // produces that matters downstream is the slug map, and how it was split.
50
+ vi.mock('./runner/combined-project-generator.js', async (importOriginal) => ({
51
+ ...(await importOriginal<
52
+ typeof import('./runner/combined-project-generator.js')
53
+ >()),
43
54
  generateCombinedProjectAsync: vi.fn(async () => ({
44
- rbxlPath: '/tmp/batch.rbxl',
55
+ chunks: combined.chunks.map((entries, index) => ({
56
+ rbxlPath: `/tmp/batch-${index + 1}.rbxl`,
57
+ slugMap: new Map(entries),
58
+ sizeBytes: 1024,
59
+ })),
45
60
  slugMap: SLUG_MAP,
46
61
  primaryTarget: { placeId: 1, universeId: 2 },
47
62
  buildContext: { cleanupAsync: async () => {} },
@@ -119,11 +134,17 @@ const TASK_MESSAGES: TaskLogMessage[] = [
119
134
  { message: SUMMARY_JSON, messageType: 'OUTPUT' },
120
135
  ];
121
136
 
122
- /** A transport that runs nothing and reports the messages it was handed. */
137
+ /**
138
+ * A transport that runs nothing and reports the messages it was handed — or,
139
+ * with `messagesFor`, the messages for whichever packages the script ran.
140
+ */
123
141
  class FakeTransport implements JobContext {
124
142
  public scriptContent?: string;
143
+ public scriptContents: string[] = [];
144
+ public deployedPaths: string[] = [];
125
145
  public deployCount = 0;
126
146
  public runCount = 0;
147
+ public messagesFor?: (script: string) => TaskLogMessage[];
127
148
 
128
149
  constructor(
129
150
  private readonly _messages: TaskLogMessage[],
@@ -134,8 +155,11 @@ class FakeTransport implements JobContext {
134
155
  throw new Error('the batch context builds the combined place itself');
135
156
  }
136
157
 
137
- async deployBuiltPlaceAsync(): Promise<Deployment> {
158
+ async deployBuiltPlaceAsync(
159
+ options: DeployPlaceOptions
160
+ ): Promise<Deployment> {
138
161
  this.deployCount++;
162
+ this.deployedPaths.push(options.builtPlace.rbxlPath);
139
163
  return {};
140
164
  }
141
165
 
@@ -145,6 +169,7 @@ class FakeTransport implements JobContext {
145
169
  ): Promise<ScriptRunResult> {
146
170
  this.runCount++;
147
171
  this.scriptContent = options.scriptContent;
172
+ this.scriptContents.push(options.scriptContent);
148
173
  return {
149
174
  success: true,
150
175
  taskState: 'COMPLETE',
@@ -153,15 +178,17 @@ class FakeTransport implements JobContext {
153
178
  }
154
179
 
155
180
  async getLogsAsync(): Promise<JobLogs> {
156
- const text = this._messages.map((m) => m.message).join('\n');
181
+ const messages =
182
+ this.messagesFor?.(this.scriptContent ?? '') ?? this._messages;
183
+ const text = messages.map((m) => m.message).join('\n');
157
184
  return {
158
185
  text,
159
- messages: this._messages,
186
+ messages,
160
187
  stats: {
161
188
  requests: 1,
162
189
  pages: 1,
163
190
  entries: 1,
164
- messages: this._messages.length,
191
+ messages: messages.length,
165
192
  chars: text.length,
166
193
  },
167
194
  };
@@ -210,6 +237,10 @@ async function runPackageAsync(
210
237
  }
211
238
 
212
239
  describe('an aggregated batch run, end to end', () => {
240
+ beforeEach(() => {
241
+ combined.chunks = [[...SLUG_MAP]];
242
+ });
243
+
213
244
  it('renders the whole run: every line, in order, grouped and titled', async () => {
214
245
  const { batch } = createBatch();
215
246
 
@@ -422,4 +453,53 @@ describe('an aggregated batch run, end to end', () => {
422
453
  // being scraped out of the section that went missing.
423
454
  expect(outcome.results.get('@quenty/alpha')?.testCounts?.total).toBe(68);
424
455
  });
456
+
457
+ it('uploads and runs each place of a split batch, and merges what they report', async () => {
458
+ combined.chunks = [
459
+ [['@quenty/alpha', 'alpha']],
460
+ [['@quenty/beta', 'beta']],
461
+ ];
462
+ const { batch, transport } = createBatch();
463
+ // Each place prints only the sections and summary of its own packages.
464
+ transport.messagesFor = (script) => {
465
+ const slug = script.includes('["alpha"]') ? 'alpha' : 'beta';
466
+ const entry = SUMMARY_ENTRIES.find((e) => e.slug === slug)!;
467
+ const verdict = entry.success ? 'PASS' : 'FAIL';
468
+ return [
469
+ { message: `===BATCH_TEST_BEGIN ${slug}===`, messageType: 'OUTPUT' },
470
+ {
471
+ message: `===BATCH_TEST_END ${slug} ${verdict} ${entry.durationMs}===`,
472
+ messageType: 'OUTPUT',
473
+ },
474
+ { message: '===BATCH_TEST_SUMMARY===', messageType: 'OUTPUT' },
475
+ { message: JSON.stringify([entry]), messageType: 'OUTPUT' },
476
+ ];
477
+ };
478
+
479
+ const outcome = await batch.getExecutionOutcomeAsync();
480
+ const alpha = await runPackageAsync(batch, '@quenty/alpha');
481
+ const beta = await runPackageAsync(batch, '@quenty/beta');
482
+
483
+ expect(transport.deployedPaths).toEqual([
484
+ '/tmp/batch-1.rbxl',
485
+ '/tmp/batch-2.rbxl',
486
+ ]);
487
+ expect(transport.runCount).toBe(2);
488
+ expect(transport.scriptContents[0]).toContain('["alpha"]');
489
+ expect(transport.scriptContents[1]).toContain('["beta"]');
490
+
491
+ expect(alpha.success).toBe(true);
492
+ expect(alpha.testResults).toMatchObject({ passed: 68, total: 68 });
493
+ expect(beta.success).toBe(false);
494
+ expect(beta.testResults).toMatchObject({ passed: 3, failed: 1 });
495
+ expect(outcome.stats?.requests).toBe(2);
496
+ expect(
497
+ renderBatchOutcome(outcome, { useGroups: true, color: false })
498
+ ).toEqual(
499
+ expect.arrayContaining([
500
+ '::group::@quenty/alpha - ✅ Passed (68/68) (1.5s)',
501
+ '::group::@quenty/beta - ❌ FAILED (3/4) (900ms)',
502
+ ])
503
+ );
504
+ });
425
505
  });
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { partitionBySize } from './combined-project-generator.js';
3
+
4
+ describe('partitionBySize', () => {
5
+ const identity = (n: number) => n;
6
+
7
+ it('keeps everything in one group when it fits', () => {
8
+ expect(partitionBySize([10, 20, 30], identity, 60)).toEqual([[10, 20, 30]]);
9
+ });
10
+
11
+ it('starts a new group, in order, before the budget would be exceeded', () => {
12
+ expect(partitionBySize([40, 30, 20, 50, 10], identity, 60)).toEqual([
13
+ [40],
14
+ [30, 20],
15
+ [50, 10],
16
+ ]);
17
+ });
18
+
19
+ it('gives an item over the budget a group of its own', () => {
20
+ expect(partitionBySize([10, 100, 10], identity, 60)).toEqual([
21
+ [10],
22
+ [100],
23
+ [10],
24
+ ]);
25
+ });
26
+
27
+ it('returns no groups for no items', () => {
28
+ expect(partitionBySize([], identity, 60)).toEqual([]);
29
+ });
30
+ });
@@ -7,11 +7,32 @@ import {
7
7
  import { OutputHelper } from '@quenty/cli-output-helpers';
8
8
  import { type DeployTarget } from '@quenty/nevermore-deploy';
9
9
  import { type BatchTarget } from '../../batch/changed-packages-utils.js';
10
+ import { recompressPlace } from '../../build/place-recompression.js';
10
11
 
11
- export interface CombinedProjectResult {
12
- /** Absolute path to the combined .rbxl file. */
12
+ /**
13
+ * Budget for one combined place, as uploaded (recompressed). Under the upload
14
+ * limit (`PLACE_UPLOAD_MAX_BYTES`), and under the 100 MB Roblox documents as
15
+ * supported, since split places run in parallel and cost little.
16
+ */
17
+ export const DEFAULT_MAX_CHUNK_BYTES = 90 * 1024 * 1024;
18
+
19
+ /** One combined place: the packages that are uploaded and executed together. */
20
+ export interface CombinedPlaceChunk {
21
+ /** Absolute path to this chunk's combined .rbxl file. */
13
22
  rbxlPath: string;
14
- /** batchTarget.name → ServerScriptService slug mapping. */
23
+ /** batchTarget.name → ServerScriptService slug, for this chunk's packages only. */
24
+ slugMap: Map<string, string>;
25
+ /** Size on disk — recompressed whenever the batch has a size budget. */
26
+ sizeBytes: number;
27
+ }
28
+
29
+ export interface CombinedProjectResult {
30
+ /**
31
+ * The combined places, in package order. Usually one; more when even
32
+ * recompressed the packages together exceed the per-upload budget.
33
+ */
34
+ chunks: CombinedPlaceChunk[];
35
+ /** batchTarget.name → ServerScriptService slug mapping, across every chunk. */
15
36
  slugMap: Map<string, string>;
16
37
  /** First batch target's deploy target (provides placeId/universeId for upload). */
17
38
  primaryTarget: DeployTarget;
@@ -35,26 +56,36 @@ export interface CombinedBuildProgress {
35
56
 
36
57
  /** Per-package build info collected during phase 1. */
37
58
  interface PackageBuildInfo {
59
+ name: string;
38
60
  slug: string;
39
61
  rbxlPath: string;
40
62
  scriptPath: string;
63
+ sizeBytes: number;
41
64
  }
42
65
 
43
66
  /**
44
- * Build a combined .rbxl containing all testable packages.
67
+ * Build combined .rbxl files containing all testable packages.
45
68
  *
46
69
  * Two-phase approach to avoid rojo's symlink deduplication:
47
70
  * Phase 1: Build each package's .rbxl individually via rojo
48
- * Phase 2: Use Lune to merge the individual builds into a single .rbxl
71
+ * Phase 2: Use Lune to merge the individual builds into one place, split
72
+ * into more only if it measures over `maxChunkBytes`
49
73
  */
50
74
  export async function generateCombinedProjectAsync(options: {
51
75
  batchTargets: BatchTarget[];
52
76
  repoRoot: string;
53
77
  batchPlaceId?: number;
54
78
  batchUniverseId?: number;
79
+ maxChunkBytes?: number;
55
80
  progress?: CombinedBuildProgress;
56
81
  }): Promise<CombinedProjectResult> {
57
- const { batchTargets, batchPlaceId, batchUniverseId, progress } = options;
82
+ const {
83
+ batchTargets,
84
+ batchPlaceId,
85
+ batchUniverseId,
86
+ maxChunkBytes = DEFAULT_MAX_CHUNK_BYTES,
87
+ progress,
88
+ } = options;
58
89
 
59
90
  if (batchTargets.length === 0) {
60
91
  throw new Error(
@@ -116,50 +147,187 @@ export async function generateCombinedProjectAsync(options: {
116
147
  }
117
148
  const scriptPath = path.resolve(buildTarget.path, target.scriptTemplate);
118
149
 
119
- builds.push({ slug, rbxlPath, scriptPath });
150
+ const { size: sizeBytes } = await fs.stat(rbxlPath);
151
+
152
+ builds.push({
153
+ name: buildTarget.name,
154
+ slug,
155
+ rbxlPath,
156
+ scriptPath,
157
+ sizeBytes,
158
+ });
120
159
  }
121
160
 
122
161
  // ── Phase 2: Merge via Lune ──
123
162
 
124
- const combinedRbxlPath = buildContext.resolvePath('batch-test.rbxl');
125
163
  const luneScriptPath = resolvePackagePath(
126
164
  import.meta.url,
127
165
  'build-scripts',
128
166
  'combine-test-places.lua'
129
167
  );
130
168
 
131
- // Build args: <outputPath> <slug1> <rbxl1> <script1> <slug2> <rbxl2> <script2> ...
132
- const luneArgs: string[] = [combinedRbxlPath];
133
- for (const build of builds) {
134
- luneArgs.push(build.slug, build.rbxlPath, build.scriptPath);
135
- }
136
-
137
- OutputHelper.verbose(
138
- `Merging ${builds.length} targets into combined .rbxl...`
139
- );
140
169
  progress?.onCombineStart?.();
141
- progress?.onStepProgress?.({
142
- kind: 'steps',
143
- completed: 0,
144
- total: builds.length,
145
- });
146
- await buildContext.executeLuneTransformScriptAsync(
147
- luneScriptPath,
148
- ...luneArgs
149
- );
150
170
 
151
- OutputHelper.verbose(
152
- `Combined ${slugMap.size} targets at ${combinedRbxlPath}`
153
- );
171
+ // One place first, split only if it measures over budget. Sizes do not add
172
+ // up: recompression dedupes the modules the packages share, so a merge of
173
+ // many packages costs little more than one, and only measuring it says so.
174
+ const totalBytes = builds.reduce((sum, b) => sum + b.sizeBytes, 0);
175
+ let groups = [builds];
176
+ let chunks: CombinedPlaceChunk[];
177
+ for (;;) {
178
+ chunks = await _mergeGroupsAsync(
179
+ buildContext,
180
+ luneScriptPath,
181
+ groups,
182
+ Number.isFinite(maxChunkBytes),
183
+ progress
184
+ );
185
+
186
+ const largest = Math.max(...chunks.map((c) => c.sizeBytes));
187
+ if (largest <= maxChunkBytes || groups.length === builds.length) {
188
+ break;
189
+ }
190
+
191
+ const groupCount = Math.max(
192
+ groups.length + 1,
193
+ Math.ceil((groups.length * largest) / maxChunkBytes)
194
+ );
195
+ OutputHelper.info(
196
+ `Combined place${groups.length === 1 ? ' is' : 's reach'} ` +
197
+ `${formatMegabytes(largest)}, over the ${formatMegabytes(
198
+ maxChunkBytes
199
+ )} per-upload budget — splitting ${builds.length} targets into ` +
200
+ `about ${groupCount} places.`
201
+ );
202
+ groups = partitionBySize(
203
+ builds,
204
+ (b) => b.sizeBytes,
205
+ totalBytes / groupCount
206
+ );
207
+ }
154
208
 
155
209
  return {
156
- rbxlPath: combinedRbxlPath,
210
+ chunks,
157
211
  slugMap,
158
212
  primaryTarget,
159
213
  buildContext,
160
214
  };
161
215
  }
162
216
 
217
+ /**
218
+ * Merge each group into its own place, recompressed when it will be measured
219
+ * against a budget — which is also the form it is uploaded in, so the upload
220
+ * has nothing left to do.
221
+ */
222
+ async function _mergeGroupsAsync(
223
+ buildContext: BuildContext,
224
+ luneScriptPath: string,
225
+ groups: PackageBuildInfo[][],
226
+ recompress: boolean,
227
+ progress: CombinedBuildProgress | undefined
228
+ ): Promise<CombinedPlaceChunk[]> {
229
+ progress?.onStepProgress?.({
230
+ kind: 'steps',
231
+ completed: 0,
232
+ total: groups.length,
233
+ });
234
+
235
+ const chunks: CombinedPlaceChunk[] = [];
236
+ for (const [index, group] of groups.entries()) {
237
+ const combinedRbxlPath = buildContext.resolvePath(
238
+ groups.length === 1 ? 'batch-test.rbxl' : `batch-test-${index + 1}.rbxl`
239
+ );
240
+
241
+ // Build args: <outputPath> <slug1> <rbxl1> <script1> <slug2> <rbxl2> <script2> ...
242
+ const luneArgs: string[] = [combinedRbxlPath];
243
+ for (const build of group) {
244
+ luneArgs.push(build.slug, build.rbxlPath, build.scriptPath);
245
+ }
246
+
247
+ OutputHelper.verbose(
248
+ `Merging ${group.length} targets into ${path.basename(
249
+ combinedRbxlPath
250
+ )}...`
251
+ );
252
+ await buildContext.executeLuneTransformScriptAsync(
253
+ luneScriptPath,
254
+ ...luneArgs
255
+ );
256
+
257
+ let sizeBytes: number;
258
+ if (recompress) {
259
+ const merged = await fs.readFile(combinedRbxlPath);
260
+ const result = recompressPlace(merged);
261
+ if (result.kind === 'recompressed') {
262
+ await fs.writeFile(combinedRbxlPath, result.data);
263
+ }
264
+ sizeBytes = result.data.length;
265
+ OutputHelper.verbose(
266
+ `Combined ${group.length} targets at ${combinedRbxlPath} ` +
267
+ `(${formatMegabytes(merged.length)} as built, ` +
268
+ `${formatMegabytes(sizeBytes)} to upload)`
269
+ );
270
+ } else {
271
+ ({ size: sizeBytes } = await fs.stat(combinedRbxlPath));
272
+ OutputHelper.verbose(
273
+ `Combined ${
274
+ group.length
275
+ } targets at ${combinedRbxlPath} (${formatMegabytes(sizeBytes)})`
276
+ );
277
+ }
278
+
279
+ chunks.push({
280
+ rbxlPath: combinedRbxlPath,
281
+ slugMap: new Map(group.map((b) => [b.name, b.slug])),
282
+ sizeBytes,
283
+ });
284
+ progress?.onStepProgress?.({
285
+ kind: 'steps',
286
+ completed: index + 1,
287
+ total: groups.length,
288
+ });
289
+ }
290
+
291
+ return chunks;
292
+ }
293
+
294
+ /**
295
+ * Split items, in order, into consecutive groups whose summed size stays within
296
+ * `maxBytes`. An item larger than the budget on its own gets a group to itself
297
+ * rather than being dropped — the upload is what reports it as too big.
298
+ */
299
+ export function partitionBySize<T>(
300
+ items: T[],
301
+ sizeOf: (item: T) => number,
302
+ maxBytes: number
303
+ ): T[][] {
304
+ const groups: T[][] = [];
305
+ let current: T[] = [];
306
+ let currentBytes = 0;
307
+
308
+ for (const item of items) {
309
+ const size = sizeOf(item);
310
+ if (current.length > 0 && currentBytes + size > maxBytes) {
311
+ groups.push(current);
312
+ current = [];
313
+ currentBytes = 0;
314
+ }
315
+ current.push(item);
316
+ currentBytes += size;
317
+ }
318
+
319
+ if (current.length > 0) {
320
+ groups.push(current);
321
+ }
322
+
323
+ return groups;
324
+ }
325
+
326
+ /** In Roblox's MB, which are 1024 * 1024 bytes. */
327
+ export function formatMegabytes(bytes: number): string {
328
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
329
+ }
330
+
163
331
  /**
164
332
  * Extract the slug from a test rojo project.
165
333
  * The slug is the first non-`$`, non-`Script` key under ServerScriptService.
@@ -102,6 +102,26 @@ describe('runSingleTestAsync', () => {
102
102
  });
103
103
  }
104
104
 
105
+ it('hands overrides to the build, which owns applying them', async () => {
106
+ const context = createContext(
107
+ { success: true, returnValues: [structuredResults()] },
108
+ PASSING_LOGS
109
+ );
110
+ const buildSpy = vi.spyOn(context, 'buildPlaceAsync');
111
+ const overrides = { placeId: 7, scriptTemplate: 'ServerMain.server.lua' };
112
+
113
+ await runSingleTestAsync(context, {
114
+ packagePath,
115
+ packageName: 'maid',
116
+ target: {
117
+ scriptTemplate: 'ServerMain.server.lua',
118
+ } as unknown as DeployTarget,
119
+ overrides,
120
+ });
121
+
122
+ expect(buildSpy.mock.calls[0]![0].overrides).toBe(overrides);
123
+ });
124
+
105
125
  it('fails a run whose returned results say it failed', async () => {
106
126
  // The runner used to announce this by throwing, which failed the task. Now
107
127
  // it returns the verdict, and these logs are what a truncated window leaves
@@ -3,6 +3,7 @@ import * as path from 'path';
3
3
  import { randomUUID } from 'crypto';
4
4
  import { type DeployTarget } from '@quenty/nevermore-deploy';
5
5
  import { type JobContext } from '../../job-context/job-context.js';
6
+ import { type DeployOverrides } from '../../build/build.js';
6
7
  import {
7
8
  type ParsedTestCounts,
8
9
  parseTestLogs,
@@ -74,6 +75,8 @@ export interface SingleTestOptions {
74
75
  * here (see flattenToBatchTargets) — the runner never picks places[0] itself.
75
76
  */
76
77
  target: DeployTarget;
78
+ /** CLI flags that replace the target's universeId, placeId, or scriptTemplate. */
79
+ overrides?: DeployOverrides;
77
80
  timeoutMs?: number;
78
81
  /** Luau code to execute directly, bypassing the configured scriptTemplate. */
79
82
  scriptText?: string;
@@ -104,6 +107,7 @@ export async function runSingleTestAsync(
104
107
  timeoutMs = 300_000,
105
108
  scriptText,
106
109
  target,
110
+ overrides,
107
111
  } = options;
108
112
 
109
113
  const sessionId = randomUUID();
@@ -112,6 +116,7 @@ export async function runSingleTestAsync(
112
116
  outputFileName: `test-${sessionId}.rbxl`,
113
117
  packagePath,
114
118
  packageName,
119
+ overrides,
115
120
  });
116
121
 
117
122
  // Packages that ship or use the manifest get the same deploy-metadata stamp a