@quenty/nevermore-cli 4.50.1 → 4.52.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 (116) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/commands/batch-command/batch-bundle-command.d.ts +15 -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 +168 -0
  5. package/dist/commands/batch-command/batch-bundle-command.js.map +1 -0
  6. package/dist/commands/batch-command/batch-deploy-command.d.ts +2 -0
  7. package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
  8. package/dist/commands/batch-command/batch-deploy-command.js +115 -16
  9. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  10. package/dist/commands/batch-command/batch-test-command.d.ts +1 -0
  11. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  12. package/dist/commands/batch-command/batch-test-command.js +109 -17
  13. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  14. package/dist/commands/batch-command/index.d.ts.map +1 -1
  15. package/dist/commands/batch-command/index.js +2 -0
  16. package/dist/commands/batch-command/index.js.map +1 -1
  17. package/dist/commands/deploy-command/index.d.ts.map +1 -1
  18. package/dist/commands/deploy-command/index.js +7 -0
  19. package/dist/commands/deploy-command/index.js.map +1 -1
  20. package/dist/commands/test-command/test-command.d.ts +1 -0
  21. package/dist/commands/test-command/test-command.d.ts.map +1 -1
  22. package/dist/commands/test-command/test-command.js +19 -3
  23. package/dist/commands/test-command/test-command.js.map +1 -1
  24. package/dist/commands/tools-command/ci-post-deploy-results.d.ts.map +1 -1
  25. package/dist/commands/tools-command/ci-post-deploy-results.js +6 -2
  26. package/dist/commands/tools-command/ci-post-deploy-results.js.map +1 -1
  27. package/dist/commands/tools-command/ci-post-test-results.d.ts +1 -0
  28. package/dist/commands/tools-command/ci-post-test-results.d.ts.map +1 -1
  29. package/dist/commands/tools-command/ci-post-test-results.js +12 -3
  30. package/dist/commands/tools-command/ci-post-test-results.js.map +1 -1
  31. package/dist/utils/batch/changed-packages-utils.d.ts +11 -0
  32. package/dist/utils/batch/changed-packages-utils.d.ts.map +1 -1
  33. package/dist/utils/batch/changed-packages-utils.js +14 -0
  34. package/dist/utils/batch/changed-packages-utils.js.map +1 -1
  35. package/dist/utils/build/build.d.ts +7 -0
  36. package/dist/utils/build/build.d.ts.map +1 -1
  37. package/dist/utils/build/build.js.map +1 -1
  38. package/dist/utils/bundle/untrusted-tar.d.ts +28 -0
  39. package/dist/utils/bundle/untrusted-tar.d.ts.map +1 -0
  40. package/dist/utils/bundle/untrusted-tar.js +116 -0
  41. package/dist/utils/bundle/untrusted-tar.js.map +1 -0
  42. package/dist/utils/deploy/deploy-github-columns.d.ts +8 -1
  43. package/dist/utils/deploy/deploy-github-columns.d.ts.map +1 -1
  44. package/dist/utils/deploy/deploy-github-columns.js.map +1 -1
  45. package/dist/utils/deploy/deploy-metadata.d.ts +5 -0
  46. package/dist/utils/deploy/deploy-metadata.d.ts.map +1 -1
  47. package/dist/utils/deploy/deploy-metadata.js +36 -0
  48. package/dist/utils/deploy/deploy-metadata.js.map +1 -1
  49. package/dist/utils/deploy/deploy-metadata.test.js +35 -2
  50. package/dist/utils/deploy/deploy-metadata.test.js.map +1 -1
  51. package/dist/utils/deploy/prebuilt-deploy-bundle.d.ts +38 -0
  52. package/dist/utils/deploy/prebuilt-deploy-bundle.d.ts.map +1 -0
  53. package/dist/utils/deploy/prebuilt-deploy-bundle.js +209 -0
  54. package/dist/utils/deploy/prebuilt-deploy-bundle.js.map +1 -0
  55. package/dist/utils/deploy/prebuilt-deploy-bundle.test.d.ts +2 -0
  56. package/dist/utils/deploy/prebuilt-deploy-bundle.test.d.ts.map +1 -0
  57. package/dist/utils/deploy/prebuilt-deploy-bundle.test.js +194 -0
  58. package/dist/utils/deploy/prebuilt-deploy-bundle.test.js.map +1 -0
  59. package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
  60. package/dist/utils/job-context/base-job-context.js +3 -2
  61. package/dist/utils/job-context/base-job-context.js.map +1 -1
  62. package/dist/utils/job-context/batch-script-job-context.d.ts +12 -0
  63. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  64. package/dist/utils/job-context/batch-script-job-context.js +19 -0
  65. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  66. package/dist/utils/open-cloud/open-cloud-client.d.ts +1 -0
  67. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  68. package/dist/utils/open-cloud/open-cloud-client.js +5 -0
  69. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  70. package/dist/utils/open-cloud/open-cloud-client.test.js +11 -0
  71. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  72. package/dist/utils/sourcemap/sourcemap-loader.d.ts +3 -3
  73. package/dist/utils/sourcemap/sourcemap-loader.d.ts.map +1 -1
  74. package/dist/utils/sourcemap/sourcemap-loader.js +6 -7
  75. package/dist/utils/sourcemap/sourcemap-loader.js.map +1 -1
  76. package/dist/utils/testing/batch-run.e2e.test.js +36 -0
  77. package/dist/utils/testing/batch-run.e2e.test.js.map +1 -1
  78. package/dist/utils/testing/runner/prebuilt-batch-bundle.d.ts +26 -0
  79. package/dist/utils/testing/runner/prebuilt-batch-bundle.d.ts.map +1 -0
  80. package/dist/utils/testing/runner/prebuilt-batch-bundle.js +145 -0
  81. package/dist/utils/testing/runner/prebuilt-batch-bundle.js.map +1 -0
  82. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.d.ts +2 -0
  83. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.d.ts.map +1 -0
  84. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.js +197 -0
  85. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.js.map +1 -0
  86. package/dist/utils/testing/runner/test-runner.d.ts +8 -1
  87. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  88. package/dist/utils/testing/runner/test-runner.js +19 -6
  89. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  90. package/package.json +7 -6
  91. package/src/commands/batch-command/batch-bundle-command.ts +236 -0
  92. package/src/commands/batch-command/batch-deploy-command.ts +160 -16
  93. package/src/commands/batch-command/batch-test-command.ts +162 -18
  94. package/src/commands/batch-command/index.ts +2 -0
  95. package/src/commands/deploy-command/index.ts +7 -0
  96. package/src/commands/test-command/test-command.ts +20 -3
  97. package/src/commands/tools-command/ci-post-deploy-results.ts +9 -2
  98. package/src/commands/tools-command/ci-post-test-results.ts +20 -3
  99. package/src/utils/batch/changed-packages-utils.ts +23 -0
  100. package/src/utils/build/build.ts +7 -0
  101. package/src/utils/bundle/untrusted-tar.ts +160 -0
  102. package/src/utils/deploy/deploy-github-columns.ts +8 -1
  103. package/src/utils/deploy/deploy-metadata.test.ts +50 -1
  104. package/src/utils/deploy/deploy-metadata.ts +39 -0
  105. package/src/utils/deploy/prebuilt-deploy-bundle.test.ts +265 -0
  106. package/src/utils/deploy/prebuilt-deploy-bundle.ts +297 -0
  107. package/src/utils/job-context/base-job-context.ts +4 -2
  108. package/src/utils/job-context/batch-script-job-context.ts +34 -3
  109. package/src/utils/open-cloud/open-cloud-client.test.ts +15 -0
  110. package/src/utils/open-cloud/open-cloud-client.ts +11 -0
  111. package/src/utils/sourcemap/sourcemap-loader.ts +7 -9
  112. package/src/utils/testing/batch-run.e2e.test.ts +42 -0
  113. package/src/utils/testing/runner/prebuilt-batch-bundle.test.ts +272 -0
  114. package/src/utils/testing/runner/prebuilt-batch-bundle.ts +204 -0
  115. package/src/utils/testing/runner/test-runner.ts +43 -13
  116. package/tsconfig.tsbuildinfo +1 -1
