@quenty/nevermore-cli 4.43.4 → 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 (143) hide show
  1. package/CHANGELOG.md +4 -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/utils/deploy/deploy-github-columns.d.ts +12 -2
  15. package/dist/utils/deploy/deploy-github-columns.d.ts.map +1 -1
  16. package/dist/utils/deploy/deploy-github-columns.js +11 -4
  17. package/dist/utils/deploy/deploy-github-columns.js.map +1 -1
  18. package/dist/utils/deploy/deploy-metadata.js +1 -1
  19. package/dist/utils/deploy/deploy-metadata.js.map +1 -1
  20. package/dist/utils/job-context/base-job-context.d.ts +2 -2
  21. package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
  22. package/dist/utils/job-context/base-job-context.js +1 -1
  23. package/dist/utils/job-context/base-job-context.js.map +1 -1
  24. package/dist/utils/job-context/batch-script-job-context.d.ts +37 -2
  25. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  26. package/dist/utils/job-context/batch-script-job-context.js +59 -22
  27. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  28. package/dist/utils/job-context/cloud-job-context.d.ts +2 -2
  29. package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
  30. package/dist/utils/job-context/cloud-job-context.js +10 -5
  31. package/dist/utils/job-context/cloud-job-context.js.map +1 -1
  32. package/dist/utils/job-context/cloud-job-context.test.d.ts +7 -0
  33. package/dist/utils/job-context/cloud-job-context.test.d.ts.map +1 -0
  34. package/dist/utils/job-context/cloud-job-context.test.js +91 -0
  35. package/dist/utils/job-context/cloud-job-context.test.js.map +1 -0
  36. package/dist/utils/job-context/job-context.d.ts +70 -2
  37. package/dist/utils/job-context/job-context.d.ts.map +1 -1
  38. package/dist/utils/job-context/local-job-context.d.ts +2 -2
  39. package/dist/utils/job-context/local-job-context.d.ts.map +1 -1
  40. package/dist/utils/job-context/local-job-context.js +4 -2
  41. package/dist/utils/job-context/local-job-context.js.map +1 -1
  42. package/dist/utils/job-context/local-job-context.test.d.ts +7 -0
  43. package/dist/utils/job-context/local-job-context.test.d.ts.map +1 -0
  44. package/dist/utils/job-context/local-job-context.test.js +68 -0
  45. package/dist/utils/job-context/local-job-context.test.js.map +1 -0
  46. package/dist/utils/open-cloud/open-cloud-client.d.ts +50 -7
  47. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  48. package/dist/utils/open-cloud/open-cloud-client.js +49 -13
  49. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  50. package/dist/utils/open-cloud/open-cloud-client.test.js +125 -1
  51. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  52. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.d.ts +22 -0
  53. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.d.ts.map +1 -0
  54. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.js +202 -0
  55. package/dist/utils/open-cloud/open-cloud-logs.characterization.test.js.map +1 -0
  56. package/dist/utils/package-manager-utils.d.ts.map +1 -1
  57. package/dist/utils/package-manager-utils.js +32 -1
  58. package/dist/utils/package-manager-utils.js.map +1 -1
  59. package/dist/utils/package-manager-utils.test.js +25 -0
  60. package/dist/utils/package-manager-utils.test.js.map +1 -1
  61. package/dist/utils/testing/batch-run.e2e.test.d.ts +17 -0
  62. package/dist/utils/testing/batch-run.e2e.test.d.ts.map +1 -0
  63. package/dist/utils/testing/batch-run.e2e.test.js +343 -0
  64. package/dist/utils/testing/batch-run.e2e.test.js.map +1 -0
  65. package/dist/utils/testing/batch-test-runner-template.test.d.ts +11 -0
  66. package/dist/utils/testing/batch-test-runner-template.test.d.ts.map +1 -0
  67. package/dist/utils/testing/batch-test-runner-template.test.js +38 -0
  68. package/dist/utils/testing/batch-test-runner-template.test.js.map +1 -0
  69. package/dist/utils/testing/log-fetch-stats.d.ts +40 -0
  70. package/dist/utils/testing/log-fetch-stats.d.ts.map +1 -0
  71. package/dist/utils/testing/log-fetch-stats.js +18 -0
  72. package/dist/utils/testing/log-fetch-stats.js.map +1 -0
  73. package/dist/utils/testing/log-fetch-stats.test.d.ts +2 -0
  74. package/dist/utils/testing/log-fetch-stats.test.d.ts.map +1 -0
  75. package/dist/utils/testing/log-fetch-stats.test.js +41 -0
  76. package/dist/utils/testing/log-fetch-stats.test.js.map +1 -0
  77. package/dist/utils/testing/parsers/batch-log-parser.d.ts +126 -1
  78. package/dist/utils/testing/parsers/batch-log-parser.d.ts.map +1 -1
  79. package/dist/utils/testing/parsers/batch-log-parser.js +440 -103
  80. package/dist/utils/testing/parsers/batch-log-parser.js.map +1 -1
  81. package/dist/utils/testing/parsers/batch-log-parser.test.js +494 -1
  82. package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -1
  83. package/dist/utils/testing/parsers/batch-log-renderer.d.ts +79 -0
  84. package/dist/utils/testing/parsers/batch-log-renderer.d.ts.map +1 -0
  85. package/dist/utils/testing/parsers/batch-log-renderer.js +241 -0
  86. package/dist/utils/testing/parsers/batch-log-renderer.js.map +1 -0
  87. package/dist/utils/testing/parsers/batch-log-renderer.test.d.ts +2 -0
  88. package/dist/utils/testing/parsers/batch-log-renderer.test.d.ts.map +1 -0
  89. package/dist/utils/testing/parsers/batch-log-renderer.test.js +202 -0
  90. package/dist/utils/testing/parsers/batch-log-renderer.test.js.map +1 -0
  91. package/dist/utils/testing/runner/combined-project-generator.js +1 -1
  92. package/dist/utils/testing/runner/combined-project-generator.js.map +1 -1
  93. package/dist/utils/testing/runner/test-runner.d.ts +24 -0
  94. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  95. package/dist/utils/testing/runner/test-runner.js +166 -6
  96. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  97. package/dist/utils/testing/runner/test-runner.test.js +228 -2
  98. package/dist/utils/testing/runner/test-runner.test.js.map +1 -1
  99. package/dist/utils/testing/structured-test-results.d.ts +77 -0
  100. package/dist/utils/testing/structured-test-results.d.ts.map +1 -0
  101. package/dist/utils/testing/structured-test-results.js +140 -0
  102. package/dist/utils/testing/structured-test-results.js.map +1 -0
  103. package/dist/utils/testing/structured-test-results.test.d.ts +7 -0
  104. package/dist/utils/testing/structured-test-results.test.d.ts.map +1 -0
  105. package/dist/utils/testing/structured-test-results.test.js +179 -0
  106. package/dist/utils/testing/structured-test-results.test.js.map +1 -0
  107. package/package.json +6 -6
  108. package/src/commands/batch-command/batch-deploy-command.ts +4 -4
  109. package/src/commands/batch-command/batch-test-command.ts +94 -19
  110. package/src/commands/deploy-command/deploy-init-utils.ts +7 -1
  111. package/src/commands/deploy-command/index.ts +1 -1
  112. package/src/utils/deploy/deploy-github-columns.ts +14 -4
  113. package/src/utils/deploy/deploy-metadata.ts +1 -1
  114. package/src/utils/job-context/base-job-context.ts +3 -2
  115. package/src/utils/job-context/batch-script-job-context.ts +98 -17
  116. package/src/utils/job-context/cloud-job-context.test.ts +112 -0
  117. package/src/utils/job-context/cloud-job-context.ts +12 -6
  118. package/src/utils/job-context/job-context.ts +71 -2
  119. package/src/utils/job-context/local-job-context.test.ts +89 -0
  120. package/src/utils/job-context/local-job-context.ts +7 -3
  121. package/src/utils/open-cloud/open-cloud-client.test.ts +155 -1
  122. package/src/utils/open-cloud/open-cloud-client.ts +94 -18
  123. package/src/utils/open-cloud/open-cloud-logs.characterization.test.ts +277 -0
  124. package/src/utils/package-manager-utils.test.ts +27 -0
  125. package/src/utils/package-manager-utils.ts +34 -1
  126. package/src/utils/testing/batch-run.e2e.test.ts +425 -0
  127. package/src/utils/testing/batch-test-runner-template.test.ts +62 -0
  128. package/src/utils/testing/log-fetch-stats.test.ts +48 -0
  129. package/src/utils/testing/log-fetch-stats.ts +55 -0
  130. package/src/utils/testing/parsers/batch-log-parser.test.ts +660 -1
  131. package/src/utils/testing/parsers/batch-log-parser.ts +721 -116
  132. package/src/utils/testing/parsers/batch-log-renderer.test.ts +330 -0
  133. package/src/utils/testing/parsers/batch-log-renderer.ts +329 -0
  134. package/src/utils/testing/runner/combined-project-generator.ts +1 -1
  135. package/src/utils/testing/runner/test-runner.test.ts +332 -2
  136. package/src/utils/testing/runner/test-runner.ts +231 -9
  137. package/src/utils/testing/structured-test-results.test.ts +267 -0
  138. package/src/utils/testing/structured-test-results.ts +199 -0
  139. package/templates/batch-test-runner.lua +242 -0
  140. package/tsconfig.tsbuildinfo +1 -1
  141. package/templates/batch-test-runner.luau +0 -137
  142. /package/build-scripts/{smoke-test-server.luau → smoke-test-server.lua} +0 -0
  143. /package/build-scripts/{transform-rojo-merge-place.luau → transform-rojo-merge-place.lua} +0 -0
