@quenty/nevermore-cli 4.43.3 → 4.44.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 (154) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/build-scripts/{combine-test-places.luau → combine-test-places.lua} +3 -3
  3. package/build-scripts/{transform-inject-deploy-metadata.luau → transform-inject-deploy-metadata.lua} +3 -3
  4. package/dist/commands/batch-command/batch-deploy-command.js +4 -4
  5. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  6. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  7. package/dist/commands/batch-command/batch-test-command.js +76 -19
  8. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  9. package/dist/commands/deploy-command/deploy-init-utils.d.ts.map +1 -1
  10. package/dist/commands/deploy-command/deploy-init-utils.js +7 -1
  11. package/dist/commands/deploy-command/deploy-init-utils.js.map +1 -1
  12. package/dist/commands/deploy-command/index.js +1 -1
  13. package/dist/commands/deploy-command/index.js.map +1 -1
  14. package/dist/commands/init-command/init-game-command.js +2 -2
  15. package/dist/commands/init-command/init-game-command.js.map +1 -1
  16. package/dist/commands/install-package-command.d.ts +14 -0
  17. package/dist/commands/install-package-command.d.ts.map +1 -1
  18. package/dist/commands/install-package-command.js +65 -12
  19. package/dist/commands/install-package-command.js.map +1 -1
  20. package/dist/utils/deploy/deploy-github-columns.d.ts +12 -2
  21. package/dist/utils/deploy/deploy-github-columns.d.ts.map +1 -1
  22. package/dist/utils/deploy/deploy-github-columns.js +11 -4
  23. package/dist/utils/deploy/deploy-github-columns.js.map +1 -1
  24. package/dist/utils/deploy/deploy-metadata.js +1 -1
  25. package/dist/utils/deploy/deploy-metadata.js.map +1 -1
  26. package/dist/utils/job-context/base-job-context.d.ts +2 -2
  27. package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
  28. package/dist/utils/job-context/base-job-context.js +1 -1
  29. package/dist/utils/job-context/base-job-context.js.map +1 -1
  30. package/dist/utils/job-context/batch-script-job-context.d.ts +37 -2
  31. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  32. package/dist/utils/job-context/batch-script-job-context.js +59 -22
  33. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  34. package/dist/utils/job-context/cloud-job-context.d.ts +2 -2
  35. package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
  36. package/dist/utils/job-context/cloud-job-context.js +10 -5
  37. package/dist/utils/job-context/cloud-job-context.js.map +1 -1
  38. package/dist/utils/job-context/cloud-job-context.test.d.ts +7 -0
  39. package/dist/utils/job-context/cloud-job-context.test.d.ts.map +1 -0
  40. package/dist/utils/job-context/cloud-job-context.test.js +91 -0
  41. package/dist/utils/job-context/cloud-job-context.test.js.map +1 -0
  42. package/dist/utils/job-context/job-context.d.ts +70 -2
  43. package/dist/utils/job-context/job-context.d.ts.map +1 -1
  44. package/dist/utils/job-context/local-job-context.d.ts +2 -2
  45. package/dist/utils/job-context/local-job-context.d.ts.map +1 -1
  46. package/dist/utils/job-context/local-job-context.js +4 -2
  47. package/dist/utils/job-context/local-job-context.js.map +1 -1
  48. package/dist/utils/job-context/local-job-context.test.d.ts +7 -0
  49. package/dist/utils/job-context/local-job-context.test.d.ts.map +1 -0
  50. package/dist/utils/job-context/local-job-context.test.js +68 -0
  51. package/dist/utils/job-context/local-job-context.test.js.map +1 -0
  52. package/dist/utils/open-cloud/open-cloud-client.d.ts +50 -7
  53. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  54. package/dist/utils/open-cloud/open-cloud-client.js +49 -13
  55. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  56. package/dist/utils/open-cloud/open-cloud-client.test.js +125 -1
  57. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  58. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.d.ts +22 -0
  59. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.d.ts.map +1 -0
  60. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.js +202 -0
  61. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.js.map +1 -0
  62. package/dist/utils/package-manager-utils.d.ts +29 -0
  63. package/dist/utils/package-manager-utils.d.ts.map +1 -0
  64. package/dist/utils/package-manager-utils.js +120 -0
  65. package/dist/utils/package-manager-utils.js.map +1 -0
  66. package/dist/utils/package-manager-utils.test.d.ts +2 -0
  67. package/dist/utils/package-manager-utils.test.d.ts.map +1 -0
  68. package/dist/utils/package-manager-utils.test.js +121 -0
  69. package/dist/utils/package-manager-utils.test.js.map +1 -0
  70. package/dist/utils/testing/batch-run.e2e.test.d.ts +17 -0
  71. package/dist/utils/testing/batch-run.e2e.test.d.ts.map +1 -0
  72. package/dist/utils/testing/batch-run.e2e.test.js +343 -0
  73. package/dist/utils/testing/batch-run.e2e.test.js.map +1 -0
  74. package/dist/utils/testing/batch-test-runner-template.test.d.ts +11 -0
  75. package/dist/utils/testing/batch-test-runner-template.test.d.ts.map +1 -0
  76. package/dist/utils/testing/batch-test-runner-template.test.js +38 -0
  77. package/dist/utils/testing/batch-test-runner-template.test.js.map +1 -0
  78. package/dist/utils/testing/log-fetch-stats.d.ts +40 -0
  79. package/dist/utils/testing/log-fetch-stats.d.ts.map +1 -0
  80. package/dist/utils/testing/log-fetch-stats.js +18 -0
  81. package/dist/utils/testing/log-fetch-stats.js.map +1 -0
  82. package/dist/utils/testing/log-fetch-stats.test.d.ts +2 -0
  83. package/dist/utils/testing/log-fetch-stats.test.d.ts.map +1 -0
  84. package/dist/utils/testing/log-fetch-stats.test.js +41 -0
  85. package/dist/utils/testing/log-fetch-stats.test.js.map +1 -0
  86. package/dist/utils/testing/parsers/batch-log-parser.d.ts +126 -1
  87. package/dist/utils/testing/parsers/batch-log-parser.d.ts.map +1 -1
  88. package/dist/utils/testing/parsers/batch-log-parser.js +440 -103
  89. package/dist/utils/testing/parsers/batch-log-parser.js.map +1 -1
  90. package/dist/utils/testing/parsers/batch-log-parser.test.js +494 -1
  91. package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -1
  92. package/dist/utils/testing/parsers/batch-log-renderer.d.ts +79 -0
  93. package/dist/utils/testing/parsers/batch-log-renderer.d.ts.map +1 -0
  94. package/dist/utils/testing/parsers/batch-log-renderer.js +241 -0
  95. package/dist/utils/testing/parsers/batch-log-renderer.js.map +1 -0
  96. package/dist/utils/testing/parsers/batch-log-renderer.test.d.ts +2 -0
  97. package/dist/utils/testing/parsers/batch-log-renderer.test.d.ts.map +1 -0
  98. package/dist/utils/testing/parsers/batch-log-renderer.test.js +202 -0
  99. package/dist/utils/testing/parsers/batch-log-renderer.test.js.map +1 -0
  100. package/dist/utils/testing/runner/combined-project-generator.js +1 -1
  101. package/dist/utils/testing/runner/combined-project-generator.js.map +1 -1
  102. package/dist/utils/testing/runner/test-runner.d.ts +24 -0
  103. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  104. package/dist/utils/testing/runner/test-runner.js +166 -6
  105. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  106. package/dist/utils/testing/runner/test-runner.test.js +228 -2
  107. package/dist/utils/testing/runner/test-runner.test.js.map +1 -1
  108. package/dist/utils/testing/structured-test-results.d.ts +77 -0
  109. package/dist/utils/testing/structured-test-results.d.ts.map +1 -0
  110. package/dist/utils/testing/structured-test-results.js +140 -0
  111. package/dist/utils/testing/structured-test-results.js.map +1 -0
  112. package/dist/utils/testing/structured-test-results.test.d.ts +7 -0
  113. package/dist/utils/testing/structured-test-results.test.d.ts.map +1 -0
  114. package/dist/utils/testing/structured-test-results.test.js +179 -0
  115. package/dist/utils/testing/structured-test-results.test.js.map +1 -0
  116. package/package.json +6 -6
  117. package/src/commands/batch-command/batch-deploy-command.ts +4 -4
  118. package/src/commands/batch-command/batch-test-command.ts +94 -19
  119. package/src/commands/deploy-command/deploy-init-utils.ts +7 -1
  120. package/src/commands/deploy-command/index.ts +1 -1
  121. package/src/commands/init-command/init-game-command.ts +2 -2
  122. package/src/commands/install-package-command.ts +107 -14
  123. package/src/utils/deploy/deploy-github-columns.ts +14 -4
  124. package/src/utils/deploy/deploy-metadata.ts +1 -1
  125. package/src/utils/job-context/base-job-context.ts +3 -2
  126. package/src/utils/job-context/batch-script-job-context.ts +98 -17
  127. package/src/utils/job-context/cloud-job-context.test.ts +112 -0
  128. package/src/utils/job-context/cloud-job-context.ts +12 -6
  129. package/src/utils/job-context/job-context.ts +71 -2
  130. package/src/utils/job-context/local-job-context.test.ts +89 -0
  131. package/src/utils/job-context/local-job-context.ts +7 -3
  132. package/src/utils/open-cloud/open-cloud-client.test.ts +155 -1
  133. package/src/utils/open-cloud/open-cloud-client.ts +94 -18
  134. package/src/utils/open-cloud/open-cloud-logs.characterization.test.ts +277 -0
  135. package/src/utils/package-manager-utils.test.ts +174 -0
  136. package/src/utils/package-manager-utils.ts +148 -0
  137. package/src/utils/testing/batch-run.e2e.test.ts +425 -0
  138. package/src/utils/testing/batch-test-runner-template.test.ts +62 -0
  139. package/src/utils/testing/log-fetch-stats.test.ts +48 -0
  140. package/src/utils/testing/log-fetch-stats.ts +55 -0
  141. package/src/utils/testing/parsers/batch-log-parser.test.ts +660 -1
  142. package/src/utils/testing/parsers/batch-log-parser.ts +721 -116
  143. package/src/utils/testing/parsers/batch-log-renderer.test.ts +330 -0
  144. package/src/utils/testing/parsers/batch-log-renderer.ts +329 -0
  145. package/src/utils/testing/runner/combined-project-generator.ts +1 -1
  146. package/src/utils/testing/runner/test-runner.test.ts +332 -2
  147. package/src/utils/testing/runner/test-runner.ts +231 -9
  148. package/src/utils/testing/structured-test-results.test.ts +267 -0
  149. package/src/utils/testing/structured-test-results.ts +199 -0
  150. package/templates/batch-test-runner.lua +242 -0
  151. package/tsconfig.tsbuildinfo +1 -1
  152. package/templates/batch-test-runner.luau +0 -137
  153. /package/build-scripts/{smoke-test-server.luau → smoke-test-server.lua} +0 -0
  154. /package/build-scripts/{transform-rojo-merge-place.luau → transform-rojo-merge-place.lua} +0 -0