@@ -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 --test`, 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
@@ -733,6 +733,21 @@ describe('OpenCloudClient asset owner lookups', () => {
733
733
  });
734
734
  });
735
735
 
736
+ describe('OpenCloudClient.createExecutionTaskAsync', () => {
737
+ it('refuses a script over the 4 MB limit without sending it', async () => {
738
+ const fakeLimiter = { fetchAsync: vi.fn() };
739
+ const client = new OpenCloudClient({
740
+ apiKey: 'key',
741
+ rateLimiter: fakeLimiter as unknown as RateLimiter,
742
+ });
743
+
744
+ await expect(
745
+ client.createExecutionTaskAsync(1, 2, 3, 'a'.repeat(4_000_001))
746
+ ).rejects.toThrow('over the Open Cloud limit of 4 MB');
747
+ expect(fakeLimiter.fetchAsync).not.toHaveBeenCalled();
748
+ });
749
+ });
750
+
736
751
  describe('getTaskReturnValues', () => {
737
752
  function makeTask(overrides: Partial<LuauTask>): LuauTask {
738
753
  return {
@@ -28,6 +28,8 @@ import { recompressPlace } from '../build/place-recompression.js';
28
28
  */
29
29
  export const PLACE_UPLOAD_MAX_BYTES = 150 * 1024 * 1024;
30
30
 
31
+ export const LUAU_EXECUTION_SCRIPT_MAX_BYTES = 4_000_000;
32
+
31
33
  /**
32
34
  * One engine message, with the severity Open Cloud reported for it.
33
35
  *
@@ -397,6 +399,15 @@ export class OpenCloudClient {
397
399
  timeoutMs?: number,
398
400
  onRateLimitWait?: (wait: RateLimitWait) => void
399
401
  ): Promise<LuauTask> {
402
+ const scriptBytes = Buffer.byteLength(script, 'utf-8');
403
+ if (scriptBytes > LUAU_EXECUTION_SCRIPT_MAX_BYTES) {
404
+ throw new Error(
405
+ `The script is ${_formatMegabytes(
406
+ scriptBytes
407
+ )}, over the Open Cloud limit of 4 MB for a Luau execution script`
408
+ );
409
+ }
410
+
400
411
  const apiKey = await this._resolveApiKeyAsync();
401
412
  const url = `https://apis.roblox.com/cloud/v2/universes/${universeId}/places/${placeId}/versions/${placeVersion}/luau-execution-session-tasks`;
