@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
@@ -1,82 +1,143 @@
1
1
  /**
2
- * Converts Roblox instance paths from Jest-lua output back to
3
- * repo-relative local filesystem paths.
2
+ * Maps Roblox instance paths from Jest-lua output to existing repo-relative
3
+ * source files, through `sourcemap.json` or else the `<package dir>/src/`
4
+ * layout.
4
5
  *
5
- * Two strategies:
6
- *
7
- * 1. **Sourcemap** (preferred) — Uses the authoritative `sourcemap.json`
8
- * mapping from `rojo sourcemap --absolute`. Exact and handles all
9
- * project layouts.
10
- *
11
- * 2. **Heuristic** (fallback) — Assumes `src/{slug}/src/` layout and
12
- * hard-codes `.spec`/`.story` as known dotted suffixes. Works when no
13
- * sourcemap is available.
6
+ * A package's npm name (`@quenty/playermock`), directory (`src/player-mock`),
7
+ * Rojo project name (`playermock`) and test-place slug (`gameproduct` for
8
+ * `src/gameproductservice`) can all differ; see `AnnotationWorkspace`.
14
9
  */
15
10
 
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
16
13
  import type { SourcemapResolver } from '../../sourcemap/index.js';
14
+ import type {
15
+ AnnotationWorkspace,
16
+ AnnotationWorkspacePackage,
17
+ } from './annotation-workspace.js';
17
18
 
18
19
  const SERVER_SCRIPT_SERVICE_PREFIX = 'ServerScriptService.';
20
+ const NODE_MODULES_SEGMENT = '.node_modules.';
21
+ const QUENTY_SCOPE = '@quenty/';
22
+ const QUENTY_SCOPE_SEGMENT = '@quenty.';
23
+ const CHUNK_NAME_PATTERN = /^\[string "(.*)"\]$/;
24
+ // Rojo strips `.server`/`.client` from script instance names.
25
+ const SOURCE_EXTENSIONS = ['', '.server', '.client'].flatMap((kind) => [
26
+ `${kind}.lua`,
27
+ `${kind}.luau`,
28
+ `/init${kind}.lua`,
29
+ `/init${kind}.luau`,
30
+ ]);
31
+
32
+ export interface RobloxSourceResolveOptions {
33
+ sourcemapResolver?: SourcemapResolver;
34
+ workspace?: AnnotationWorkspace;
35
+ /** npm name of the package under test, e.g. `@quenty/gameproductservice` */
36
+ packageName?: string;
37
+ /** Repo root that holds the workspace. Defaults to cwd. */
38
+ repoRoot?: string;
39
+ }
40
+
41
+ export interface ResolvedSourceFile {
42
+ file: string;
43
+ /** False for dependency copies and sibling packages. */
44
+ isPackageUnderTest: boolean;
45
+ }
19
46
 
20
47
  /**
21
- * Resolve a Roblox instance path (from Jest-lua or a stack trace) to a
22
- * repo-relative filesystem path.
23
- *
24
- * When a `sourcemapResolver` is provided, it is tried first. Falls back to
25
- * the heuristic if the resolver doesn't have a mapping.
48
+ * Resolve an instance path (no `:LINE` suffix) to an existing repo-relative
49
+ * source file, or `undefined` when none matches.
26
50
  */