@@ -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> {
@@ -1,4 +1,24 @@
1
1
  import { type BuildPlaceOptions, type BuiltPlace } from '../build/build.js';
2
+ import { type StructuredTestResults } from '../testing/structured-test-results.js';
3
+ import { type LogFetchStats } from '../testing/log-fetch-stats.js';
4
+ import { type TaskLogMessage } from '../open-cloud/open-cloud-client.js';
5
+
6
+ /** A run's output, with the severity and the fetch that produced it. */
7
+ export interface JobLogs {
8
+ text: string;
9
+ /**
10
+ * The same output split into typed messages, when the transport reports
11
+ * severity. Empty for one that does not — a local run reads its logs off the
12
+ * Studio bridge, which says only what was printed.
13
+ */
14
+ messages: TaskLogMessage[];
15
+ /**
16
+ * What collecting them took, when the transport can say. Absent rather than
17
+ * zeroed, so "no fetch to measure" cannot read as "a fetch that found
18
+ * nothing".
19
+ */
20
+ stats?: LogFetchStats;
21
+ }
2
22
 
3
23
  export type { BuiltPlace } from '../build/build.js';
4
24
 
@@ -28,6 +48,48 @@ export interface ScriptRunResult {
28
48
  taskState?: string;
29
49
  /** Error message from the execution backend, if any. */
30
50
  errorMessage?: string;
51
+ /**
52
+ * Everything the executed script returned, in order — the structured channel
53
+ * out of a run, as opposed to its printed output. Engine logs are truncated
54
+ * by Open Cloud on long runs, so anything a caller must read back exactly
55
+ * belongs here rather than in the log text.
56
+ *
57
+ * `undefined` means the transport never delivered a return channel: a cloud
58
+ * task that ended without an `output` (a FAILED task carries none, and an
59
+ * oversize return value fails the task rather than truncating the value), a
60
+ * bridge run that timed out or disconnected, or a context that does not
61
+ * carry return values at all. That is deliberately distinct from `[]`, which
62
+ * means the script ran and returned nothing — a caller that needs the value
63
+ * can fall back to parsing logs in the first case but not the second.
64
+ *
65
+ * Values are JSON-shaped, but the two transports spell exotic Luau types
66
+ * differently: Open Cloud auto-serializes them, while the Studio bridge
67
+ * marshals them into `{ type, value }` wrappers (`SerializedReturnValue`).
68
+ * Plain tables of strings, numbers and booleans come back identically on
69
+ * both, so structured results should stay inside that subset.
70
+ */
71
+ returnValues?: unknown[];
72
+ /**
73
+ * The run's structured test results, when the context resolved them itself
74
+ * rather than leaving them in `returnValues` for the caller to decode.
75
+ *
76
+ * Only aggregated batch mode sets this: one execution covers every package, so
77
+ * its return value belongs to no single one of them and the per-package
78
+ * results have to be recovered from the batch summary first. A context that
79
+ * sets this has also reported where the counts came from, so the caller does
80
+ * not report it a second time.
81
+ */
82
+ testResults?: StructuredTestResults;
83
+ /**
84
+ * True when this run's verdict rests on returned results alone, its log
85
+ * output having been dropped before it reached us.
86
+ *
87
+ * A narrower verdict than an ordinary one: the counts are exact, but with no
88
+ * text there is nothing to check for tracebacks and nothing to read when
89
+ * something did go wrong. Reported so it can be shown as such rather than as
90
+ * an ordinary pass.
91
+ */
92
+ logsLost?: boolean;
31
93
  }
32
94
 
33
95
  /**
@@ -50,8 +112,15 @@ export interface JobContext {
50
112
  options: RunScriptOptions
51
113
  ): Promise<ScriptRunResult>;
52
114
 
53
- /** Retrieve raw logs from the most recent script execution on this deployment. */
54
- getLogsAsync(deployment: Deployment): Promise<string>;
115
+ /**
116
+ * Everything the most recent script execution printed.
117
+ *
118
+ * One value rather than a log plus two optional side-channels for its
119
+ * severity and its fetch: they all come from the same fetch, so returning
120
+ * them together removes both the chance of asking a context that does not
121
+ * implement one and the chance of handing an accessor the wrong handle.
122
+ */
123
+ getLogsAsync(deployment: Deployment): Promise<JobLogs>;
55
124
 
56
125
  /** Release a single deployment (stop bridge / clear task metadata). */
57
126
  releaseAsync(deployment: Deployment): Promise<void>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Unit tests for LocalJobContext.runScriptAsync — validates that a bridge run
3
+ * hands its script return values on as a ScriptRunResult, and that a run which
4
+ * never completed reports none rather than an empty result.
5
+ */
6
+
7
+ import { describe, it, expect, vi } from 'vitest';
8
+ import { type Reporter } from '@quenty/cli-output-helpers/reporting';
9
+ import { LocalJobContext } from './local-job-context.js';
10
+ import { type Deployment } from './job-context.js';
11
+
12
+ function createReporter(): Reporter {
13
+ return {
14
+ onPackagePhaseChange: vi.fn(),
15
+ onPackageProgressUpdate: vi.fn(),
16
+ onPackageStart: vi.fn(),
17
+ onPackageResult: vi.fn(),
18
+ } as unknown as Reporter;
19
+ }
20
+
21
+ /**
22
+ * The deployment handle is private to the context, so the test stands in a
23
+ * bridge with the one method runScriptAsync calls on it.
24
+ */
25
+ function createDeployment(
26
+ executeAsync: () => Promise<{
27
+ success: boolean;
28
+ logs: string;
29
+ returnValues?: unknown[];
30
+ }>
31
+ ): Deployment {
32
+ return {
33
+ bridge: { executeAsync },
34
+ cachedLogs: '',
35
+ } as unknown as Deployment;
36
+ }
37
+
38
+ describe('LocalJobContext.runScriptAsync', () => {
39
+ it('reports the values the script returned', async () => {
40
+ const context = new LocalJobContext(createReporter());
41
+ const deployment = createDeployment(async () => ({
42
+ success: true,
43
+ logs: 'ran',
44
+ returnValues: [{ counts: { passed: 7 } }],
45
+ }));
46
+
47
+ const result = await context.runScriptAsync(deployment, {
48
+ scriptContent: 'return results',
49
+ packageName: 'maid',
50
+ });
51
+
52
+ expect(result.success).toBe(true);
53
+ expect(result.returnValues).toEqual([{ counts: { passed: 7 } }]);
54
+ expect((await context.getLogsAsync(deployment)).text).toBe('ran');
55
+ });
56
+
57
+ it('leaves returnValues absent when the bridge reported none', async () => {
58
+ const context = new LocalJobContext(createReporter());
59
+ const deployment = createDeployment(async () => ({
60
+ success: false,
61
+ logs: '[StudioBridge] Timed out after 200ms',
62
+ }));
63
+
64
+ const result = await context.runScriptAsync(deployment, {
65
+ scriptContent: 'while true do end',
66
+ packageName: 'maid',
67
+ });
68
+
69
+ expect(result.returnValues).toBeUndefined();
70
+ });
71
+
72
+ it('leaves returnValues absent when the bridge throws', async () => {
73
+ const context = new LocalJobContext(createReporter());
74
+ const deployment = createDeployment(async () => {
75
+ throw new Error('no connected client');
76
+ });
77
+
78
+ const result = await context.runScriptAsync(deployment, {
79
+ scriptContent: 'return results',
80
+ packageName: 'maid',
81
+ });
82
+
83
+ expect(result.success).toBe(false);
84
+ expect(result.returnValues).toBeUndefined();
85
+ expect((await context.getLogsAsync(deployment)).text).toContain(
86
+ 'no connected client'
87
+ );
88
+ });
89
+ });
@@ -6,6 +6,7 @@ import {
6
6
  type DeployPlaceOptions,
7
7
  type RunScriptOptions,
8
8
  type ScriptRunResult,
9
+ type JobLogs,
9
10
  } from './job-context.js';
10
11
  import { type BasePlaceResolver } from '@quenty/nevermore-deploy';
11
12
  import { BaseJobContext } from './base-job-context.js';
@@ -69,7 +70,7 @@ export class LocalJobContext extends BaseJobContext {
69
70
  timeoutMs,
70
71
  });
71
72
  localDeployment.cachedLogs = result.logs;
72
- return { success: result.success };
73
+ return { success: result.success, returnValues: result.returnValues };
73
74
  } catch (error) {
74
75
  const errorMessage =
75
76
  error instanceof Error ? error.message : String(error);
@@ -78,9 +79,12 @@ export class LocalJobContext extends BaseJobContext {
78
79
  }
79
80
  }
80
81
 
81
- async getLogsAsync(deployment: Deployment): Promise<string> {
82
+ async getLogsAsync(deployment: Deployment): Promise<JobLogs> {
82
83
  const localDeployment = deployment as LocalDeployment;
83
- return localDeployment.cachedLogs;
84
+
85
+ // No severity and no fetch: the bridge hands over text it already has.
86
+ // Both stay absent rather than being invented.
87
+ return { text: localDeployment.cachedLogs, messages: [] };
84
88
  }
85
89
 
86
90
  async releaseAsync(deployment: Deployment): Promise<void> {