@@ -6,9 +6,17 @@ import { Argv, CommandModule } from 'yargs';
6
6
  import { OutputHelper } from '@quenty/cli-output-helpers';
7
7
  import { NevermoreGlobalArgs } from '../args/global-args.js';
8
8
  import { runCommandAsync } from '../utils/nevermore-cli-utils.js';
9
+ import {
10
+ buildAddPackagesCommand,
11
+ detectPackageManagerAsync,
12
+ isPackageManager,
13
+ PACKAGE_MANAGERS,
14
+ PackageManager,
15
+ } from '../utils/package-manager-utils.js';
9
16
 
10
17
  export interface InstallPackageArgs extends NevermoreGlobalArgs {
11
18
  packages: string[];
19
+ packageManager?: string;
12
20
  }
13
21
 
14
22
  /**
@@ -27,19 +35,65 @@ export class InstallPackageCommand<T>
27
35
  }
28
36
  }
29
37
 
38
+ /**
39
+ * Lists the published `@quenty/*` package names, without the scope.
40
+ *
41
+ * The registry caps search results at 250 per request, well under the number
42
+ * of published packages, so this pages through until the reported total is
43
+ * covered.
44
+ */
30
45
  private static async _getPackages(): Promise<string[]> {
46
+ const pageSize = 250;
47
+ const names = new Set<string>();
48
+
49
+ try {
50
+ let from = 0;
51
+ for (;;) {
52
+ const response = await fetch(
53
+ `https://registry.npmjs.org/-/v1/search?text=@quenty/&size=${pageSize}&from=${from}`
54
+ );
55
+ const data = await response.json();
56
+ const objects: any[] = data.objects ?? [];
57
+
58
+ for (const entry of objects) {
59
+ const name: string = entry.package?.name ?? '';
60
+ if (name.startsWith('@quenty/')) {
61
+ names.add(name.slice('@quenty/'.length));
62
+ }
63
+ }
64
+
65
+ from += objects.length;
66
+ if (objects.length < pageSize || from >= (data.total ?? 0)) {
67
+ break;
68
+ }
69
+ }
70
+ } catch {
71
+ return [];
72
+ }
73
+
74
+ return [...names].sort();
75
+ }
76
+
77
+ /**
78
+ * Asks the registry about one package directly. Returns undefined when the
79
+ * registry can't be reached, so an offline run doesn't look like a typo.
80
+ */
81
+ private static async _packageExistsAsync(
82
+ scopedName: string
83
+ ): Promise<boolean | undefined> {
31
84
  try {
32
85
  const response = await fetch(
33
- 'https://registry.npmjs.org/-/v1/search?text=@quenty/&size=1000'
86
+ `https://registry.npmjs.org/${scopedName.replace('/', '%2f')}`,
87
+ { method: 'HEAD' }
34
88
  );
35
- const data = await response.json();
36
- return data.objects
37
- .map((obj: any) => obj.package.name)
38
- .filter((name: string) => name.startsWith('@quenty/'))
39
- .map((name: string) => name.replace('@quenty/', ''))
40
- .sort();
89
+
90
+ if (response.status === 404) {
91
+ return false;
92
+ }
93
+
94
+ return response.ok ? true : undefined;
41
95
  } catch {
42
- return [];
96
+ return undefined;
43
97
  }
44
98
  }