27
- export function resolveRobloxTestPath(
51
+ export function tryResolveRobloxSourceFile(
28
52
  instancePath: string,
29
- sourcemapResolver?: SourcemapResolver
30
- ): string {
31
- if (sourcemapResolver) {
32
- const resolved = sourcemapResolver.resolve(instancePath);
33
- if (resolved) return resolved;
53
+ options: RobloxSourceResolveOptions = {}
54
+ ): ResolvedSourceFile | undefined {
55
+ let unwrapped = _unwrapChunkName(instancePath);
56
+
57
+ // Roblox truncates long chunk names: `...@quenty.servic..."]`
58
+ if (unwrapped.endsWith('...')) return undefined;
59
+
60
+ if (unwrapped.startsWith(SERVER_SCRIPT_SERVICE_PREFIX)) {
61
+ unwrapped = unwrapped.slice(SERVER_SCRIPT_SERVICE_PREFIX.length);
34
62
  }
35
63
 
36
- return _resolveHeuristic(instancePath);
37
- }
64
+ const packagesByName = options.workspace?.packagesByName;
38
65
 
39
- /**
40
- * Heuristic fallback: assumes `src/{slug}/src/` layout.
41
- *
42
- * Handles optional `:LINE` suffixes and missing `ServerScriptService.` prefix.
43
- */
44
- function _resolveHeuristic(instancePath: string): string {
45
- // Strip :LINE suffix if present
46
- let path = instancePath.replace(/:\d+$/, '');
66
+ let workspacePackage: AnnotationWorkspacePackage | undefined;
67
+ let packageInstanceName: string;
68
+ let rest: string;
69
+ let isPackageUnderTest = false;
47
70
 
48
- // Strip ServerScriptService. prefix
49
- if (path.startsWith(SERVER_SCRIPT_SERVICE_PREFIX)) {
50
- path = path.slice(SERVER_SCRIPT_SERVICE_PREFIX.length);
71
+ const nodeModulesIndex = unwrapped.lastIndexOf(NODE_MODULES_SEGMENT);
72
+ if (nodeModulesIndex !== -1) {
73
+ const dependencyPath = unwrapped.slice(
74
+ nodeModulesIndex + NODE_MODULES_SEGMENT.length
75
+ );
76
+ if (!dependencyPath.startsWith(QUENTY_SCOPE_SEGMENT)) return undefined;
77
+ [packageInstanceName, rest] = _splitFirstSegment(
78
+ dependencyPath.slice(QUENTY_SCOPE_SEGMENT.length)
79
+ );
80
+ workspacePackage = packagesByName?.get(QUENTY_SCOPE + packageInstanceName);
81
+ } else {
82
+ [packageInstanceName, rest] = _splitFirstSegment(unwrapped);
83
+ const packageUnderTest = options.packageName
84
+ ? packagesByName?.get(options.packageName)
85
+ : undefined;
86
+ if (packageUnderTest?.testSlugs.includes(packageInstanceName)) {
87
+ workspacePackage = packageUnderTest;
88
+ isPackageUnderTest = true;
89
+ }
51
90
  }
52
91
 
53
- // First dot-separated segment is the package slug
54
- const dotIndex = path.indexOf('.');
55
- if (dotIndex === -1) {
56
- // Just a package name with no sub-path — return the src dir
57
- return `src/${path}/src`;
92
+ const dir = workspacePackage?.dir ?? `src/${packageInstanceName}`;
93
+ // The sourcemap names packages by Rojo project name, not folder.
94
+ const sourcemapNames = new Set([
95
+ workspacePackage?.projectName ?? packageInstanceName,
96
+ path.posix.basename(dir),
97
+ ]);
98
+ for (const name of sourcemapNames) {
99
+ const mapped = options.sourcemapResolver?.resolve(
100
+ SERVER_SCRIPT_SERVICE_PREFIX + name + (rest ? `.${rest}` : '')
101
+ );
102
+ if (mapped) return { file: mapped, isPackageUnderTest };
58
103
  }
59
104
 
60
- const packageSlug = path.slice(0, dotIndex);
61
- const remaining = path.slice(dotIndex + 1);
105
+ const repoRoot = options.repoRoot ?? process.cwd();
106
+ const base = _resolveHeuristic(dir, rest);
107
+ for (const extension of SOURCE_EXTENSIONS) {
108
+ const file = `${base}${extension}`;
109
+ if (fs.existsSync(path.join(repoRoot, file))) {
110
+ return { file, isPackageUnderTest };
111
+ }
112
+ }
62
113
 
63
- // Split into segments. In Rojo, dots in filenames (e.g. "Maid.spec.lua"
64
- // becomes instance "Maid.spec") are ambiguous with the hierarchy separator.
65
- // Rejoin known filename suffixes like ".spec" and ".story" with a dot
66
- // instead of a slash.
67
- const segments = remaining.split('.');
68
- const rejoined: string[] = [];
114
+ return undefined;
115
+ }
69
116
 
70
- for (let i = 0; i < segments.length; i++) {
71
- const seg = segments[i];
72
- // If this segment is a known file suffix, merge it with the previous
73
- if (i > 0 && (seg === 'spec' || seg === 'story')) {
74
- rejoined[rejoined.length - 1] += `.${seg}`;
117
+ function _unwrapChunkName(name: string): string {
118
+ const match = name.trim().match(CHUNK_NAME_PATTERN);
119
+ return match ? match[1] : name.trim();
120
+ }
121
+
122
+ function _splitFirstSegment(dottedPath: string): [string, string] {
123
+ const dotIndex = dottedPath.indexOf('.');
124
+ return dotIndex === -1
125
+ ? [dottedPath, '']
126
+ : [dottedPath.slice(0, dotIndex), dottedPath.slice(dotIndex + 1)];
127
+ }
128
+
129
+ function _resolveHeuristic(dir: string, rest: string): string {
130
+ if (!rest) return `${dir}/src`;
131
+
132
+ // Rojo turns `Maid.spec.lua` into instance `Maid.spec`, so rejoin suffixes.
133
+ const rejoined: string[] = [];
134
+ for (const segment of rest.split('.')) {
135
+ if (rejoined.length > 0 && (segment === 'spec' || segment === 'story')) {
136
+ rejoined[rejoined.length - 1] += `.${segment}`;
75
137
  } else {
76
- rejoined.push(seg);
138
+ rejoined.push(segment);
77
139
  }
78
140
  }
79
141
 
80
- const subPath = rejoined.join('/');
81
- return `src/${packageSlug}/src/${subPath}.lua`;
142
+ return `${dir}/src/${rejoined.join('/')}`;
82
143
  }
@@ -8,6 +8,10 @@ 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
10
  import { recompressPlace } from '../../build/place-recompression.js';
11
+ import {
12
+ type TestRojoProject,
13
+ findTestProjectSlug,
14
+ } from './test-project-slug.js';
11
15
 
12
16
  /**
13
17
  * Budget for one combined place, as uploaded (recompressed). Under the upload
@@ -40,10 +44,6 @@ export interface CombinedProjectResult {
40
44
  buildContext: BuildContext;
41
45
  }
42
46
 
43
- interface RojoNode {
44
- [key: string]: unknown;
45
- }
46
-
47
47
  import { type StepProgress } from '@quenty/cli-output-helpers/reporting';
48
48
 
49
49
  /** Progress callbacks for granular phase reporting during combined builds. */
@@ -345,20 +345,16 @@ async function _extractSlugAsync(
345
345
  );
346
346
  }
347
347
 
348
- const project = JSON.parse(content) as {
349
- tree: { ServerScriptService?: RojoNode };
350
- };
351
- const serverScriptService = project.tree?.ServerScriptService;
352
- if (!serverScriptService) {
348
+ const project = JSON.parse(content) as TestRojoProject;
349
+ if (!project.tree?.ServerScriptService) {
353
350
  throw new Error(
354
351
  `Test project for ${batchTargetName} is missing ServerScriptService in tree`
355
352
  );
356
353
  }
357
354
 
358
- for (const key of Object.keys(serverScriptService)) {
359
- if (!key.startsWith('$') && key !== 'Script') {
360
- return key;
361
- }
355
+ const slug = findTestProjectSlug(project);
356
+ if (slug) {
357
+ return slug;
362
358
  }
363
359
 
364
360
  throw new Error(
@@ -0,0 +1,252 @@
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
+ });