402
413
 
@@ -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
  }
@@ -142,6 +142,7 @@ class FakeTransport implements JobContext {
142
142
  public scriptContent?: string;
143
143
  public scriptContents: string[] = [];
144
144
  public deployedPaths: string[] = [];
145
+ public deployedTargets: unknown[] = [];
145
146
  public deployCount = 0;
146
147
  public runCount = 0;
147
148
  public messagesFor?: (script: string) => TaskLogMessage[];
@@ -160,6 +161,7 @@ class FakeTransport implements JobContext {
160
161
  ): Promise<Deployment> {
161
162
  this.deployCount++;
162
163
  this.deployedPaths.push(options.builtPlace.rbxlPath);
164
+ this.deployedTargets.push(options.builtPlace.target);
163
165
  return {};
164
166
  }
165
167
 
@@ -506,4 +508,44 @@ describe('an aggregated batch run, end to end', () => {
506
508
  ])
507
509
  );
508
510
  });
511
+
512
+ it('runs prebuilt places on the target it was given, without building', async () => {
513
+ const generate = vi.mocked(
514
+ (await import('./runner/combined-project-generator.js'))
515
+ .generateCombinedProjectAsync
516
+ );
517
+ generate.mockClear();
518
+ const { targets } = createBatch();
519
+ const transport = new FakeTransport(TASK_MESSAGES);
520
+ const cleanupAsync = vi.fn(async () => {});
521
+ const batch = new BatchScriptJobContext(transport, targets, {
522
+ repoRoot: '/repo',
523
+ batchPlaceId: 900,
524
+ batchUniverseId: 901,
525
+ prebuilt: {
526
+ chunks: [
527
+ {
528
+ rbxlPath: '/bundle/place-1.rbxl',
529
+ slugMap: SLUG_MAP,
530
+ sizeBytes: 1024,
531
+ },
532
+ ],
533
+ buildContext: { cleanupAsync } as any,
534
+ },
535
+ });
536
+
537
+ const alpha = await runPackageAsync(batch, '@quenty/alpha');
538
+ const beta = await runPackageAsync(batch, '@quenty/beta');
539
+
540
+ expect(generate).not.toHaveBeenCalled();
541
+ expect(transport.deployedPaths).toEqual(['/bundle/place-1.rbxl']);
542
+ expect(transport.deployedTargets).toEqual([
543
+ { placeId: 900, universeId: 901, project: '' },
544
+ ]);
545
+ expect(alpha.success).toBe(true);
546
+ expect(beta.success).toBe(false);
547
+
548
+ await batch.disposeAsync();
549
+ expect(cleanupAsync).toHaveBeenCalledOnce();
550
+ });
509
551
  });
