@quenty/nevermore-cli 4.50.0 → 4.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/commands/batch-command/batch-bundle-command.d.ts +12 -0
  3. package/dist/commands/batch-command/batch-bundle-command.d.ts.map +1 -0
  4. package/dist/commands/batch-command/batch-bundle-command.js +96 -0
  5. package/dist/commands/batch-command/batch-bundle-command.js.map +1 -0
  6. package/dist/commands/batch-command/batch-test-command.d.ts +1 -0
  7. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  8. package/dist/commands/batch-command/batch-test-command.js +103 -15
  9. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  10. package/dist/commands/batch-command/index.d.ts.map +1 -1
  11. package/dist/commands/batch-command/index.js +2 -0
  12. package/dist/commands/batch-command/index.js.map +1 -1
  13. package/dist/commands/tools-command/ci-post-test-results.d.ts +1 -0
  14. package/dist/commands/tools-command/ci-post-test-results.d.ts.map +1 -1
  15. package/dist/commands/tools-command/ci-post-test-results.js +13 -4
  16. package/dist/commands/tools-command/ci-post-test-results.js.map +1 -1
  17. package/dist/utils/batch/changed-packages-utils.d.ts +8 -0
  18. package/dist/utils/batch/changed-packages-utils.d.ts.map +1 -1
  19. package/dist/utils/batch/changed-packages-utils.js +9 -11
  20. package/dist/utils/batch/changed-packages-utils.js.map +1 -1
  21. package/dist/utils/job-context/batch-script-job-context.d.ts +12 -0
  22. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  23. package/dist/utils/job-context/batch-script-job-context.js +19 -0
  24. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  25. package/dist/utils/sourcemap/sourcemap-loader.d.ts +3 -3
  26. package/dist/utils/sourcemap/sourcemap-loader.d.ts.map +1 -1
  27. package/dist/utils/sourcemap/sourcemap-loader.js +6 -7
  28. package/dist/utils/sourcemap/sourcemap-loader.js.map +1 -1
  29. package/dist/utils/testing/batch-run.e2e.test.js +36 -0
  30. package/dist/utils/testing/batch-run.e2e.test.js.map +1 -1
  31. package/dist/utils/testing/parsers/annotation-workspace.d.ts +17 -0
  32. package/dist/utils/testing/parsers/annotation-workspace.d.ts.map +1 -0
  33. package/dist/utils/testing/parsers/annotation-workspace.js +47 -0
  34. package/dist/utils/testing/parsers/annotation-workspace.js.map +1 -0
  35. package/dist/utils/testing/parsers/index.d.ts +0 -1
  36. package/dist/utils/testing/parsers/index.d.ts.map +1 -1
  37. package/dist/utils/testing/parsers/index.js +0 -1
  38. package/dist/utils/testing/parsers/index.js.map +1 -1
  39. package/dist/utils/testing/parsers/jest-lua-parser.d.ts +14 -29
  40. package/dist/utils/testing/parsers/jest-lua-parser.d.ts.map +1 -1
  41. package/dist/utils/testing/parsers/jest-lua-parser.js +85 -100
  42. package/dist/utils/testing/parsers/jest-lua-parser.js.map +1 -1
  43. package/dist/utils/testing/parsers/jest-lua-parser.test.js +359 -56
  44. package/dist/utils/testing/parsers/jest-lua-parser.test.js.map +1 -1
  45. package/dist/utils/testing/parsers/roblox-path-resolver.d.ts +23 -17
  46. package/dist/utils/testing/parsers/roblox-path-resolver.d.ts.map +1 -1
  47. package/dist/utils/testing/parsers/roblox-path-resolver.js +89 -54
  48. package/dist/utils/testing/parsers/roblox-path-resolver.js.map +1 -1
  49. package/dist/utils/testing/parsers/roblox-path-resolver.test.js +76 -36
  50. package/dist/utils/testing/parsers/roblox-path-resolver.test.js.map +1 -1
  51. package/dist/utils/testing/runner/combined-project-generator.d.ts.map +1 -1
  52. package/dist/utils/testing/runner/combined-project-generator.js +5 -6
  53. package/dist/utils/testing/runner/combined-project-generator.js.map +1 -1
  54. package/dist/utils/testing/runner/prebuilt-batch-bundle.d.ts +26 -0
  55. package/dist/utils/testing/runner/prebuilt-batch-bundle.d.ts.map +1 -0
  56. package/dist/utils/testing/runner/prebuilt-batch-bundle.js +200 -0
  57. package/dist/utils/testing/runner/prebuilt-batch-bundle.js.map +1 -0
  58. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.d.ts +2 -0
  59. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.d.ts.map +1 -0
  60. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.js +181 -0
  61. package/dist/utils/testing/runner/prebuilt-batch-bundle.test.js.map +1 -0
  62. package/dist/utils/testing/runner/test-project-slug.d.ts +11 -0
  63. package/dist/utils/testing/runner/test-project-slug.d.ts.map +1 -0
  64. package/dist/utils/testing/runner/test-project-slug.js +11 -0
  65. package/dist/utils/testing/runner/test-project-slug.js.map +1 -0
  66. package/dist/utils/testing/runner/test-runner.d.ts +8 -1
  67. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  68. package/dist/utils/testing/runner/test-runner.js +19 -6
  69. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  70. package/package.json +7 -6
  71. package/src/commands/batch-command/batch-bundle-command.ts +140 -0
  72. package/src/commands/batch-command/batch-test-command.ts +156 -16
  73. package/src/commands/batch-command/index.ts +2 -0
  74. package/src/commands/tools-command/ci-post-test-results.ts +18 -3
  75. package/src/utils/batch/changed-packages-utils.ts +21 -12
  76. package/src/utils/job-context/batch-script-job-context.ts +34 -3
  77. package/src/utils/sourcemap/sourcemap-loader.ts +7 -9
  78. package/src/utils/testing/batch-run.e2e.test.ts +42 -0
  79. package/src/utils/testing/parsers/annotation-workspace.ts +74 -0
  80. package/src/utils/testing/parsers/index.ts +0 -1
  81. package/src/utils/testing/parsers/jest-lua-parser.test.ts +433 -65
  82. package/src/utils/testing/parsers/jest-lua-parser.ts +131 -109
  83. package/src/utils/testing/parsers/roblox-path-resolver.test.ts +144 -61
  84. package/src/utils/testing/parsers/roblox-path-resolver.ts +117 -56
  85. package/src/utils/testing/runner/combined-project-generator.ts +9 -13
  86. package/src/utils/testing/runner/prebuilt-batch-bundle.test.ts +252 -0
  87. package/src/utils/testing/runner/prebuilt-batch-bundle.ts +267 -0
  88. package/src/utils/testing/runner/test-project-slug.ts +18 -0
  89. package/src/utils/testing/runner/test-runner.ts +43 -13
  90. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,267 @@
