@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.
- package/CHANGELOG.md +4 -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/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.map +1 -1
- package/dist/utils/package-manager-utils.js +32 -1
- package/dist/utils/package-manager-utils.js.map +1 -1
- package/dist/utils/package-manager-utils.test.js +25 -0
- package/dist/utils/package-manager-utils.test.js.map +1 -1
- 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/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 +27 -0
- package/src/utils/package-manager-utils.ts +34 -1
- 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,6 +1,290 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
afterAll,
|
|
6
|
+
afterEach,
|
|
7
|
+
beforeAll,
|
|
8
|
+
describe,
|
|
9
|
+
expect,
|
|
10
|
+
it,
|
|
11
|
+
vi,
|
|
12
|
+
} from 'vitest';
|
|
13
|
+
import { type DeployTarget } from '@quenty/nevermore-deploy';
|
|
14
|
+
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
2
15
|
|
|
3
|
-
import { mergeFailureReasons } from './test-runner.js';
|
|
16
|
+
import { mergeFailureReasons, runSingleTestAsync } from './test-runner.js';
|
|
17
|
+
import { TEST_RESULTS_FORMAT } from '../structured-test-results.js';
|
|
18
|
+
import {
|
|
19
|
+
type Deployment,
|
|
20
|
+
type JobContext,
|
|
21
|
+
type ScriptRunResult,
|
|
22
|
+
} from '../../job-context/job-context.js';
|
|
23
|
+
|
|
24
|
+
const PASSING_LOGS = [
|
|
25
|
+
'Test Suites: 3 passed, 3 total',
|
|
26
|
+
'Tests: 25 passed, 25 total',
|
|
27
|
+
].join('\n');
|
|
28
|
+
|
|
29
|
+
function structuredResults(overrides: Record<string, unknown> = {}) {
|
|
30
|
+
return {
|
|
31
|
+
format: TEST_RESULTS_FORMAT,
|
|
32
|
+
success: true,
|
|
33
|
+
ranJest: true,
|
|
34
|
+
passed: 25,
|
|
35
|
+
failed: 0,
|
|
36
|
+
skipped: 0,
|
|
37
|
+
total: 25,
|
|
38
|
+
suitesPassed: 3,
|
|
39
|
+
suitesFailed: 0,
|
|
40
|
+
suitesTotal: 3,
|
|
41
|
+
failures: [],
|
|
42
|
+
omittedFailures: 0,
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Collect what the runner said out loud, so silence can be asserted on. */
|
|
48
|
+
function captureWarnings(): () => string[] {
|
|
49
|
+
const warnings: string[] = [];
|
|
50
|
+
vi.spyOn(OutputHelper, 'warn').mockImplementation((message: string) => {
|
|
51
|
+
warnings.push(message);
|
|
52
|
+
});
|
|
53
|
+
vi.spyOn(OutputHelper, 'info').mockImplementation(() => {});
|
|
54
|
+
return () => warnings;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
vi.restoreAllMocks();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A context that runs nothing: it hands back the run result and logs the test
|
|
63
|
+
* wants to reason about. Everything else is the minimum the runner touches.
|
|
64
|
+
*/
|
|
65
|
+
function createContext(run: ScriptRunResult, logs: string): JobContext {
|
|
66
|
+
return {
|
|
67
|
+
buildPlaceAsync: async (options) => ({
|
|
68
|
+
rbxlPath: 'unused.rbxl',
|
|
69
|
+
target: options.target,
|
|
70
|
+
}),
|
|
71
|
+
deployBuiltPlaceAsync: async () => ({} as Deployment),
|
|
72
|
+
runScriptAsync: async () => run,
|
|
73
|
+
getLogsAsync: async () => ({ text: logs, messages: [] }),
|
|
74
|
+
releaseAsync: async () => {},
|
|
75
|
+
releaseBuiltPlaceAsync: async () => {},
|
|
76
|
+
disposeAsync: async () => {},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('runSingleTestAsync', () => {
|
|
81
|
+
let packagePath: string;
|
|
82
|
+
|
|
83
|
+
beforeAll(async () => {
|
|
84
|
+
packagePath = await fs.mkdtemp(path.join(os.tmpdir(), 'nevermore-test-'));
|
|
85
|
+
await fs.writeFile(
|
|
86
|
+
path.join(packagePath, 'ServerMain.server.lua'),
|
|
87
|
+
'return nil\n'
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterAll(async () => {
|
|
92
|
+
await fs.rm(packagePath, { recursive: true, force: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
async function runAsync(run: ScriptRunResult, logs: string) {
|
|
96
|
+
return runSingleTestAsync(createContext(run, logs), {
|
|
97
|
+
packagePath,
|
|
98
|
+
packageName: 'maid',
|
|
99
|
+
target: {
|
|
100
|
+
scriptTemplate: 'ServerMain.server.lua',
|
|
101
|
+
} as unknown as DeployTarget,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
it('fails a run whose returned results say it failed', async () => {
|
|
106
|
+
// The runner used to announce this by throwing, which failed the task. Now
|
|
107
|
+
// it returns the verdict, and these logs are what a truncated window leaves
|
|
108
|
+
// behind: a jest report from a suite that passed and no sign of the one
|
|
109
|
+
// that did not.
|
|
110
|
+
const result = await runAsync(
|
|
111
|
+
{
|
|
112
|
+
success: true,
|
|
113
|
+
returnValues: [structuredResults({ success: false, failed: 2 })],
|
|
114
|
+
},
|
|
115
|
+
PASSING_LOGS
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(result.success).toBe(false);
|
|
119
|
+
expect(result.error).toContain('2 test(s) failed');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('reports the counts the run returned, not the ones in the log', async () => {
|
|
123
|
+
const result = await runAsync(
|
|
124
|
+
{
|
|
125
|
+
success: true,
|
|
126
|
+
returnValues: [structuredResults({ passed: 400, total: 400 })],
|
|
127
|
+
},
|
|
128
|
+
PASSING_LOGS
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(result.success).toBe(true);
|
|
132
|
+
expect(result.testCounts).toEqual({ passed: 400, failed: 0, total: 400 });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('accepts returned results as proof the runner ran', async () => {
|
|
136
|
+
// Demanding a jest report in the logs is a stand-in for that proof, and it
|
|
137
|
+
// is the first thing a truncated log window costs.
|
|
138
|
+
const result = await runAsync(
|
|
139
|
+
{ success: true, returnValues: [structuredResults()] },
|
|
140
|
+
'(logs truncated)'
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(result.success).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('still fails a run with a traceback in its logs', async () => {
|
|
147
|
+
// Jest cannot count a deferred-callback crash, so passing results are no
|
|
148
|
+
// reason to stop reading the logs.
|
|
149
|
+
const result = await runAsync(
|
|
150
|
+
{ success: true, returnValues: [structuredResults()] },
|
|
151
|
+
`${PASSING_LOGS}\nStack Begin\nScript 'maid.spec', Line 4\nStack End`
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
expect(result.success).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('falls back to the logs for a script that returned nothing', async () => {
|
|
158
|
+
// A test script written before results were returned. It must keep working,
|
|
159
|
+
// and it must not start passing when its logs say otherwise.
|
|
160
|
+
const failing = await runAsync(
|
|
161
|
+
{ success: true, returnValues: [] },
|
|
162
|
+
'Tests: 2 failed, 23 passed, 25 total'
|
|
163
|
+
);
|
|
164
|
+
expect(failing.success).toBe(false);
|
|
165
|
+
expect(failing.error).toContain('2 test(s) failed');
|
|
166
|
+
|
|
167
|
+
const passing = await runAsync(
|
|
168
|
+
{ success: true, returnValues: [] },
|
|
169
|
+
PASSING_LOGS
|
|
170
|
+
);
|
|
171
|
+
expect(passing.success).toBe(true);
|
|
172
|
+
expect(passing.testCounts).toEqual({ passed: 25, failed: 0, total: 25 });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('fails a script that returned nothing and logged nothing', async () => {
|
|
176
|
+
const result = await runAsync(
|
|
177
|
+
{ success: true, returnValues: undefined },
|
|
178
|
+
''
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(result.success).toBe(false);
|
|
182
|
+
expect(result.error).toContain('nothing proves any test ran');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('records where the counts came from', async () => {
|
|
186
|
+
const returned = await runAsync(
|
|
187
|
+
{ success: true, returnValues: [structuredResults()] },
|
|
188
|
+
PASSING_LOGS
|
|
189
|
+
);
|
|
190
|
+
expect(returned.countsSource).toBe('returned');
|
|
191
|
+
|
|
192
|
+
const scraped = await runAsync(
|
|
193
|
+
{ success: true, returnValues: [] },
|
|
194
|
+
PASSING_LOGS
|
|
195
|
+
);
|
|
196
|
+
expect(scraped.countsSource).toBe('scraped');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('warns out loud when the run returned no results', async () => {
|
|
200
|
+
// Silence here is what let the structured channel ship inert: a run that
|
|
201
|
+
// fell back to scraping produced output identical to one that did not.
|
|
202
|
+
const warnings = captureWarnings();
|
|
203
|
+
|
|
204
|
+
await runAsync({ success: true, returnValues: [] }, PASSING_LOGS);
|
|
205
|
+
|
|
206
|
+
expect(warnings().some((w) => w.includes('returned no test results'))).toBe(
|
|
207
|
+
true
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('fails a package with specs whose run never reached jest', async () => {
|
|
212
|
+
// A Rojo regression that stops shipping jest.config turns a real suite into
|
|
213
|
+
// a smoke test. Before results were returned, the required jest report caught
|
|
214
|
+
// that; a smoke-test result retires the report and would otherwise pass with
|
|
215
|
+
// zero tests.
|
|
216
|
+
await fs.mkdir(path.join(packagePath, 'src'), { recursive: true });
|
|
217
|
+
await fs.writeFile(
|
|
218
|
+
path.join(packagePath, 'src', 'jest.config.lua'),
|
|
219
|
+
'return {}\n'
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const result = await runAsync(
|
|
223
|
+
{
|
|
224
|
+
success: true,
|
|
225
|
+
returnValues: [
|
|
226
|
+
structuredResults({ ranJest: false, passed: 0, total: 0 }),
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
'[NevermoreTestRunner] No jest.config found — smoke test passed (boot success)'
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(result.success).toBe(false);
|
|
233
|
+
expect(result.error).toContain('none of its specs ran');
|
|
234
|
+
|
|
235
|
+
await fs.rm(path.join(packagePath, 'src'), {
|
|
236
|
+
recursive: true,
|
|
237
|
+
force: true,
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('passes a package with no specs that smoke tested', async () => {
|
|
242
|
+
// No jest.config on disk, so a smoke test is the whole contract.
|
|
243
|
+
const result = await runAsync(
|
|
244
|
+
{
|
|
245
|
+
success: true,
|
|
246
|
+
returnValues: [
|
|
247
|
+
structuredResults({ ranJest: false, passed: 0, total: 0 }),
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
'[NevermoreTestRunner] No jest.config found — smoke test passed (boot success)'
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
expect(result.success).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('takes results a context resolved for it, without re-reporting', async () => {
|
|
257
|
+
// Aggregated batch mode: one execution covers every package, so the batch
|
|
258
|
+
// log parser splits the per-package results out and has already reported
|
|
259
|
+
// provenance for the whole batch.
|
|
260
|
+
const warnings = captureWarnings();
|
|
261
|
+
|
|
262
|
+
const result = await runAsync(
|
|
263
|
+
{
|
|
264
|
+
success: true,
|
|
265
|
+
testResults: {
|
|
266
|
+
success: false,
|
|
267
|
+
ranJest: true,
|
|
268
|
+
passed: 273,
|
|
269
|
+
failed: 2,
|
|
270
|
+
skipped: 0,
|
|
271
|
+
total: 275,
|
|
272
|
+
suitesPassed: 8,
|
|
273
|
+
suitesFailed: 1,
|
|
274
|
+
suitesTotal: 9,
|
|
275
|
+
failures: [],
|
|
276
|
+
omittedFailures: 0,
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
'(this package’s section was truncated away)'
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
expect(result.success).toBe(false);
|
|
283
|
+
expect(result.countsSource).toBe('returned');
|
|
284
|
+
expect(result.testCounts).toEqual({ passed: 273, failed: 2, total: 275 });
|
|
285
|
+
expect(warnings()).toHaveLength(0);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
4
288
|
|
|
5
289
|
describe('mergeFailureReasons', () => {
|
|
6
290
|
it('does not repeat reasons the context already reported', () => {
|
|
@@ -27,3 +311,49 @@ describe('mergeFailureReasons', () => {
|
|
|
27
311
|
expect(mergeFailureReasons(undefined, [])).toEqual([]);
|
|
28
312
|
});
|
|
29
313
|
});
|
|
314
|
+
|
|
315
|
+
describe('runSingleTestAsync log volume reporting', () => {
|
|
316
|
+
let packagePath: string;
|
|
317
|
+
|
|
318
|
+
beforeAll(async () => {
|
|
319
|
+
packagePath = await fs.mkdtemp(path.join(os.tmpdir(), 'volume-'));
|
|
320
|
+
await fs.writeFile(
|
|
321
|
+
path.join(packagePath, 'ServerMain.server.lua'),
|
|
322
|
+
'return nil\n'
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
afterAll(async () => {
|
|
327
|
+
await fs.rm(packagePath, { recursive: true, force: true });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
function runAsync(logs: string) {
|
|
331
|
+
return runSingleTestAsync(createContext({ success: true }, logs), {
|
|
332
|
+
packagePath,
|
|
333
|
+
packageName: 'gameconfig',
|
|
334
|
+
target: {
|
|
335
|
+
scriptTemplate: 'ServerMain.server.lua',
|
|
336
|
+
} as unknown as DeployTarget,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
it('reports how much log text a verdict was read from', async () => {
|
|
341
|
+
const result = await runAsync('some output with no jest report');
|
|
342
|
+
|
|
343
|
+
expect(result.success).toBe(false);
|
|
344
|
+
expect(result.error).toContain('verdict read from log text alone');
|
|
345
|
+
expect(result.error).toContain('31 chars, 1 line(s) received');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('says nothing about log volume when there was no log text', async () => {
|
|
349
|
+
// A batch package whose section was lost has no text of its own. Reporting
|
|
350
|
+
// "0 chars received" here contradicts the fetch stats already printed for
|
|
351
|
+
// the whole batch window, and describes a judgement never made: the verdict
|
|
352
|
+
// came from the output being absent, which its own reason already says.
|
|
353
|
+
const result = await runAsync('');
|
|
354
|
+
|
|
355
|
+
expect(result.success).toBe(false);
|
|
356
|
+
expect(result.error).not.toContain('verdict read from log text alone');
|
|
357
|
+
expect(result.error).not.toContain('0 chars, 0 line(s)');
|
|
358
|
+
});
|
|
359
|
+
});
|
|
@@ -8,6 +8,15 @@ import {
|
|
|
8
8
|
parseTestLogs,
|
|
9
9
|
parseTestCounts,
|
|
10
10
|
} from '../test-log-parser.js';
|
|
11
|
+
import { describeLogVolume } from '../log-fetch-stats.js';
|
|
12
|
+
import {
|
|
13
|
+
type StructuredTestResults,
|
|
14
|
+
describeUnexplainedVerdict,
|
|
15
|
+
findStructuredTestResults,
|
|
16
|
+
structuredFailureReasons,
|
|
17
|
+
toParsedTestCounts,
|
|
18
|
+
} from '../structured-test-results.js';
|
|
19
|
+
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
11
20
|
import {
|
|
12
21
|
buildDeployMetadataAttributes,
|
|
13
22
|
gatherGitDeployInfo,
|
|
@@ -28,6 +37,14 @@ export interface SingleTestResult {
|
|
|
28
37
|
durationMs?: number;
|
|
29
38
|
/** Why the run failed, when the runner can say more than "it failed". */
|
|
30
39
|
error?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Where `testCounts` came from. `returned` means the run handed them over as a
|
|
42
|
+
* value; `scraped` means they were read out of log text, which is the channel
|
|
43
|
+
* truncation destroys. Absent when there are no counts at all.
|
|
44
|
+
*/
|
|
45
|
+
countsSource?: 'returned' | 'scraped';
|
|
46
|
+
/** True when the verdict rests on returned results with no log to read. */
|
|
47
|
+
logsLost?: boolean;
|
|
31
48
|
}
|
|
32
49
|
|
|
33
50
|
/**
|
|
@@ -60,6 +77,12 @@ export interface SingleTestOptions {
|
|
|
60
77
|
timeoutMs?: number;
|
|
61
78
|
/** Luau code to execute directly, bypassing the configured scriptTemplate. */
|
|
62
79
|
scriptText?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Skip this run's counts-provenance line, because the caller reports it for
|
|
82
|
+
* every package at once. Set by an aggregated batch, where one fetch serves
|
|
83
|
+
* the whole run and the per-package line says the same thing each time.
|
|
84
|
+
*/
|
|
85
|
+
suppressCountsProvenance?: boolean;
|
|
63
86
|
}
|
|
64
87
|
|
|
65
88
|
/**
|
|
@@ -125,31 +148,230 @@ export async function runSingleTestAsync(
|
|
|
125
148
|
timeoutMs,
|
|
126
149
|
});
|
|
127
150
|
|
|
128
|
-
const
|
|
151
|
+
const logs = await context.getLogsAsync(deployment);
|
|
152
|
+
const rawLogs = logs.text;
|
|
153
|
+
const logVolume = describeLogVolume(rawLogs, logs.stats);
|
|
154
|
+
|
|
155
|
+
// The runner used to announce a failing suite by throwing, which failed the
|
|
156
|
+
// task. It returns its verdict now, so the verdict has to be read: without
|
|
157
|
+
// this, a failing suite whose report fell outside the truncated log window
|
|
158
|
+
// would come back a pass.
|
|
159
|
+
//
|
|
160
|
+
// A context that already resolved the results (aggregated batch, where one
|
|
161
|
+
// execution's return value belongs to no single package) hands them over
|
|
162
|
+
// directly; otherwise they are decoded from what the script returned.
|
|
163
|
+
const structured =
|
|
164
|
+
result.testResults ?? findStructuredTestResults(result.returnValues);
|
|
165
|
+
|
|
129
166
|
// A probe script is arbitrary Luau with no jest in it, so demanding a test
|
|
130
167
|
// report would fail every --script-text run. Everything else must prove a
|
|
131
|
-
// runner spoke before it can pass
|
|
168
|
+
// runner spoke before it can pass — and returned results are that proof,
|
|
169
|
+
// where a scraped report is only what survived truncation.
|
|
132
170
|
const parsed = parseTestLogs(rawLogs, {
|
|
133
|
-
requireTestReport: scriptText === undefined,
|
|
171
|
+
requireTestReport: scriptText === undefined && structured === undefined,
|
|
134
172
|
});
|
|
135
173
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
174
|
+
// A run that reports it never reached jest, for a package that has a
|
|
175
|
+
// jest.config on disk, did not test anything — the built place lost the
|
|
176
|
+
// config. Before results were returned, the required jest report caught this
|
|
177
|
+
// as "nothing proves any test ran"; a smoke-test result would otherwise
|
|
178
|
+
// retire that check and pass with zero tests.
|
|
179
|
+
const smokeTestedWithSpecs =
|
|
180
|
+
structured !== undefined &&
|
|
181
|
+
!structured.ranJest &&
|
|
182
|
+
scriptText === undefined &&
|
|
183
|
+
(await packageHasJestConfigAsync(packagePath));
|
|
184
|
+
|
|
185
|
+
const reasons = mergeFailureReasons(result.errorMessage, [
|
|
186
|
+
...(smokeTestedWithSpecs
|
|
187
|
+
? [
|
|
188
|
+
'this package has a jest.config but the run reported no jest.config ' +
|
|
189
|
+
'in the built place, so none of its specs ran',
|
|
190
|
+
]
|
|
191
|
+
: []),
|
|
192
|
+
...(structured ? structuredFailureReasons(structured) : []),
|
|
193
|
+
...parsed.failureReasons,
|
|
194
|
+
// Said only when there was log text and it did not hold up: the reasons
|
|
195
|
+
// above are then statements about what arrived, and how much arrived is
|
|
196
|
+
// the first thing to check. With no text at all the verdict came from the
|
|
197
|
+
// absence of it, which the reason for that absence already explains —
|
|
198
|
+
// saying "0 chars received" beside it only contradicts the fetch that
|
|
199
|
+
// reported the whole window.
|
|
200
|
+
...(structured === undefined &&
|
|
201
|
+
parsed.failureReasons.length > 0 &&
|
|
202
|
+
rawLogs.length > 0
|
|
203
|
+
? [`verdict read from log text alone (${logVolume})`]
|
|
204
|
+
: []),
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
// Returned counts outrank scraped ones: same numbers when the log
|
|
208
|
+
// survived, real numbers when it did not.
|
|
209
|
+
const scrapedCounts = parseTestCounts(parsed.logs);
|
|
210
|
+
const testCounts = structured
|
|
211
|
+
? toParsedTestCounts(structured)
|
|
212
|
+
: scrapedCounts;
|
|
213
|
+
|
|
214
|
+
reportCountsProvenance({
|
|
215
|
+
packageName,
|
|
216
|
+
structured,
|
|
217
|
+
scrapedCounts,
|
|
218
|
+
logVolume,
|
|
219
|
+
// A probe is not a test run, so it has no results to be missing. Neither
|
|
220
|
+
// is one package of an aggregated batch, whose context reports the whole
|
|
221
|
+
// batch's provenance once, as a group — repeating it per package printed
|
|
222
|
+
// the same fetch stats 78 times over.
|
|
223
|
+
silent:
|
|
224
|
+
scriptText !== undefined ||
|
|
225
|
+
result.testResults !== undefined ||
|
|
226
|
+
(options.suppressCountsProvenance ?? false),
|
|
227
|
+
hadReturnChannel: result.returnValues !== undefined,
|
|
228
|
+
});
|
|
140
229
|
|
|
141
230
|
return {
|
|
142
|
-
success:
|
|
231
|
+
success:
|
|
232
|
+
result.success &&
|
|
233
|
+
parsed.success &&
|
|
234
|
+
(structured?.success ?? true) &&
|
|
235
|
+
!smokeTestedWithSpecs,
|
|
143
236
|
logs: parsed.logs,
|
|
144
|
-
testCounts
|
|
237
|
+
testCounts,
|
|
238
|
+
countsSource: structured
|
|
239
|
+
? 'returned'
|
|
240
|
+
: scrapedCounts
|
|
241
|
+
? 'scraped'
|
|
242
|
+
: undefined,
|
|
145
243
|
durationMs: result.durationMs,
|
|
146
244
|
error: reasons.length > 0 ? reasons.join('; ') : undefined,
|
|
245
|
+
logsLost: result.logsLost,
|
|
147
246
|
};
|
|
148
247
|
} finally {
|
|
149
248
|
await context.releaseAsync(deployment);
|
|
150
249
|
}
|
|
151
250
|
}
|
|
152
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Say where a run's counts came from, and complain when they had to be scraped.
|
|
254
|
+
*
|
|
255
|
+
* The structured channel exists because Open Cloud truncates a long run's logs.
|
|
256
|
+
* A channel that is plumbed but not flowing produces output identical to one
|
|
257
|
+
* that works, so the fallback is a warning rather than silence — the first
|
|
258
|
+
* version of this shipped inert and looked green.
|
|
259
|
+
*/
|
|
260
|
+
function reportCountsProvenance(options: {
|
|
261
|
+
packageName: string;
|
|
262
|
+
structured?: StructuredTestResults;
|
|
263
|
+
scrapedCounts?: ParsedTestCounts;
|
|
264
|
+
silent: boolean;
|
|
265
|
+
hadReturnChannel: boolean;
|
|
266
|
+
/** How much log text there was to scrape, for the fallback warning. */
|
|
267
|
+
logVolume: string;
|
|
268
|
+
}): void {
|
|
269
|
+
const {
|
|
270
|
+
packageName,
|
|
271
|
+
structured,
|
|
272
|
+
scrapedCounts,
|
|
273
|
+
silent,
|
|
274
|
+
hadReturnChannel,
|
|
275
|
+
logVolume,
|
|
276
|
+
} = options;
|
|
277
|
+
|
|
278
|
+
if (silent) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (structured) {
|
|
283
|
+
OutputHelper.info(
|
|
284
|
+
`${packageName}: counts returned by the run — ` +
|
|
285
|
+
`${structured.passed} passed, ${structured.failed} failed, ` +
|
|
286
|
+
`${structured.total} total.`
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const unexplained = describeUnexplainedVerdict(structured);
|
|
290
|
+
if (unexplained) {
|
|
291
|
+
OutputHelper.warn(`${packageName}: ${unexplained}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// A suite that runs and counts nothing is not the same as one that passed.
|
|
295
|
+
if (structured.ranJest && structured.total === 0) {
|
|
296
|
+
OutputHelper.warn(
|
|
297
|
+
`${packageName}: jest ran but found no tests to run. If this package has ` +
|
|
298
|
+
`specs, they are not reaching the test place.`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Two channels reporting the same run must agree. When they do not, one is
|
|
303
|
+
// lying and neither total stands on its own.
|
|
304
|
+
if (scrapedCounts && scrapedCounts.total !== structured.total) {
|
|
305
|
+
OutputHelper.warn(
|
|
306
|
+
`${packageName}: returned counts disagree with the log — the run returned ` +
|
|
307
|
+
`${structured.passed}/${structured.failed}/${structured.total} ` +
|
|
308
|
+
`(passed/failed/total), its jest report says ` +
|
|
309
|
+
`${scrapedCounts.passed}/${scrapedCounts.failed}/${scrapedCounts.total}. ` +
|
|
310
|
+
`Reporting the returned counts; one of the two channels is wrong.`
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
OutputHelper.warn(
|
|
317
|
+
`${packageName}: the run returned no test results, so its counts were ` +
|
|
318
|
+
(scrapedCounts ? 'scraped from log text' : 'unavailable') +
|
|
319
|
+
` — the channel Open Cloud truncates on long runs (${logVolume}). ` +
|
|
320
|
+
(hadReturnChannel
|
|
321
|
+
? 'The run delivered a return channel but nothing recognizable in it: the ' +
|
|
322
|
+
'test script should end with "return results" (see docs/testing/testing.md).'
|
|
323
|
+
: 'No return channel was delivered at all, so the transport lost it.')
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Whether this package ships a jest.config, i.e. whether it has specs to run.
|
|
329
|
+
*
|
|
330
|
+
* Deliberately a bounded walk, not a recursive glob: every package under `src/`
|
|
331
|
+
* has a symlinked, self-referential `node_modules`, so recursive search here
|
|
332
|
+
* hangs. Two levels covers the layouts in use (`src/jest.config.lua` for a
|
|
333
|
+
* package, `src/modules/jest.config.lua` for a game) without needing a list of
|
|
334
|
+
* them.
|
|
335
|
+
*/
|
|
336
|
+
export async function packageHasJestConfigAsync(
|
|
337
|
+
packagePath: string
|
|
338
|
+
): Promise<boolean> {
|
|
339
|
+
async function scanAsync(dir: string, depth: number): Promise<boolean> {
|
|
340
|
+
let entries;
|
|
341
|
+
try {
|
|
342
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
343
|
+
} catch {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
for (const entry of entries) {
|
|
348
|
+
if (entry.isFile() && entry.name.startsWith('jest.config')) {
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (depth === 0) {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const entry of entries) {
|
|
358
|
+
if (!entry.isDirectory()) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
// `test` holds the test place, never the specs the place runs.
|
|
362
|
+
if (entry.name === 'node_modules' || entry.name === 'test') {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (await scanAsync(path.join(dir, entry.name), depth - 1)) {
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return scanAsync(packagePath, 2);
|
|
373
|
+
}
|
|
374
|
+
|
|
153
375
|
/**
|
|
154
376
|
* Read a test script from the deploy target's configured script path.
|
|
155
377
|
*/
|