@@ -0,0 +1,272 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { Header } from 'tar';
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import {
7
+ type PrebuiltBatchBundle,
8
+ readPrebuiltBatchBundleAsync,
9
+ writePrebuiltBatchBundleAsync,
10
+ } from './prebuilt-batch-bundle.js';
11
+
12
+ interface RawEntry {
13
+ path: string;
14
+ body?: string;
15
+ type?: 'File' | 'SymbolicLink' | 'Directory';
16
+ linkpath?: string;
17
+ }
18
+
19
+ function rawTar(entries: RawEntry[]): Buffer {
20
+ const blocks: Buffer[] = [];
21
+ for (const entry of entries) {
22
+ const body = Buffer.from(entry.body ?? '');
23
+ const header = new Header({
24
+ path: entry.path,
25
+ type: entry.type ?? 'File',
26
+ linkpath: entry.linkpath,
27
+ size: entry.type && entry.type !== 'File' ? 0 : body.length,
28
+ mode: 0o644,
29
+ mtime: new Date(0),
30
+ });
31
+ header.encode();
32
+ blocks.push(header.block!);
33
+ if (!entry.type || entry.type === 'File') {
34
+ const padded = Buffer.alloc(Math.ceil(body.length / 512) * 512);
35
+ body.copy(padded);
36
+ blocks.push(padded);
37
+ }
38
+ }
39
+ blocks.push(Buffer.alloc(1024));
40
+ return Buffer.concat(blocks);
41
+ }
42
+
43
+ function validManifest(): any {
44
+ return {
45
+ formatVersion: 1,
46
+ chunks: [
47
+ {
48
+ file: 'place-1.rbxl',
49
+ packages: [
50
+ { name: '@quenty/rx', slug: 'rx', hasJestConfig: true },
51
+ { name: '@quenty/maid', slug: 'maid', hasJestConfig: false },
52
+ ],
53
+ },
54
+ ],
55
+ };
56
+ }
57
+
58
+ describe('prebuilt batch bundle', () => {
59
+ let workDir: string;
60
+ const opened: PrebuiltBatchBundle[] = [];
61
+
62
+ beforeEach(async () => {
63
+ workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prebuilt-bundle-'));
64
+ });
65
+
66
+ afterEach(async () => {
67
+ for (const bundle of opened.splice(0)) {
68
+ await bundle.buildContext.cleanupAsync();
69
+ }
70
+ await fs.rm(workDir, { recursive: true, force: true });
71
+ });
72
+
73
+ async function readAsync(file: string) {
74
+ const bundle = await readPrebuiltBatchBundleAsync(file);
75
+ opened.push(bundle);
76
+ return bundle;
77
+ }
78
+
79
+ async function writeRawAsync(entries: RawEntry[]): Promise<string> {
80
+ const file = path.join(workDir, 'raw.nevermore.tests.tar');
81
+ await fs.writeFile(file, rawTar(entries));
82
+ return file;
83
+ }
84
+
85
+ async function writeManifestAsync(manifest: any): Promise<string> {
86
+ return writeRawAsync([
87
+ { path: 'manifest.json', body: JSON.stringify(manifest) },
88
+ { path: 'place-1.rbxl', body: 'place bytes' },
89
+ ]);
90
+ }
91
+
92
+ it('round-trips places, slugs and jest.config flags through one file', async () => {
93
+ const source = path.join(workDir, 'source.rbxl');
94
+ await fs.writeFile(source, 'place bytes');
95
+ const file = path.join(workDir, 'out', 'pr-1.nevermore.tests.tar');
96
+ await writePrebuiltBatchBundleAsync(
97
+ file,
98
+ [
99
+ {
100
+ rbxlPath: source,
101
+ slugMap: new Map([
102
+ ['@quenty/rx', 'rx'],
103
+ ['@quenty/maid', 'maid'],
104
+ ]),
105
+ sizeBytes: 11,
106
+ },
107
+ ],
108
+ new Map([['@quenty/rx', true]])
109
+ );
110
+
111
+ const bundle = await readAsync(file);
112
+
113
+ expect(bundle.packages).toEqual([
114
+ { name: '@quenty/rx', hasJestConfig: true },
115
+ { name: '@quenty/maid', hasJestConfig: false },
116
+ ]);
117
+ expect(bundle.chunks).toHaveLength(1);
118
+ expect(bundle.chunks[0]!.sizeBytes).toBe(11);
119
+ expect([...bundle.chunks[0]!.slugMap]).toEqual([
120
+ ['@quenty/rx', 'rx'],
121
+ ['@quenty/maid', 'maid'],
122
+ ]);
123
+ expect(await fs.readFile(bundle.chunks[0]!.rbxlPath, 'utf-8')).toBe(
124
+ 'place bytes'
125
+ );
126
+ });
127
+
128
+ it('reads an empty bundle as no packages', async () => {
129
+ const file = path.join(workDir, 'empty.nevermore.tests.tar');
130
+ await writePrebuiltBatchBundleAsync(file, [], new Map());
131
+
132
+ const bundle = await readAsync(file);
133
+
134
+ expect(bundle.packages).toEqual([]);
135
+ expect(bundle.chunks).toEqual([]);
136
+ });
137
+
138
+ it('removes the unpacked places on cleanup', async () => {
139
+ const bundle = await readPrebuiltBatchBundleAsync(
140
+ await writeManifestAsync(validManifest())
141
+ );
142
+ const placePath = bundle.chunks[0]!.rbxlPath;
143
+
144
+ await bundle.buildContext.cleanupAsync();
145
+
146
+ await expect(fs.stat(placePath)).rejects.toThrow();
147
+ });
148
+
149
+ it('never writes outside its own directory for an entry that escapes', async () => {
150
+ const file = await writeRawAsync([
151
+ { path: 'manifest.json', body: JSON.stringify(validManifest()) },
152
+ { path: 'place-1.rbxl', body: 'place bytes' },
153
+ { path: '../escaped.rbxl', body: 'evil' },
154
+ ]);
155
+
156
+ await expect(readAsync(file)).rejects.toThrow('unexpected entry');
157
+ await expect(
158
+ fs.stat(path.join(os.tmpdir(), 'escaped.rbxl'))
159
+ ).rejects.toThrow();
160
+ });
161
+
162
+ it('rejects a symlink entry', async () => {
163
+ const file = await writeRawAsync([
164
+ { path: 'manifest.json', body: JSON.stringify(validManifest()) },
165
+ { path: 'place-1.rbxl', type: 'SymbolicLink', linkpath: '/etc/passwd' },
166
+ ]);
167
+
168
+ await expect(readAsync(file)).rejects.toThrow('not a regular file');
169
+ });
170
+
171
+ it('rejects a place the manifest does not list', async () => {
172
+ const file = await writeRawAsync([
173
+ { path: 'manifest.json', body: JSON.stringify(validManifest()) },
174
+ { path: 'place-1.rbxl', body: 'place bytes' },
175
+ { path: 'place-2.rbxl', body: 'extra' },
176
+ ]);
177
+
178
+ await expect(readAsync(file)).rejects.toThrow('every place it holds');
179
+ });
180
+
181
+ it('rejects a manifest that lists a place the bundle does not hold', async () => {
182
+ const manifest = validManifest();
183
+ manifest.chunks[0].file = 'place-2.rbxl';
184
+
185
+ await expect(readAsync(await writeManifestAsync(manifest))).rejects.toThrow(
186
+ 'invalid place file'
187
+ );
188
+ });
189
+
190
+ it('rejects a file that is not a tar', async () => {
191
+ const file = path.join(workDir, 'junk.nevermore.tests.tar');
192
+ await fs.writeFile(file, 'not a tar at all');
193
+
194
+ await expect(readAsync(file)).rejects.toThrow();
195
+ });
196
+
197
+ it('rejects a slug that could break out of the batch script', async () => {
198
+ const manifest = validManifest();
199
+ manifest.chunks[0].packages[0].slug = 'rx"]] print("hi") --';
200
+
201
+ await expect(readAsync(await writeManifestAsync(manifest))).rejects.toThrow(
202
+ 'invalid slug'
203
+ );
204
+ });
205
+
206
+ it('accepts a multi-place target name with a free-form place name', async () => {
207
+ const manifest = validManifest();
208
+ manifest.chunks[0].packages[0].name = '@quenty/rx - Hub (Test) + Lobby é';
209
+
210
+ const bundle = await readAsync(await writeManifestAsync(manifest));
211
+
212
+ expect(bundle.packages[0]!.name).toBe('@quenty/rx - Hub (Test) + Lobby é');
213
+ });
214
+
215
+ it.each([
216
+ '@quenty/rx | injected',
217
+ '@quenty/rx<img>',
218
+ '@quenty/rx\nline',
219
+ '@quenty/rx [Approved](https://evil.example)',
220
+ '@quenty/rx ![x](p.png)',
221
+ '@quenty/rx cc @someuser',
222
+ '@quenty/rx https://evil.example',
223
+ ])(
224
+ 'rejects a package name that could break the PR comment: %j',
225
+ async (name) => {
226
+ const manifest = validManifest();
227
+ manifest.chunks[0].packages[0].name = name;
228
+
229
+ await expect(
230
+ readAsync(await writeManifestAsync(manifest))
231
+ ).rejects.toThrow('invalid package name');
232
+ }
233
+ );
234
+
235
+ it('rejects a package listed twice', async () => {
236
+ const manifest = validManifest();
237
+ manifest.chunks[0].packages[1].name = '@quenty/rx';
238
+
239
+ await expect(readAsync(await writeManifestAsync(manifest))).rejects.toThrow(
240
+ 'more than once'
241
+ );
242
+ });
243
+
244
+ it('rejects an unknown format version', async () => {
245
+ const manifest = validManifest();
246
+ manifest.formatVersion = 2;
247
+
248
+ await expect(readAsync(await writeManifestAsync(manifest))).rejects.toThrow(
249
+ 'Unsupported bundle format'
250
+ );
251
+ });
252
+
253
+ it('names the right command for a deploy bundle, before its unexpected entries', async () => {
254
+ const file = await writeRawAsync([
255
+ {
256
+ path: 'manifest.json',
257
+ body: JSON.stringify({
258
+ formatVersion: 1,
259
+ kind: 'deploy',
260
+ target: 'integration',
261
+ places: [],
262
+ }),
263
+ },
264
+ { path: 'place-1.rbxl', body: 'place bytes' },
265
+ { path: 'project-1.json', body: '{}' },
266
+ ]);
267
+
268
+ await expect(readAsync(file)).rejects.toThrow(
269
+ 'This is a deploy bundle, not a tests bundle. Run it with "nevermore batch deploy --bundle <file>".'
270
+ );
271
+ });
272
+ });