@quenty/nevermore-cli 4.48.0 → 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 (41) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/commands/batch-command/batch-test-command.d.ts +1 -0
  3. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  4. package/dist/commands/batch-command/batch-test-command.js +11 -0
  5. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  6. package/dist/utils/build/place-recompression.d.ts +25 -0
  7. package/dist/utils/build/place-recompression.d.ts.map +1 -0
  8. package/dist/utils/build/place-recompression.js +166 -0
  9. package/dist/utils/build/place-recompression.js.map +1 -0
  10. package/dist/utils/build/place-recompression.test.d.ts +2 -0
  11. package/dist/utils/build/place-recompression.test.d.ts.map +1 -0
  12. package/dist/utils/build/place-recompression.test.js +68 -0
  13. package/dist/utils/build/place-recompression.test.js.map +1 -0
  14. package/dist/utils/job-context/batch-script-job-context.d.ts +20 -8
  15. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  16. package/dist/utils/job-context/batch-script-job-context.js +94 -22
  17. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  18. package/dist/utils/open-cloud/open-cloud-client.d.ts +12 -0
  19. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  20. package/dist/utils/open-cloud/open-cloud-client.js +37 -1
  21. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  22. package/dist/utils/testing/batch-run.e2e.test.js +78 -13
  23. package/dist/utils/testing/batch-run.e2e.test.js.map +1 -1
  24. package/dist/utils/testing/runner/combined-project-generator.d.ts +33 -5
  25. package/dist/utils/testing/runner/combined-project-generator.d.ts.map +1 -1
  26. package/dist/utils/testing/runner/combined-project-generator.js +116 -19
  27. package/dist/utils/testing/runner/combined-project-generator.js.map +1 -1
  28. package/dist/utils/testing/runner/combined-project-generator.test.d.ts +2 -0
  29. package/dist/utils/testing/runner/combined-project-generator.test.d.ts.map +1 -0
  30. package/dist/utils/testing/runner/combined-project-generator.test.js +26 -0
  31. package/dist/utils/testing/runner/combined-project-generator.test.js.map +1 -0
  32. package/package.json +2 -2
  33. package/src/commands/batch-command/batch-test-command.ts +14 -0
  34. package/src/utils/build/place-recompression.test.ts +104 -0
  35. package/src/utils/build/place-recompression.ts +236 -0
  36. package/src/utils/job-context/batch-script-job-context.ts +140 -31
  37. package/src/utils/open-cloud/open-cloud-client.ts +58 -1
  38. package/src/utils/testing/batch-run.e2e.test.ts +93 -13
  39. package/src/utils/testing/runner/combined-project-generator.test.ts +30 -0
  40. package/src/utils/testing/runner/combined-project-generator.ts +198 -30
  41. package/tsconfig.tsbuildinfo +1 -1
@@ -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.