45
99
 
@@ -53,9 +107,34 @@ export class InstallPackageCommand<T>
53
107
  return packages.filter((name) => !current || name.startsWith(current));
54
108
  },
55
109
  });
110
+ args.option('package-manager', {
111
+ describe:
112
+ 'Package manager to install with. Detected from the project by default.',
113
+ type: 'string',
114
+ choices: PACKAGE_MANAGERS as unknown as string[],
115
+ });
56
116
  return args as Argv<InstallPackageArgs>;
57
117
  }
58
118
 
119
+ private static async _resolvePackageManagerAsync(
120
+ args: InstallPackageArgs,
121
+ srcRoot: string
122
+ ): Promise<PackageManager> {
123
+ const requested = args.packageManager;
124
+ if (requested) {
125
+ if (!isPackageManager(requested)) {
126
+ throw new Error(
127
+ `Unsupported package manager '${requested}'. Expected one of: ${PACKAGE_MANAGERS.join(
128
+ ', '
129
+ )}`
130
+ );
131
+ }
132
+ return requested;
133
+ }
134
+
135
+ return detectPackageManagerAsync(srcRoot);
136
+ }
137
+
59
138
  public async handler(args: InstallPackageArgs) {
60
139
  const srcRoot = process.cwd();
61
140
 
@@ -67,22 +146,36 @@ export class InstallPackageCommand<T>
67
146
  InstallPackageCommand._validatePackageName(packageName)
68
147
  );
