@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
@@ -0,0 +1,204 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import * as tar from 'tar';
4
+ import { BuildContext } from '@quenty/nevermore-template-helpers';
5
+ import { type CombinedPlaceChunk } from './combined-project-generator.js';
6
+ import { PLACE_UPLOAD_MAX_BYTES } from '../../open-cloud/open-cloud-client.js';
7
+ import {
8
+ PACKAGE_NAME_PATTERN,
9
+ readUntrustedTarAsync,
10
+ } from '../../bundle/untrusted-tar.js';
11
+
12
+ /**
13
+ * Places built by `nevermore batch bundle --test`, unpacked from a
14
+ * `*.nevermore.tests.tar` and ready for `batch test --bundle` to run.
15
+ */
16
+ export interface PrebuiltBatchBundle {
17
+ chunks: CombinedPlaceChunk[];
18
+ packages: PrebuiltBatchPackage[];
19
+ /** Holds the unpacked places; whoever runs them owns its cleanup. */
20
+ buildContext: BuildContext;
21
+ }
22
+
23
+ export interface PrebuiltBatchPackage {
24
+ name: string;
25
+ hasJestConfig: boolean;
26
+ }
27
+
28
+ interface ManifestFile {
29
+ formatVersion: number;
30
+ kind?: 'tests';
31
+ chunks: Array<{
32
+ file: string;
33
+ packages: Array<{ name: string; slug: string; hasJestConfig: boolean }>;
34
+ }>;
35
+ }
36
+
37
+ /** Suggested file suffix, following `deploy.nevermore.json`. */
38
+ export const BUNDLE_FILE_SUFFIX = '.nevermore.tests.tar';
39
+
40
+ const MANIFEST_FILE_NAME = 'manifest.json';
41
+ const FORMAT_VERSION = 1;
42
+ const MAX_CHUNKS = 32;
43
+ const MAX_PACKAGES = 1000;
44
+ const MAX_MANIFEST_BYTES = 1024 * 1024;
45
+ const CHUNK_FILE_PATTERN = /^place-\d{1,3}\.rbxl$/;
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
+ kind: 'tests',
59
+ chunks: [],
60
+ };
61
+ for (const [index, chunk] of chunks.entries()) {
62
+ const placeFile = `place-${index + 1}.rbxl`;
63
+ await fs.copyFile(chunk.rbxlPath, path.join(stageDir, placeFile));
64
+ manifest.chunks.push({
65
+ file: placeFile,
66
+ packages: [...chunk.slugMap].map(([name, slug]) => ({
67
+ name,
68
+ slug,
69
+ hasJestConfig: hasJestConfig.get(name) ?? false,
70
+ })),
71
+ });
72
+ }
73
+ await fs.writeFile(
74
+ path.join(stageDir, MANIFEST_FILE_NAME),
75
+ JSON.stringify(manifest, null, 2)
76
+ );
77
+
78
+ await fs.mkdir(path.dirname(path.resolve(file)), { recursive: true });
79
+ await tar.c({ file, cwd: stageDir, portable: true }, [
80
+ MANIFEST_FILE_NAME,
81
+ ...manifest.chunks.map((c) => c.file),
82
+ ]);
83
+ } finally {
84
+ await stage.cleanupAsync();
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Read a bundle file. It is untrusted: entries are read from the archive, never
90
+ * extracted by it, and only `manifest.json` and `place-N.rbxl` regular files
91
+ * are accepted.
92
+ */
93
+ export async function readPrebuiltBatchBundleAsync(
94
+ file: string
95
+ ): Promise<PrebuiltBatchBundle> {
96
+ let stat;
97
+ try {
98
+ stat = await fs.lstat(file);
99
+ } catch {
100
+ throw new Error(`Bundle file not found: ${file}`);
101
+ }
102
+ if (!stat.isFile()) {
103
+ throw new Error(`Bundle ${file} is not a regular file`);
104
+ }
105
+
106
+ const buildContext = await BuildContext.createAsync({
107
+ prefix: 'test-bundle-',
108
+ });
109
+ const dir = buildContext.buildDir;
110
+ try {
111
+ const { manifestText, entrySizes: placeSizes } =
112
+ await readUntrustedTarAsync(file, dir, {
113
+ kind: 'tests',
114
+ manifestName: MANIFEST_FILE_NAME,
115
+ maxManifestBytes: MAX_MANIFEST_BYTES,
116
+ isEntryAllowed: (name) => CHUNK_FILE_PATTERN.test(name),
117
+ maxEntries: MAX_CHUNKS,
118
+ maxEntriesMessage: `Bundle must hold at most ${MAX_CHUNKS} places`,
119
+ checkEntrySize: (name, size) =>
120
+ size > PLACE_UPLOAD_MAX_BYTES
121
+ ? `${name} is over the place upload limit`
122
+ : undefined,
123
+ });
124
+ return { ..._parseManifest(manifestText, dir, placeSizes), buildContext };
125
+ } catch (err) {
126
+ await buildContext.cleanupAsync();
127
+ throw err;
128
+ }
129
+ }
130
+
131
+ function _parseManifest(
132
+ manifestText: string,
133
+ dir: string,
134
+ placeSizes: Map<string, number>
135
+ ): { chunks: CombinedPlaceChunk[]; packages: PrebuiltBatchPackage[] } {
136
+ let parsed: unknown;
137
+ try {
138
+ parsed = JSON.parse(manifestText);
139
+ } catch {
140
+ throw new Error('Bundle manifest is not valid JSON');
141
+ }
142
+
143
+ const manifest = parsed as Partial<ManifestFile>;
144
+ if (manifest?.formatVersion !== FORMAT_VERSION) {
145
+ throw new Error(
146
+ `Unsupported bundle format ${String(manifest?.formatVersion)} ` +
147
+ `(expected ${FORMAT_VERSION})`
148
+ );
149
+ }
150
+ if (!Array.isArray(manifest.chunks) || manifest.chunks.length > MAX_CHUNKS) {
151
+ throw new Error(`Bundle must list at most ${MAX_CHUNKS} places`);
152
+ }
153
+ if (manifest.chunks.length !== placeSizes.size) {
154
+ throw new Error('Bundle manifest does not list every place it holds');
155
+ }
156
+
157
+ const seenNames = new Set<string>();
158
+ const seenSlugs = new Set<string>();
159
+ const seenFiles = new Set<string>();
160
+ const chunks: CombinedPlaceChunk[] = [];
161
+ const packages: PrebuiltBatchPackage[] = [];
162
+
163
+ for (const entry of manifest.chunks) {
164
+ const file = entry?.file;
165
+ const sizeBytes =
166
+ typeof file === 'string' ? placeSizes.get(file) : undefined;
167
+ if (sizeBytes === undefined || seenFiles.has(file!)) {
168
+ throw new Error(`Bundle lists an invalid place file: ${String(file)}`);
169
+ }
170
+ seenFiles.add(file!);
171
+
172
+ if (!Array.isArray(entry.packages) || entry.packages.length === 0) {
173
+ throw new Error(`${file} lists no packages`);
174
+ }
175
+
176
+ const slugMap = new Map<string, string>();
177
+ for (const pkg of entry.packages) {
178
+ const { name, slug, hasJestConfig } = pkg ?? {};
179
+ if (typeof name !== 'string' || !PACKAGE_NAME_PATTERN.test(name)) {
180
+ throw new Error(
181
+ `Bundle lists an invalid package name: ${String(name)}`
182
+ );
183
+ }
184
+ if (typeof slug !== 'string' || !SLUG_PATTERN.test(slug)) {
185
+ throw new Error(`Bundle lists an invalid slug for ${name}`);
186
+ }
187
+ if (seenNames.has(name) || seenSlugs.has(slug)) {
188
+ throw new Error(`Bundle lists ${name} (${slug}) more than once`);
189
+ }
190
+ seenNames.add(name);
191
+ seenSlugs.add(slug);
192
+ slugMap.set(name, slug);
193
+ packages.push({ name, hasJestConfig: hasJestConfig === true });
194
+ }
195
+
196
+ chunks.push({ rbxlPath: path.join(dir, file!), slugMap, sizeBytes });
197
+ }
198
+
199
+ if (packages.length > MAX_PACKAGES) {
200
+ throw new Error(`Bundle lists more than ${MAX_PACKAGES} packages`);
201
+ }
202
+
203
+ return { chunks, packages };
204
+ }
@@ -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