@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.
- package/CHANGELOG.md +10 -0
- package/build-scripts/{combine-test-places.luau → combine-test-places.lua} +3 -3
- package/build-scripts/{transform-inject-deploy-metadata.luau → transform-inject-deploy-metadata.lua} +3 -3
- package/dist/commands/batch-command/batch-deploy-command.js +4 -4
- package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
- package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-test-command.js +76 -19
- package/dist/commands/batch-command/batch-test-command.js.map +1 -1
- package/dist/commands/deploy-command/deploy-init-utils.d.ts.map +1 -1
- package/dist/commands/deploy-command/deploy-init-utils.js +7 -1
- package/dist/commands/deploy-command/deploy-init-utils.js.map +1 -1
- package/dist/commands/deploy-command/index.js +1 -1
- package/dist/commands/deploy-command/index.js.map +1 -1
- package/dist/commands/init-command/init-game-command.js +2 -2
- package/dist/commands/init-command/init-game-command.js.map +1 -1
- package/dist/commands/install-package-command.d.ts +14 -0
- package/dist/commands/install-package-command.d.ts.map +1 -1
- package/dist/commands/install-package-command.js +65 -12
- package/dist/commands/install-package-command.js.map +1 -1
- package/dist/utils/deploy/deploy-github-columns.d.ts +12 -2
- package/dist/utils/deploy/deploy-github-columns.d.ts.map +1 -1
- package/dist/utils/deploy/deploy-github-columns.js +11 -4
- package/dist/utils/deploy/deploy-github-columns.js.map +1 -1
- package/dist/utils/deploy/deploy-metadata.js +1 -1
- package/dist/utils/deploy/deploy-metadata.js.map +1 -1
- package/dist/utils/job-context/base-job-context.d.ts +2 -2
- package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/base-job-context.js +1 -1
- package/dist/utils/job-context/base-job-context.js.map +1 -1
- package/dist/utils/job-context/batch-script-job-context.d.ts +37 -2
- package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/batch-script-job-context.js +59 -22
- package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
- package/dist/utils/job-context/cloud-job-context.d.ts +2 -2
- package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/cloud-job-context.js +10 -5
- package/dist/utils/job-context/cloud-job-context.js.map +1 -1
- package/dist/utils/job-context/cloud-job-context.test.d.ts +7 -0
- package/dist/utils/job-context/cloud-job-context.test.d.ts.map +1 -0
- package/dist/utils/job-context/cloud-job-context.test.js +91 -0
- package/dist/utils/job-context/cloud-job-context.test.js.map +1 -0
- package/dist/utils/job-context/job-context.d.ts +70 -2
- package/dist/utils/job-context/job-context.d.ts.map +1 -1
- package/dist/utils/job-context/local-job-context.d.ts +2 -2
- package/dist/utils/job-context/local-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/local-job-context.js +4 -2
- package/dist/utils/job-context/local-job-context.js.map +1 -1
- package/dist/utils/job-context/local-job-context.test.d.ts +7 -0
- package/dist/utils/job-context/local-job-context.test.d.ts.map +1 -0
- package/dist/utils/job-context/local-job-context.test.js +68 -0
- package/dist/utils/job-context/local-job-context.test.js.map +1 -0
- package/dist/utils/open-cloud/open-cloud-client.d.ts +50 -7
- package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.js +49 -13
- package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.test.js +125 -1
- package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-logs.characterization.test.d.ts +22 -0
- package/dist/utils/open-cloud/open-cloud-logs.characterization.test.d.ts.map +1 -0
- package/dist/utils/open-cloud/open-cloud-logs.characterization.test.js +202 -0
- package/dist/utils/open-cloud/open-cloud-logs.characterization.test.js.map +1 -0
- package/dist/utils/package-manager-utils.d.ts +29 -0
- package/dist/utils/package-manager-utils.d.ts.map +1 -0
- package/dist/utils/package-manager-utils.js +120 -0
- package/dist/utils/package-manager-utils.js.map +1 -0
- package/dist/utils/package-manager-utils.test.d.ts +2 -0
- package/dist/utils/package-manager-utils.test.d.ts.map +1 -0
- package/dist/utils/package-manager-utils.test.js +121 -0
- package/dist/utils/package-manager-utils.test.js.map +1 -0
- package/dist/utils/testing/batch-run.e2e.test.d.ts +17 -0
- package/dist/utils/testing/batch-run.e2e.test.d.ts.map +1 -0
- package/dist/utils/testing/batch-run.e2e.test.js +343 -0
- package/dist/utils/testing/batch-run.e2e.test.js.map +1 -0
- package/dist/utils/testing/batch-test-runner-template.test.d.ts +11 -0
- package/dist/utils/testing/batch-test-runner-template.test.d.ts.map +1 -0
- package/dist/utils/testing/batch-test-runner-template.test.js +38 -0
- package/dist/utils/testing/batch-test-runner-template.test.js.map +1 -0
- package/dist/utils/testing/log-fetch-stats.d.ts +40 -0
- package/dist/utils/testing/log-fetch-stats.d.ts.map +1 -0
- package/dist/utils/testing/log-fetch-stats.js +18 -0
- package/dist/utils/testing/log-fetch-stats.js.map +1 -0
- package/dist/utils/testing/log-fetch-stats.test.d.ts +2 -0
- package/dist/utils/testing/log-fetch-stats.test.d.ts.map +1 -0
- package/dist/utils/testing/log-fetch-stats.test.js +41 -0
- package/dist/utils/testing/log-fetch-stats.test.js.map +1 -0
- package/dist/utils/testing/parsers/batch-log-parser.d.ts +126 -1
- package/dist/utils/testing/parsers/batch-log-parser.d.ts.map +1 -1
- package/dist/utils/testing/parsers/batch-log-parser.js +440 -103
- package/dist/utils/testing/parsers/batch-log-parser.js.map +1 -1
- package/dist/utils/testing/parsers/batch-log-parser.test.js +494 -1
- package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -1
- package/dist/utils/testing/parsers/batch-log-renderer.d.ts +79 -0
- package/dist/utils/testing/parsers/batch-log-renderer.d.ts.map +1 -0
- package/dist/utils/testing/parsers/batch-log-renderer.js +241 -0
- package/dist/utils/testing/parsers/batch-log-renderer.js.map +1 -0
- package/dist/utils/testing/parsers/batch-log-renderer.test.d.ts +2 -0
- package/dist/utils/testing/parsers/batch-log-renderer.test.d.ts.map +1 -0
- package/dist/utils/testing/parsers/batch-log-renderer.test.js +202 -0
- package/dist/utils/testing/parsers/batch-log-renderer.test.js.map +1 -0
- package/dist/utils/testing/runner/combined-project-generator.js +1 -1
- package/dist/utils/testing/runner/combined-project-generator.js.map +1 -1
- package/dist/utils/testing/runner/test-runner.d.ts +24 -0
- package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
- package/dist/utils/testing/runner/test-runner.js +166 -6
- package/dist/utils/testing/runner/test-runner.js.map +1 -1
- package/dist/utils/testing/runner/test-runner.test.js +228 -2
- package/dist/utils/testing/runner/test-runner.test.js.map +1 -1
- package/dist/utils/testing/structured-test-results.d.ts +77 -0
- package/dist/utils/testing/structured-test-results.d.ts.map +1 -0
- package/dist/utils/testing/structured-test-results.js +140 -0
- package/dist/utils/testing/structured-test-results.js.map +1 -0
- package/dist/utils/testing/structured-test-results.test.d.ts +7 -0
- package/dist/utils/testing/structured-test-results.test.d.ts.map +1 -0
- package/dist/utils/testing/structured-test-results.test.js +179 -0
- package/dist/utils/testing/structured-test-results.test.js.map +1 -0
- package/package.json +6 -6
- package/src/commands/batch-command/batch-deploy-command.ts +4 -4
- package/src/commands/batch-command/batch-test-command.ts +94 -19
- package/src/commands/deploy-command/deploy-init-utils.ts +7 -1
- package/src/commands/deploy-command/index.ts +1 -1
- package/src/commands/init-command/init-game-command.ts +2 -2
- package/src/commands/install-package-command.ts +107 -14
- package/src/utils/deploy/deploy-github-columns.ts +14 -4
- package/src/utils/deploy/deploy-metadata.ts +1 -1
- package/src/utils/job-context/base-job-context.ts +3 -2
- package/src/utils/job-context/batch-script-job-context.ts +98 -17
- package/src/utils/job-context/cloud-job-context.test.ts +112 -0
- package/src/utils/job-context/cloud-job-context.ts +12 -6
- package/src/utils/job-context/job-context.ts +71 -2
- package/src/utils/job-context/local-job-context.test.ts +89 -0
- package/src/utils/job-context/local-job-context.ts +7 -3
- package/src/utils/open-cloud/open-cloud-client.test.ts +155 -1
- package/src/utils/open-cloud/open-cloud-client.ts +94 -18
- package/src/utils/open-cloud/open-cloud-logs.characterization.test.ts +277 -0
- package/src/utils/package-manager-utils.test.ts +174 -0
- package/src/utils/package-manager-utils.ts +148 -0
- package/src/utils/testing/batch-run.e2e.test.ts +425 -0
- package/src/utils/testing/batch-test-runner-template.test.ts +62 -0
- package/src/utils/testing/log-fetch-stats.test.ts +48 -0
- package/src/utils/testing/log-fetch-stats.ts +55 -0
- package/src/utils/testing/parsers/batch-log-parser.test.ts +660 -1
- package/src/utils/testing/parsers/batch-log-parser.ts +721 -116
- package/src/utils/testing/parsers/batch-log-renderer.test.ts +330 -0
- package/src/utils/testing/parsers/batch-log-renderer.ts +329 -0
- package/src/utils/testing/runner/combined-project-generator.ts +1 -1
- package/src/utils/testing/runner/test-runner.test.ts +332 -2
- package/src/utils/testing/runner/test-runner.ts +231 -9
- package/src/utils/testing/structured-test-results.test.ts +267 -0
- package/src/utils/testing/structured-test-results.ts +199 -0
- package/templates/batch-test-runner.lua +242 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/templates/batch-test-runner.luau +0 -137
- /package/build-scripts/{smoke-test-server.luau → smoke-test-server.lua} +0 -0
- /package/build-scripts/{transform-rojo-merge-place.luau → transform-rojo-merge-place.lua} +0 -0
|
@@ -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
|
-
/**
|
|
54
|
-
|
|
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<
|
|
82
|
+
async getLogsAsync(deployment: Deployment): Promise<JobLogs> {
|
|
82
83
|
const localDeployment = deployment as LocalDeployment;
|
|
83
|
-
|
|
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> {
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import * as fs from 'fs/promises';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
OpenCloudClient,
|
|
5
|
+
getTaskReturnValues,
|
|
6
|
+
type LuauTask,
|
|
7
|
+
} from './open-cloud-client.js';
|
|
4
8
|
import type { RateLimiter } from './rate-limiter.js';
|
|
5
9
|
|
|
6
10
|
vi.mock('@quenty/cli-output-helpers', () => ({
|
|
@@ -367,3 +371,153 @@ describe('OpenCloudClient.resolveLatestPlaceVersionAsync', () => {
|
|
|
367
371
|
).rejects.toThrowError(/unparseable version path/);
|
|
368
372
|
});
|
|
369
373
|
});
|
|
374
|
+
|
|
375
|
+
describe('getTaskReturnValues', () => {
|
|
376
|
+
function makeTask(overrides: Partial<LuauTask>): LuauTask {
|
|
377
|
+
return {
|
|
378
|
+
path: 'universes/1/places/2/versions/3/luau-execution-session-tasks/4',
|
|
379
|
+
createTime: '2026-01-01T00:00:00Z',
|
|
380
|
+
updateTime: '2026-01-01T00:01:00Z',
|
|
381
|
+
user: 'users/1',
|
|
382
|
+
state: 'COMPLETE',
|
|
383
|
+
script: 'return 1',
|
|
384
|
+
...overrides,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
it('returns the values natively typed, one entry per returned value', () => {
|
|
389
|
+
// Roblox serializes the return value itself, so a returned table arrives as
|
|
390
|
+
// real nested JSON — there is no JSON string to parse a second time.
|
|
391
|
+
const task = makeTask({
|
|
392
|
+
output: {
|
|
393
|
+
results: [{ slug: 'maid', counts: { passed: 1014 } }, 'str', 42, true],
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
expect(getTaskReturnValues(task)).toEqual([
|
|
398
|
+
{ slug: 'maid', counts: { passed: 1014 } },
|
|
399
|
+
'str',
|
|
400
|
+
42,
|
|
401
|
+
true,
|
|
402
|
+
]);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('reports an empty result when the task returned nothing', () => {
|
|
406
|
+
expect(getTaskReturnValues(makeTask({ output: {} }))).toEqual([]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('reports undefined when a failed task carried no output at all', () => {
|
|
410
|
+
// An oversize return value fails the task with no output and no error
|
|
411
|
+
// message, so "nothing came back to read" has to stay distinguishable from
|
|
412
|
+
// "the script returned nothing" — only the former can fall back to logs.
|
|
413
|
+
const task = makeTask({ state: 'FAILED', output: undefined });
|
|
414
|
+
|
|
415
|
+
expect(getTaskReturnValues(task)).toBeUndefined();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe('OpenCloudClient.getRawTaskLogsAsync', () => {
|
|
420
|
+
afterEach(() => {
|
|
421
|
+
vi.restoreAllMocks();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Page the logs endpoint the way the real one does: many messages inside each
|
|
426
|
+
* `luauExecutionSessionTaskLog`, and pagination over those entries rather than
|
|
427
|
+
* over messages.
|
|
428
|
+
*/
|
|
429
|
+
function makeLogsLimiter(
|
|
430
|
+
pages: Array<Array<string[]>>,
|
|
431
|
+
urls: string[] = []
|
|
432
|
+
): RateLimiter {
|
|
433
|
+
return {
|
|
434
|
+
fetchAsync: vi.fn(async (url: string | URL) => {
|
|
435
|
+
urls.push(String(url));
|
|
436
|
+
const page = pages[urls.length - 1] ?? [];
|
|
437
|
+
return new Response(
|
|
438
|
+
JSON.stringify({
|
|
439
|
+
luauExecutionSessionTaskLogs: page.map((messages) => ({
|
|
440
|
+
structuredMessages: messages.map((message) => ({
|
|
441
|
+
message,
|
|
442
|
+
createTime: '2026-08-27T00:00:00.38Z',
|
|
443
|
+
messageType: 'OUTPUT',
|
|
444
|
+
})),
|
|
445
|
+
})),
|
|
446
|
+
...(urls.length < pages.length
|
|
447
|
+
? { nextPageToken: `token-${urls.length}` }
|
|
448
|
+
: {}),
|
|
449
|
+
}),
|
|
450
|
+
{ status: 200 }
|
|
451
|
+
);
|
|
452
|
+
}),
|
|
453
|
+
} as unknown as RateLimiter;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
it('counts pages, entries and messages separately', async () => {
|
|
457
|
+
// Entries are not lines: the endpoint's page size applies to the log
|
|
458
|
+
// objects, each of which carries many messages. Conflating the two reads a
|
|
459
|
+
// 2-entry response as a 2-line run.
|
|
460
|
+
const client = new OpenCloudClient({
|
|
461
|
+
apiKey: 'test-key',
|
|
462
|
+
rateLimiter: makeLogsLimiter([[['a', 'b', 'c'], ['d']], [['e', 'f']]]),
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const fetched = await client.getRawTaskLogsAsync('universes/1/tasks/2');
|
|
466
|
+
|
|
467
|
+
expect(fetched.text).toBe('a\nb\nc\nd\ne\nf');
|
|
468
|
+
expect(fetched.stats).toEqual({
|
|
469
|
+
requests: 2,
|
|
470
|
+
pages: 2,
|
|
471
|
+
entries: 3,
|
|
472
|
+
messages: 6,
|
|
473
|
+
chars: 11,
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('counts every attempt when empty logs were retried', async () => {
|
|
478
|
+
// The retry is invisible in the text it eventually returns, so a run whose
|
|
479
|
+
// logs only showed up on the third ask looks exactly like one that answered
|
|
480
|
+
// immediately. It is the difference between a slow API and a silent run.
|
|
481
|
+
vi.spyOn(global, 'setTimeout').mockImplementation((fn: () => void) => {
|
|
482
|
+
fn();
|
|
483
|
+
return 0 as unknown as NodeJS.Timeout;
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// One unpaged response per attempt: two empty, then the logs. A limiter
|
|
487
|
+
// that pages would fold all three into a single attempt and prove nothing.
|
|
488
|
+
let attempt = 0;
|
|
489
|
+
const rateLimiter = {
|
|
490
|
+
fetchAsync: vi.fn(async () => {
|
|
491
|
+
attempt++;
|
|
492
|
+
return new Response(
|
|
493
|
+
JSON.stringify({
|
|
494
|
+
luauExecutionSessionTaskLogs:
|
|
495
|
+
attempt < 3
|
|
496
|
+
? []
|
|
497
|
+
: [
|
|
498
|
+
{
|
|
499
|
+
structuredMessages: [
|
|
500
|
+
{
|
|
501
|
+
message: 'late',
|
|
502
|
+
createTime: '2026-08-27T00:00:00.38Z',
|
|
503
|
+
messageType: 'OUTPUT',
|
|
504
|
+
},
|
|
505
|
+
],
|
|
506
|
+
},
|
|
507
|
+
],
|
|
508
|
+
}),
|
|
509
|
+
{ status: 200 }
|
|
510
|
+
);
|
|
511
|
+
}),
|
|
512
|
+
} as unknown as RateLimiter;
|
|
513
|
+
|
|
514
|
+
const client = new OpenCloudClient({ apiKey: 'test-key', rateLimiter });
|
|
515
|
+
|
|
516
|
+
const fetched = await client.getRawTaskLogsAsync('universes/1/tasks/2');
|
|
517
|
+
|
|
518
|
+
expect(fetched.text).toBe('late');
|
|
519
|
+
expect(fetched.stats.requests).toBe(3);
|
|
520
|
+
expect(fetched.stats.pages).toBe(1);
|
|
521
|
+
expect(fetched.stats.messages).toBe(1);
|
|
522
|
+
});
|
|
523
|
+
});
|
|
@@ -7,6 +7,30 @@ import {
|
|
|
7
7
|
parseTestLogs,
|
|
8
8
|
type ParsedTestLogs,
|
|
9
9
|
} from '../testing/test-log-parser.js';
|
|
10
|
+
import { type LogFetchStats } from '../testing/log-fetch-stats.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One engine message, with the severity Open Cloud reported for it.
|
|
14
|
+
*
|
|
15
|
+
* A message is not a line: an error arrives with its whole traceback attached,
|
|
16
|
+
* so `message` can span several. `messageType` is OUTPUT, WARNING or ERROR.
|
|
17
|
+
*/
|
|
18
|
+
export interface TaskLogMessage {
|
|
19
|
+
message: string;
|
|
20
|
+
messageType?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A task's log text together with what the fetch had to do to collect it. */
|
|
24
|
+
export interface TaskLogFetch {
|
|
25
|
+
text: string;
|
|
26
|
+
stats: LogFetchStats;
|
|
27
|
+
/**
|
|
28
|
+
* The same output as `text`, still split into typed messages. Kept because
|
|
29
|
+
* severity is the API's to state and cannot be recovered from the joined
|
|
30
|
+
* text: a traceback's continuation lines look like ordinary output.
|
|
31
|
+
*/
|
|
32
|
+
messages: TaskLogMessage[];
|
|
33
|
+
}
|
|
10
34
|
|
|
11
35
|
export interface LuauTask {
|
|
12
36
|
path: string;
|
|
@@ -22,12 +46,38 @@ export interface LuauTask {
|
|
|
22
46
|
| 'FAILED';
|
|
23
47
|
script: string;
|
|
24
48
|
timeout?: string;
|
|
25
|
-
/**
|
|
26
|
-
|
|
49
|
+
/**
|
|
50
|
+
* What the script returned, populated on COMPLETE. `results` is a flat array
|
|
51
|
+
* of the returned values — `return t, "s", 42` arrives as three entries —
|
|
52
|
+
* natively typed: Roblox serializes them itself, so a returned Lua table is
|
|
53
|
+
* real nested JSON here, not a JSON string. (Which is also why a script must
|
|
54
|
+
* not JSONEncode its result: that lands as a double-encoded string.)
|
|
55
|
+
*
|
|
56
|
+
* Absent whenever the task produced no result. A FAILED task carries no
|
|
57
|
+
* `output` at all, with no error message explaining why — and an oversize
|
|
58
|
+
* return value (~4MB observed; ~2MB still arrives complete) fails the task
|
|
59
|
+
* exactly that way rather than truncating the value.
|
|
60
|
+
*/
|
|
61
|
+
output?: { results?: unknown[] };
|
|
27
62
|
/** Error details (populated on FAILED). */
|
|
28
63
|
error?: { code?: string; message?: string };
|
|
29
64
|
}
|
|
30
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The values a finished task's script returned, or `undefined` when the task
|
|
68
|
+
* reported no result at all.
|
|
69
|
+
*
|
|
70
|
+
* The distinction matters: `undefined` means nothing came back to read, so a
|
|
71
|
+
* caller can fall back to the task's logs, while `[]` means the task did report
|
|
72
|
+
* a result and the script returned nothing — no fallback will find more.
|
|
73
|
+
*/
|
|
74
|
+
export function getTaskReturnValues(task: LuauTask): unknown[] | undefined {
|
|
75
|
+
if (!task.output) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return task.output.results ?? [];
|
|
79
|
+
}
|
|
80
|
+
|
|
31
81
|
export interface OpenCloudClientOptions {
|
|
32
82
|
apiKey: string | (() => Promise<string>);
|
|
33
83
|
rateLimiter: RateLimiter;
|
|
@@ -316,22 +366,32 @@ export class OpenCloudClient {
|
|
|
316
366
|
|
|
317
367
|
async getTaskLogsAsync(taskPath: string): Promise<ParsedTestLogs> {
|
|
318
368
|
const raw = await this.getRawTaskLogsAsync(taskPath);
|
|
319
|
-
return parseTestLogs(raw);
|
|
369
|
+
return parseTestLogs(raw.text);
|
|
320
370
|
}
|
|
321
371
|
|
|
322
372
|
/**
|
|
323
|
-
* Fetch raw log text from a completed Luau execution task
|
|
373
|
+
* Fetch raw log text from a completed Luau execution task, along with the
|
|
374
|
+
* shape of the fetch that produced it.
|
|
375
|
+
*
|
|
324
376
|
* Retries a few times if the API returns empty logs, since the test runner
|
|
325
|
-
* always produces at least some output.
|
|
377
|
+
* always produces at least some output. The reported `requests` count spans
|
|
378
|
+
* every attempt, so a fetch that only filled in on its third try says so.
|
|
326
379
|
*/
|
|
327
|
-
async getRawTaskLogsAsync(taskPath: string): Promise<
|
|
380
|
+
async getRawTaskLogsAsync(taskPath: string): Promise<TaskLogFetch> {
|
|
328
381
|
const maxAttempts = 3;
|
|
329
382
|
const retryDelayMs = 1000;
|
|
383
|
+
let requests = 0;
|
|
330
384
|
|
|
331
385
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
386
|
+
const fetched = await this._fetchRawLogsAsync(taskPath);
|
|
387
|
+
requests += fetched.stats.requests;
|
|
388
|
+
|
|
389
|
+
if (fetched.text || attempt === maxAttempts) {
|
|
390
|
+
return {
|
|
391
|
+
text: fetched.text,
|
|
392
|
+
stats: { ...fetched.stats, requests },
|
|
393
|
+
messages: fetched.messages,
|
|
394
|
+
};
|
|
335
395
|
}
|
|
336
396
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
337
397
|
}
|
|
@@ -340,12 +400,12 @@ export class OpenCloudClient {
|
|
|
340
400
|
return this._fetchRawLogsAsync(taskPath);
|
|
341
401
|
}
|
|
342
402
|
|
|
343
|
-
private async _fetchRawLogsAsync(taskPath: string): Promise<
|
|
403
|
+
private async _fetchRawLogsAsync(taskPath: string): Promise<TaskLogFetch> {
|
|
344
404
|
const apiKey = await this._resolveApiKeyAsync();
|
|
345
405
|
// Arrival order, deliberately. Do not sort by createTime: every print in a
|
|
346
406
|
// single frame shares one value, so it cannot order them anyway, and the
|
|
347
407
|
// fractional digit count varies, so comparing the strings reorders them.
|
|
348
|
-
const messages:
|
|
408
|
+
const messages: TaskLogMessage[] = [];
|
|
349
409
|
let pageToken: string | undefined;
|
|
350
410
|
let pagesFetched = 0;
|
|
351
411
|
let entriesFetched = 0;
|
|
@@ -383,9 +443,16 @@ export class OpenCloudClient {
|
|
|
383
443
|
|
|
384
444
|
for (const entry of data.luauExecutionSessionTaskLogs ?? []) {
|
|
385
445
|
if (entry.structuredMessages?.length) {
|
|
386
|
-
messages.push(
|
|
446
|
+
messages.push(
|
|
447
|
+
...entry.structuredMessages.map((msg) => ({
|
|
448
|
+
message: msg.message,
|
|
449
|
+
messageType: msg.messageType,
|
|
450
|
+
}))
|
|
451
|
+
);
|
|
387
452
|
} else if (entry.messages?.length) {
|
|
388
|
-
|
|
453
|
+
// The flat view carries no severity, which stays absent rather than
|
|
454
|
+
// being guessed at.
|
|
455
|
+
messages.push(...entry.messages.map((message) => ({ message })));
|
|
389
456
|
}
|
|
390
457
|
}
|
|
391
458
|
|
|
@@ -394,17 +461,26 @@ export class OpenCloudClient {
|
|
|
394
461
|
pageToken = data.nextPageToken || undefined;
|
|
395
462
|
} while (pageToken);
|
|
396
463
|
|
|
397
|
-
const text = messages.join('\n');
|
|
464
|
+
const text = messages.map((entry) => entry.message).join('\n');
|
|
465
|
+
|
|
466
|
+
const stats: LogFetchStats = {
|
|
467
|
+
requests: pagesFetched,
|
|
468
|
+
pages: pagesFetched,
|
|
469
|
+
entries: entriesFetched,
|
|
470
|
+
messages: messages.length,
|
|
471
|
+
chars: text.length,
|
|
472
|
+
};
|
|
398
473
|
|
|
399
474
|
// Log volume is the prime suspect when engine output goes missing between
|
|
400
475
|
// the task and the parser, and the parser cannot tell a short run from a
|
|
401
|
-
// truncated fetch.
|
|
476
|
+
// truncated fetch. Handed out with the text rather than only printed, so
|
|
477
|
+
// the diagnostic that finds the output missing can say what the fetch saw.
|
|
402
478
|
OutputHelper.verbose(
|
|
403
|
-
`[open-cloud] Task logs: ${
|
|
404
|
-
`${messages
|
|
479
|
+
`[open-cloud] Task logs: ${stats.pages} page(s), ${stats.entries} entries, ` +
|
|
480
|
+
`${stats.messages} messages, ${stats.chars} chars`
|
|
405
481
|
);
|
|
406
482
|
|
|
407
|
-
return text;
|
|
483
|
+
return { text, stats, messages };
|
|
408
484
|
}
|
|
409
485
|
|
|
410
486
|
/**
|