1
+ import * as fs from 'fs/promises';
2
+ import { createReadStream, createWriteStream } from 'fs';
3
+ import * as path from 'path';
4
+ import * as tar from 'tar';
5
+ import { BuildContext } from '@quenty/nevermore-template-helpers';
6
+ import { type CombinedPlaceChunk } from './combined-project-generator.js';
7
+ import { PLACE_UPLOAD_MAX_BYTES } from '../../open-cloud/open-cloud-client.js';
8
+
9
+ /**
10
+ * Places built by `nevermore batch bundle`, unpacked from a
11
+ * `*.nevermore.tests.tar` and ready for `batch test --bundle` to run.
12
+ */
13
+ export interface PrebuiltBatchBundle {
14
+ chunks: CombinedPlaceChunk[];
15
+ packages: PrebuiltBatchPackage[];
16
+ /** Holds the unpacked places; whoever runs them owns its cleanup. */
17
+ buildContext: BuildContext;
18
+ }
19
+
20
+ export interface PrebuiltBatchPackage {
21
+ name: string;
22
+ hasJestConfig: boolean;
23
+ }
24
+
25
+ interface ManifestFile {
26
+ formatVersion: number;
27
+ chunks: Array<{
28
+ file: string;
29
+ packages: Array<{ name: string; slug: string; hasJestConfig: boolean }>;
30
+ }>;
31
+ }
32
+
33
+ /** Suggested file suffix, following `deploy.nevermore.json`. */
34
+ export const BUNDLE_FILE_SUFFIX = '.nevermore.tests.tar';
35
+
36
+ const MANIFEST_FILE_NAME = 'manifest.json';
37
+ const FORMAT_VERSION = 1;
38
+ const MAX_CHUNKS = 32;
39
+ const MAX_PACKAGES = 1000;
40
+ const MAX_MANIFEST_BYTES = 1024 * 1024;
41
+ const CHUNK_FILE_PATTERN = /^place-\d{1,3}\.rbxl$/;
42
+ // Names reach the bot's PR comment, so a fork's bundle gets no Markdown links,
43
+ // images, URLs, tables or @mentions: only a leading scope `@`, and plain text
44
+ // for a multi-place target's place name ("pkg - Hub (Test)").
45
+ const PACKAGE_NAME_PATTERN = /^@?[\p{L}\p{N} /()+.,'_~-]{1,200}$/u;
46
+ const SLUG_PATTERN = /^[A-Za-z0-9_.-]{1,100}$/;
47
+
48
+ export async function writePrebuiltBatchBundleAsync(
49
+ file: string,
50
+ chunks: CombinedPlaceChunk[],
51
+ hasJestConfig: Map<string, boolean>
52
+ ): Promise<void> {
53
+ const stage = await BuildContext.createAsync({ prefix: 'test-bundle-' });
54
+ const stageDir = stage.buildDir;
55
+ try {
56
+ const manifest: ManifestFile = {
57
+ formatVersion: FORMAT_VERSION,
58
+ chunks: [],
59
+ };
60
+ for (const [index, chunk] of chunks.entries()) {
61
+ const placeFile = `place-${index + 1}.rbxl`;
62
+ await fs.copyFile(chunk.rbxlPath, path.join(stageDir, placeFile));
63
+ manifest.chunks.push({
64
+ file: placeFile,
65
+ packages: [...chunk.slugMap].map(([name, slug]) => ({
66
+ name,
67
+ slug,
68
+ hasJestConfig: hasJestConfig.get(name) ?? false,
69
+ })),
70
+ });
71
+ }
72
+ await fs.writeFile(
73
+ path.join(stageDir, MANIFEST_FILE_NAME),
74
+ JSON.stringify(manifest, null, 2)
75
+ );
76
+
77
+ await fs.mkdir(path.dirname(path.resolve(file)), { recursive: true });
78
+ await tar.c({ file, cwd: stageDir, portable: true }, [
79
+ MANIFEST_FILE_NAME,
80
+ ...manifest.chunks.map((c) => c.file),
81
+ ]);
82
+ } finally {
83
+ await stage.cleanupAsync();
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Read a bundle file. It is untrusted: entries are read from the archive, never
89
+ * extracted by it, and only `manifest.json` and `place-N.rbxl` regular files
90
+ * are accepted.
91
+ */
92
+ export async function readPrebuiltBatchBundleAsync(
93
+ file: string
94
+ ): Promise<PrebuiltBatchBundle> {
95
+ let stat;
96
+ try {
97
+ stat = await fs.lstat(file);
98
+ } catch {
99
+ throw new Error(`Bundle file not found: ${file}`);
100
+ }
101
+ if (!stat.isFile()) {
102
+ throw new Error(`Bundle ${file} is not a regular file`);
103
+ }
104
+
105
+ const buildContext = await BuildContext.createAsync({
106
+ prefix: 'test-bundle-',
107
+ });
108
+ const dir = buildContext.buildDir;
109
+ try {
110
+ const { manifestText, placeSizes } = await _unpackAsync(file, dir);
111
+ return { ..._parseManifest(manifestText, dir, placeSizes), buildContext };
112
+ } catch (err) {
113
+ await buildContext.cleanupAsync();
114
+ throw err;
115
+ }
116
+ }
117
+
118
+ async function _unpackAsync(
119
+ file: string,
120
+ dir: string
121
+ ): Promise<{ manifestText: string; placeSizes: Map<string, number> }> {
122
+ let manifestText: string | undefined;
123
+ const placeSizes = new Map<string, number>();
124
+ const writes: Promise<void>[] = [];
125
+ let failure: Error | undefined;
126
+
127
+ const parser = new tar.Parser({
128
+ strict: true,
129
+ onReadEntry: (entry) => {
130
+ const name = entry.path;
131
+ const reject = (message: string) => {
132
+ failure ??= new Error(message);
133
+ entry.resume();
134
+ };
135
+
136
+ if (entry.type !== 'File' && entry.type !== 'OldFile') {
137
+ return reject(`Bundle entry ${name} is not a regular file`);
138
+ }
139
+
140
+ if (name === MANIFEST_FILE_NAME) {
141
+ if (manifestText !== undefined || entry.size > MAX_MANIFEST_BYTES) {
142
+ return reject('Bundle has more than one manifest, or one too large');
143
+ }
144
+ const parts: Buffer[] = [];
145
+ entry.on('data', (part: Buffer) => parts.push(part));
146
+ entry.on('end', () => {
147
+ manifestText = Buffer.concat(parts).toString('utf-8');
148
+ });
149
+ return;
150
+ }
151
+
152
+ if (!CHUNK_FILE_PATTERN.test(name) || placeSizes.has(name)) {
153
+ return reject(`Bundle has an unexpected entry: ${name}`);
154
+ }
155
+ if (placeSizes.size >= MAX_CHUNKS) {
156
+ return reject(`Bundle must hold at most ${MAX_CHUNKS} places`);
157
+ }
158
+ if (entry.size > PLACE_UPLOAD_MAX_BYTES) {
159
+ return reject(`${name} is over the place upload limit`);
160
+ }
161
+ placeSizes.set(name, entry.size);
162
+ writes.push(
163
+ new Promise<void>((resolve, rejectWrite) => {
164
+ const out = createWriteStream(path.join(dir, name));
165
+ out.on('finish', () => resolve());
166
+ out.on('error', rejectWrite);
167
+ entry.on('error', rejectWrite);
168
+ entry.pipe(out);
169
+ })
170
+ );
171
+ },
172
+ });
173
+
174
+ await new Promise<void>((resolve, rejectRead) => {
175
+ const input = createReadStream(file);
176
+ input.on('error', rejectRead);
177
+ parser.on('error', (err: Error) =>
178
+ rejectRead(new Error(`Bundle is not a valid tar file: ${err.message}`))
179
+ );
180
+ parser.on('end', () => resolve());
181
+ input.pipe(parser);
182
+ });
183
+ await Promise.all(writes);
184
+
185
+ if (failure) {
186
+ throw failure;
187
+ }
188
+ if (manifestText === undefined) {
189
+ throw new Error(`Bundle is missing ${MANIFEST_FILE_NAME}`);
190
+ }
191
+ return { manifestText, placeSizes };
192
+ }
193
+
194
+ function _parseManifest(
195
+ manifestText: string,
196
+ dir: string,
197
+ placeSizes: Map<string, number>
198
+ ): { chunks: CombinedPlaceChunk[]; packages: PrebuiltBatchPackage[] } {
199
+ let parsed: unknown;
200
+ try {
201
+ parsed = JSON.parse(manifestText);
202
+ } catch {
203
+ throw new Error('Bundle manifest is not valid JSON');
204
+ }
205
+
206
+ const manifest = parsed as Partial<ManifestFile>;
207
+ if (manifest?.formatVersion !== FORMAT_VERSION) {
208
+ throw new Error(
209
+ `Unsupported bundle format ${String(manifest?.formatVersion)} ` +
210
+ `(expected ${FORMAT_VERSION})`
211
+ );
212
+ }
213
+ if (!Array.isArray(manifest.chunks) || manifest.chunks.length > MAX_CHUNKS) {
214
+ throw new Error(`Bundle must list at most ${MAX_CHUNKS} places`);
215
+ }
216
+ if (manifest.chunks.length !== placeSizes.size) {
217
+ throw new Error('Bundle manifest does not list every place it holds');
218
+ }
219
+
220
+ const seenNames = new Set<string>();
221
+ const seenSlugs = new Set<string>();
222
+ const seenFiles = new Set<string>();
223
+ const chunks: CombinedPlaceChunk[] = [];
224
+ const packages: PrebuiltBatchPackage[] = [];
225
+
226
+ for (const entry of manifest.chunks) {
227
+ const file = entry?.file;
228
+ const sizeBytes =
229
+ typeof file === 'string' ? placeSizes.get(file) : undefined;
230
+ if (sizeBytes === undefined || seenFiles.has(file!)) {
231
+ throw new Error(`Bundle lists an invalid place file: ${String(file)}`);
232
+ }
233
+ seenFiles.add(file!);
234
+
235
+ if (!Array.isArray(entry.packages) || entry.packages.length === 0) {
236
+ throw new Error(`${file} lists no packages`);
237
+ }
238
+
239
+ const slugMap = new Map<string, string>();
240
+ for (const pkg of entry.packages) {
241
+ const { name, slug, hasJestConfig } = pkg ?? {};
242
+ if (typeof name !== 'string' || !PACKAGE_NAME_PATTERN.test(name)) {
243
+ throw new Error(
244
+ `Bundle lists an invalid package name: ${String(name)}`
245
+ );
246
+ }
247
+ if (typeof slug !== 'string' || !SLUG_PATTERN.test(slug)) {
248
+ throw new Error(`Bundle lists an invalid slug for ${name}`);
249
+ }
250
+ if (seenNames.has(name) || seenSlugs.has(slug)) {
251
+ throw new Error(`Bundle lists ${name} (${slug}) more than once`);
252
+ }
253
+ seenNames.add(name);
254
+ seenSlugs.add(slug);
255
+ slugMap.set(name, slug);
256
+ packages.push({ name, hasJestConfig: hasJestConfig === true });
257
+ }
258
+
259
+ chunks.push({ rbxlPath: path.join(dir, file!), slugMap, sizeBytes });
260
+ }
261
+
262
+ if (packages.length > MAX_PACKAGES) {
263
+ throw new Error(`Bundle lists more than ${MAX_PACKAGES} packages`);
264
+ }
265
+
266
+ return { chunks, packages };
267
+ }
@@ -0,0 +1,18 @@
1
+ export interface TestRojoProject {
2
+ tree?: { ServerScriptService?: Record<string, unknown> };
3
+ }
4
+
5
+ /**
6
+ * A package's instance name in its test place: the first non-`$`,
7
+ * non-`Script` key under ServerScriptService.
8
+ */
9
+ export function findTestProjectSlug(
10
+ project: TestRojoProject
11
+ ): string | undefined {
12
+ const serverScriptService = project.tree?.ServerScriptService;
13
+ if (!serverScriptService) return undefined;
14
+
15
+ return Object.keys(serverScriptService).find(
16
+ (key) => !key.startsWith('$') && key !== 'Script'
17
+ );
18
+ }
@@ -2,7 +2,10 @@ import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
3
  import { randomUUID } from 'crypto';
4
4
  import { type DeployTarget } from '@quenty/nevermore-deploy';
5
- import { type JobContext } from '../../job-context/job-context.js';
5
+ import {
6
+ type BuiltPlace,
7
+ type JobContext,
8
+ } from '../../job-context/job-context.js';
6
9
  import { type DeployOverrides } from '../../build/build.js';
7
10
  import {
8
11
  type ParsedTestCounts,
@@ -88,6 +91,12 @@ export interface SingleTestOptions {
88
91
  suppressCountsProvenance?: boolean;
89
92
  }
90
93
 
94
+ export interface PreparedTest {
95
+ builtPlace: BuiltPlace;
96
+ scriptContent: string;
97
+ hasJestConfig: boolean;
98
+ }
99
+
91
100
  /**
92
101
  * Build, deploy, and run a test for a single package using the provided
93
102
  * JobContext. The context determines the execution environment (cloud or local).
@@ -98,17 +107,15 @@ export async function runSingleTestAsync(
98
107
  context: JobContext,
99
108
  options: SingleTestOptions
100
109
  ): Promise<SingleTestResult> {
101
- const {
102
- packagePath,
103
- packageName,
104
- // 300s is the Open Cloud maximum. The old 120s default failed real suites
105
- // with no jest summary at all, so "--timeout 300" had become folklore
106
- // everyone had to know before their first useful run.
107
- timeoutMs = 300_000,
108
- scriptText,
109
- target,
110
- overrides,
111
- } = options;
110
+ const prepared = await prepareTestAsync(context, options);
111
+ return executePreparedTestAsync(context, prepared, options);
112
+ }
113
+
114
+ export async function prepareTestAsync(
115
+ context: JobContext,
116
+ options: SingleTestOptions
117
+ ): Promise<PreparedTest> {
118
+ const { packagePath, packageName, scriptText, target, overrides } = options;
112
119
 
113
120
  const sessionId = randomUUID();
114
121
  const builtPlace = await context.buildPlaceAsync({
@@ -140,6 +147,29 @@ export async function runSingleTestAsync(
140
147
  scriptText ??
141
148
  (await readTestScriptAsync(packagePath, builtPlace.target.scriptTemplate));
142
149
 
150
+ return {
151
+ builtPlace,
152
+ scriptContent,
153
+ hasJestConfig: await packageHasJestConfigAsync(packagePath),
154
+ };
155
+ }
156
+
157
+ export async function executePreparedTestAsync(
158
+ context: JobContext,
159
+ prepared: PreparedTest,
160
+ options: SingleTestOptions
161
+ ): Promise<SingleTestResult> {
162
+ const {
163
+ packagePath,
164
+ packageName,
165
+ // 300s is the Open Cloud maximum. The old 120s default failed real suites
166
+ // with no jest summary at all, so "--timeout 300" had become folklore
167
+ // everyone had to know before their first useful run.
168
+ timeoutMs = 300_000,
169
+ scriptText,
170
+ } = options;
171
+ const { builtPlace, scriptContent, hasJestConfig } = prepared;
172
+
143
173
  const deployment = await context.deployBuiltPlaceAsync({
144
174
  builtPlace,
145
175
  packageName,
@@ -185,7 +215,7 @@ export async function runSingleTestAsync(
185
215
  structured !== undefined &&
186
216
  !structured.ranJest &&
187
217
  scriptText === undefined &&
188
- (await packageHasJestConfigAsync(packagePath));
218
+ hasJestConfig;
189
219
 
190
220
  const reasons = mergeFailureReasons(result.errorMessage, [
191
221
  ...(smokeTestedWithSpecs