69
148
 
70
- const availablePackages = await InstallPackageCommand._getPackages();
149
+ const prefixedPackages = args.packages.map(
150
+ (packageName) => `@quenty/${packageName}`
151
+ );
152
+
153
+ const existence = await Promise.all(
154
+ prefixedPackages.map((scopedName) =>
155
+ InstallPackageCommand._packageExistsAsync(scopedName)
156
+ )
157
+ );
71
158
  const invalidPackages = args.packages.filter(
72
- (packageName) => !availablePackages.includes(packageName)
159
+ (_, index) => existence[index] === false
73
160
  );
74
161
 
75
162
  if (invalidPackages.length > 0) {
76
163
  throw new Error(`Invalid packages: ${invalidPackages.join(', ')}`);
77
164
  }
78
165
 
79
- const prefixedPackages = args.packages.map(
80
- (packageName) => `@quenty/${packageName}`
166
+ const packageManager =
167
+ await InstallPackageCommand._resolvePackageManagerAsync(args, srcRoot);
168
+
169
+ OutputHelper.info(
170
+ `Installing packages with ${packageManager}: ${args.packages.join(', ')}`
81
171
  );
82
172
 
83
- OutputHelper.info(`Installing packages: ${args.packages.join(', ')}`);
173
+ const { command, args: commandArgs } = buildAddPackagesCommand(
174
+ packageManager,
175
+ prefixedPackages
176
+ );
84
177
 
85
- await runCommandAsync(args, 'npm', ['install', ...prefixedPackages], {
178
+ await runCommandAsync(args, command, commandArgs, {
86
179
  cwd: srcRoot,
87
180
  });
88
181
  }
@@ -11,16 +11,26 @@ export interface BatchDeployResult extends PackageResult {
11
11
  placeId: number;
12
12
  }
13
13
 
14
- /** Shared config for the deploy results GitHub comment reporter. */
15
- export function createDeployCommentConfig(): GithubCommentTableConfig {
14
+ /**
15
+ * Shared config for the deploy results GitHub comment reporter.
16
+ *
17
+ * Takes the labels rather than fixing them, because a publish and a deploy are
18
+ * different words and the command already knows which it is doing. Without
19
+ * this the comment said "Failed" for a run whose terminal output said "DEPLOY
20
+ * FAILED" — the same run, described two ways.
21
+ */
22
+ export function createDeployCommentConfig(labels?: {
23
+ successLabel?: string;
24
+ failureLabel?: string;
25
+ }): GithubCommentTableConfig {
16
26
  return {
17
27
  heading: 'Deploy Results',
18
28
  commentMarker: '<!-- nevermore-deploy-results -->',
19
29
  sectionId: 'deploy',
20
30
  extraColumns: [createErrorColumn(), createTryItColumn()],
21
31
  errorHeading: 'Deploy Results',
22
- successLabel: 'Deployed',
23
- failureLabel: 'Failed',
32
+ successLabel: labels?.successLabel ?? 'Deployed',
33
+ failureLabel: labels?.failureLabel ?? 'DEPLOY FAILED',
24
34
  summaryVerb: 'deployed',
25
35
  };
26
36
  }
@@ -25,7 +25,7 @@ export type DeployMetadataAttributes = Record<
25
25
  const INJECT_SCRIPT_PATH = resolvePackagePath(
26
26
  import.meta.url,
27
27
  'build-scripts',
28
- 'transform-inject-deploy-metadata.luau'
28
+ 'transform-inject-deploy-metadata.lua'
29
29
  );
30
30
 
31
31
  function _git(args: string[]): string | undefined {
@@ -17,6 +17,7 @@ import {
17
17
  type DeployPlaceOptions,
18
18
  type RunScriptOptions,
19
19
  type ScriptRunResult,
20
+ type JobLogs,
20
21
  } from './job-context.js';
21
22
  import { type OpenCloudClient } from '../open-cloud/open-cloud-client.js';
22
23
  import { type BasePlaceResolver } from '@quenty/nevermore-deploy';
@@ -24,7 +25,7 @@ import { type BasePlaceResolver } from '@quenty/nevermore-deploy';
24
25
  const MERGE_SCRIPT_PATH = resolvePackagePath(
25
26
  import.meta.url,
26
27
  'build-scripts',
27
- 'transform-rojo-merge-place.luau'
28
+ 'transform-rojo-merge-place.lua'
28
29
  );
29
30
 
30
31
  interface SharedRojoBuild {
@@ -300,7 +301,7 @@ export abstract class BaseJobContext implements JobContext {
300
301
  deployment: Deployment,
301
302
  options: RunScriptOptions
302
303
  ): Promise<ScriptRunResult>;
303
- abstract getLogsAsync(deployment: Deployment): Promise<string>;
304
+ abstract getLogsAsync(deployment: Deployment): Promise<JobLogs>;
304
305
  abstract releaseAsync(deployment: Deployment): Promise<void>;
305
306
 
306
307
  async disposeAsync(): Promise<void> {
@@ -12,6 +12,7 @@ import {
12
12
  type JobContext,
13
13
  type RunScriptOptions,
14
14
  type ScriptRunResult,
15
+ type JobLogs,
15
16
  } from './job-context.js';
16
17
  import { type BuildPlaceOptions } from '../build/build.js';
17
18
  import { type BatchTarget } from '../batch/changed-packages-utils.js';
@@ -21,9 +22,15 @@ import {
21
22
  generateCombinedProjectAsync,
22
23
  } from '../testing/runner/combined-project-generator.js';
23
24
  import {
25
+ type BatchDiagnosticLevel,
24
26
  type BatchPackageResult,
25
27
  parseBatchTestLogs,
26
28
  } from '../testing/parsers/batch-log-parser.js';
29
+ import { type LogFetchStats } from '../testing/log-fetch-stats.js';
30
+ import {
31
+ toRenderableLines,
32
+ type RenderableLogLine,
33
+ } from '../testing/parsers/batch-log-renderer.js';
27
34
 
28
35
  /** Per-package deployment handle. Wraps a shared inner deployment. */
29
36
  class BatchDeployment implements Deployment {
@@ -50,6 +57,28 @@ interface CombinedBuildState {
50
57
  * package configured with one — those belong in `nevermore batch test
51
58
  * --no-aggregated`, or in an integration deploy.
52
59
  */
60
+ /**
61
+ * Everything one aggregated batch produced, as data.
62
+ *
63
+ * The execution used to parse, render and print from in here. Printing from a
64
+ * job context put presentation in the transport, bypassed whatever reporter the
65
+ * command had chosen — output written straight to the console is invisible to a
66
+ * SpinnerReporter's capture — and left the whole path untestable without spying
67
+ * on `console.log`. The context returns this instead, and the command layer
68
+ * decides what to show.
69
+ */
70
+ export interface BatchExecutionOutcome {
71
+ results: Map<string, BatchPackageResult>;
72
+ /** The run's output, typed by severity and not yet rendered. */
73
+ lines: RenderableLogLine[];
74
+ /** What the fetch had to do to collect it; absent for a transport that cannot say. */
75
+ stats?: LogFetchStats;
76
+ /** What reading the log turned up, to report after the log itself. */
77
+ diagnostics: Array<{ level: BatchDiagnosticLevel; message: string }>;
78
+ /** Section slug to package name, for whoever titles the sections. */
79
+ slugToPackage: Map<string, string>;
80
+ }
81
+
53
82
  export class BatchScriptJobContext implements JobContext {
54
83
  private _inner: JobContext;
55
84
  private _batchTargets: BatchTarget[];
@@ -62,7 +91,7 @@ export class BatchScriptJobContext implements JobContext {
62
91
  // Lazy-promise state
63
92
  private _combinedBuildPromise?: Promise<CombinedBuildState>;
64
93
  private _deployPromise?: Promise<Deployment>;
65
- private _executionPromise?: Promise<Map<string, BatchPackageResult>>;
94
+ private _executionPromise?: Promise<BatchExecutionOutcome>;
66
95
 
67
96
  // State for cleanup
68
97
  private _combinedBuildContext?: BuildContext;
@@ -124,8 +153,8 @@ export class BatchScriptJobContext implements JobContext {
124
153
  options: RunScriptOptions
125
154
  ): Promise<ScriptRunResult> {
126
155
  const batchDeployment = deployment as BatchDeployment;
127
- const packageResults = await this._getBatchExecutionAsync();
128
- const result = packageResults.get(batchDeployment.packageName);
156
+ const { results } = await this._getBatchExecutionAsync();
157
+ const result = results.get(batchDeployment.packageName);
129
158
 
130
159
  if (!result) {
131
160
  return { success: false };
@@ -135,15 +164,39 @@ export class BatchScriptJobContext implements JobContext {
135
164
  success: result.success,
136
165
  durationMs: result.durationMs,
137
166
  errorMessage: result.error,
167
+ // returnValues stays absent: one execution covers every package, so
168
+ // nothing it returns belongs to any single one of them. The batch runner
169
+ // folds each package's own results into the batch summary, which the log
170
+ // parser has already split back out — so they are handed over decoded
171
+ // rather than left for the caller to find in a return value that is not
172
+ // this package's.
173
+ testResults: result.testResults,
174
+ logsLost: result.logsLost,
138
175
  };
139
176
  }
140
177
 
141
- async getLogsAsync(deployment: Deployment): Promise<string> {
142
- const batchDeployment = deployment as BatchDeployment;
143
- const packageResults = await this._getBatchExecutionAsync();
144
- const result = packageResults.get(batchDeployment.packageName);
178
+ /**
179
+ * Run the batch if it has not run, and hand back everything it produced.
180
+ *
181
+ * Public so the command layer can render the run once, before the
182
+ * per-package loop asks for results — which is also what keeps the shared
183
+ * build and upload out of any one package's async context.
184
+ */
185
+ async getExecutionOutcomeAsync(): Promise<BatchExecutionOutcome> {
186
+ return this._getBatchExecutionAsync();
187
+ }
145
188
 
146
- return result?.logs ?? '';
189
+ async getLogsAsync(deployment: Deployment): Promise<JobLogs> {
190
+ const batchDeployment = deployment as BatchDeployment;
191
+ const { results, stats } = await this._getBatchExecutionAsync();
192
+ const result = results.get(batchDeployment.packageName);
193
+
194
+ // The text is this package's section; the stats describe the one fetch that
195
+ // pulled every section, which is what happened to any package whose own
196
+ // section went missing from it. Messages stay empty: severity is per
197
+ // message across the whole window, and slicing it per package would need
198
+ // the section boundaries the parser already resolved.
199
+ return { text: result?.logs ?? '', messages: [], stats };
147
200
  }
148
201
 
149
202
  async releaseAsync(_deployment: Deployment): Promise<void> {
@@ -257,7 +310,7 @@ export class BatchScriptJobContext implements JobContext {
257
310
  return deployment;
258
311
  }
259
312
 
260
- private _getBatchExecutionAsync(): Promise<Map<string, BatchPackageResult>> {
313
+ private _getBatchExecutionAsync(): Promise<BatchExecutionOutcome> {
261
314
  if (!this._executionPromise) {
262
315
  this._executionPromise = this._doBatchExecutionAsync();
263
316
  // On rejection, clear the promise so that a retry re-triggers the batch
@@ -268,9 +321,7 @@ export class BatchScriptJobContext implements JobContext {
268
321
  return this._executionPromise;
269
322
  }
270
323
 
271
- private async _doBatchExecutionAsync(): Promise<
272
- Map<string, BatchPackageResult>
273
- > {
324
+ private async _doBatchExecutionAsync(): Promise<BatchExecutionOutcome> {
274
325
  const buildState = await this._getCombinedBuildAsync();
275
326
  const deployment = await this._getSharedDeploymentAsync();
276
327
  const { slugMap } = buildState.combinedResult;
@@ -278,7 +329,7 @@ export class BatchScriptJobContext implements JobContext {
278
329
  // Build the batch Luau script from the template
279
330
  const templatePath = resolveTemplatePath(
280
331
  import.meta.url,
281
- 'batch-test-runner.luau'
332
+ 'batch-test-runner.lua'
282
333
  );
283
334
  const template = await fs.readFile(templatePath, 'utf-8');
284
335
 
@@ -300,8 +351,9 @@ export class BatchScriptJobContext implements JobContext {
300
351
  timeoutMs: this._batchTimeoutMs,
301
352
  });
302
353
 
303
- // Fetch the combined logs
304
- const rawLogs = await this._inner.getLogsAsync(deployment);
354
+ // Fetch the combined logs, with the severity and fetch that produced them.
355
+ const logs = await this._inner.getLogsAsync(deployment);
356
+ const rawLogs = logs.text;
305
357
 
306
358
  if (!result.success) {
307
359
  const stateInfo = result.taskState ? ` (state: ${result.taskState})` : '';
@@ -319,7 +371,36 @@ export class BatchScriptJobContext implements JobContext {
319
371
  OutputHelper.verbose(`Raw batch logs:\n${rawLogs || '(empty)'}`);
320
372
  }
321
373
 
322
- // Parse into per-package results
323
- return parseBatchTestLogs(rawLogs, slugMap);
374
+ // Parse into per-package results. Diagnostics are collected rather than
375
+ // printed: they describe the log window, so they belong after it, and that
376
+ // is the caller's ordering to make.
377
+ const diagnostics: Array<{ level: BatchDiagnosticLevel; message: string }> =
378
+ [];
379
+ const results = parseBatchTestLogs(rawLogs, slugMap, {
380
+ logFetchStats: logs.stats,
381
+ // The batch script returns the same summary it prints. Handing both over
382
+ // lets the parser read the copy that cannot arrive truncated, and fall
383
+ // back to the printed one on a transport that carries no return channel.
384
+ returnValues: result.returnValues,
385
+ onDiagnostic: (level, message) => diagnostics.push({ level, message }),
386
+ });
387
+
388
+ const slugToPackage = new Map<string, string>();
389
+ for (const [packageName, slug] of slugMap) {
390
+ slugToPackage.set(slug, packageName);
391
+ }
392
+
393
+ // Typed messages when the transport carries severity; otherwise the joined
394
+ // text, which renders uncolored rather than guessed at.
395
+ const messages =
396
+ logs.messages.length > 0 ? logs.messages : [{ message: rawLogs }];
397
+
398
+ return {
399
+ results,
400
+ lines: toRenderableLines(messages),
401
+ stats: logs.stats,
402
+ diagnostics,
403
+ slugToPackage,
404
+ };
324
405
  }
325
406
  }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Unit tests for CloudJobContext.runScriptAsync — validates what a finished
3
+ * Open Cloud task reports back as a ScriptRunResult, in particular that a task
4
+ * which produced no output stays distinguishable from one that returned nothing.
5
+ */
6
+
7
+ import { describe, it, expect, vi, afterEach } from 'vitest';
8
+ import { type Reporter } from '@quenty/cli-output-helpers/reporting';
9
+ import { CloudJobContext } from './cloud-job-context.js';
10
+ import {
11
+ type LuauTask,
12
+ type OpenCloudClient,
13
+ } from '../open-cloud/open-cloud-client.js';
14
+ import { type Deployment } from './job-context.js';
15
+
16
+ function createReporter(): Reporter {
17
+ return {
18
+ onPackagePhaseChange: vi.fn(),
19
+ onPackageProgressUpdate: vi.fn(),
20
+ onPackageStart: vi.fn(),
21
+ onPackageResult: vi.fn(),
22
+ } as unknown as Reporter;
23
+ }
24
+
25
+ /**
26
+ * A client whose task completes as `completedTask`. The real deployment handle
27
+ * is private to the context, so the test passes the three fields runScriptAsync
28
+ * reads off it.
29
+ */
30
+ function createContext(completedTask: Partial<LuauTask>) {
31
+ const task = {
32
+ path: 'universes/1/places/2/versions/3/luau-execution-session-tasks/4',
33
+ createTime: '2026-01-01T00:00:00Z',
34
+ updateTime: '2026-01-01T00:01:00Z',
35
+ user: 'users/1',
36
+ state: 'COMPLETE',
37
+ script: 'return 1',
38
+ ...completedTask,
39
+ } as LuauTask;
40
+
41
+ const client = {
42
+ createExecutionTaskAsync: vi.fn(async () => task),
43
+ pollTaskCompletionAsync: vi.fn(async () => task),
44
+ } as unknown as OpenCloudClient;
45
+
46
+ const context = new CloudJobContext(createReporter(), client);
47
+ const deployment = {
48
+ universeId: 1,
49
+ placeId: 2,
50
+ version: 3,
51
+ } as unknown as Deployment;
52
+
53
+ return { context, deployment };
54
+ }
55
+
56
+ describe('CloudJobContext.runScriptAsync', () => {
57
+ afterEach(() => {
58
+ vi.useRealTimers();
59
+ });
60
+
61
+ it('reports the values the script returned', async () => {
62
+ // Fake timers keep the client-side timeout race from leaving a live timer
63
+ // behind; nothing in the run itself waits on one.
64
+ vi.useFakeTimers();
65
+ const { context, deployment } = createContext({
66
+ state: 'COMPLETE',
67
+ output: { results: [{ slug: 'maid', counts: { passed: 1014 } }] },
68
+ });
69
+
70
+ const result = await context.runScriptAsync(deployment, {
71
+ scriptContent: 'return results',
72
+ packageName: 'maid',
73
+ });
74
+
75
+ expect(result.success).toBe(true);
76
+ expect(result.returnValues).toEqual([
77
+ { slug: 'maid', counts: { passed: 1014 } },
78
+ ]);
79
+ });
80
+
81
+ it('reports an empty result when the task returned nothing', async () => {
82
+ vi.useFakeTimers();
83
+ const { context, deployment } = createContext({
84
+ state: 'COMPLETE',
85
+ output: {},
86
+ });
87
+
88
+ const result = await context.runScriptAsync(deployment, {
89
+ scriptContent: 'print("hi")',
90
+ packageName: 'maid',
91
+ });
92
+
93
+ expect(result.returnValues).toEqual([]);
94
+ });
95
+
96
+ it('leaves returnValues absent when a failed task carried no output', async () => {
97
+ vi.useFakeTimers();
98
+ const { context, deployment } = createContext({
99
+ state: 'FAILED',
100
+ output: undefined,
101
+ });
102
+
103
+ const result = await context.runScriptAsync(deployment, {
104
+ scriptContent: 'return huge',
105
+ packageName: 'maid',
106
+ });
107
+
108
+ expect(result.success).toBe(false);
109
+ expect(result.taskState).toBe('FAILED');
110
+ expect(result.returnValues).toBeUndefined();
111
+ });
112
+ });
@@ -1,5 +1,6 @@
1
1
  import { type Reporter } from '@quenty/cli-output-helpers/reporting';
2
2
  import {
3
+ getTaskReturnValues,
3
4
  type LuauTask,
4
5
  type OpenCloudClient,
5
6
  } from '../open-cloud/open-cloud-client.js';
@@ -8,6 +9,7 @@ import { buildPlaceNameAsync, timeoutAsync } from '../nevermore-cli-utils.js';
8
9
  import {
9
10
  type Deployment,
10
11
  type DeployPlaceOptions,
12
+ type JobLogs,
11
13
  type RunScriptOptions,
12
14
  type ScriptRunResult,
13
15
  } from './job-context.js';
@@ -134,27 +136,31 @@ export class CloudJobContext extends BaseJobContext {
134
136
  success: completedTask.state === 'COMPLETE',
135
137
  taskState: completedTask.state,
136
138
  errorMessage,
139
+ returnValues: getTaskReturnValues(completedTask),
137
140
  };
138
141
  }
139
142
 
140
- async getLogsAsync(deployment: Deployment): Promise<string> {
143
+ async getLogsAsync(deployment: Deployment): Promise<JobLogs> {
141
144
  const cloudDeployment = deployment as CloudDeployment;
142
145
 
143
146
  if (!cloudDeployment.taskPath) {
144
147
  throw new Error('No task has been run yet');
145
148
  }
146
149
 
147
- const logs = await this._openCloudClient!.getRawTaskLogsAsync(
150
+ const fetched = await this._openCloudClient!.getRawTaskLogsAsync(
148
151
  cloudDeployment.taskPath
149
152
  );
150
153
 
151
154
  if (cloudDeployment.taskState && cloudDeployment.taskState !== 'COMPLETE') {
152
- return [logs, `Task ended with state: ${cloudDeployment.taskState}`]
153
- .filter(Boolean)
154
- .join('\n');
155
+ const note = `Task ended with state: ${cloudDeployment.taskState}`;
156
+ return {
157
+ text: [fetched.text, note].filter(Boolean).join('\n'),
158
+ messages: [...fetched.messages, { message: note }],
159
+ stats: fetched.stats,
160
+ };
155
161
  }
156
162
 
157
- return logs;
163
+ return fetched;
158
164
  }
159
165
 
160
166
  async releaseAsync(_deployment: Deployment): Promise<void> {