@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
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for decoding the results a test run returns. The decoder stands
|
|
3
|
+
* between a transport and a pass/fail verdict, so the cases that matter are the
|
|
4
|
+
* ones where a value is not what it claims to be.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
TEST_RESULTS_FORMAT,
|
|
11
|
+
decodeStructuredTestResults,
|
|
12
|
+
describeUnexplainedVerdict,
|
|
13
|
+
findStructuredTestResults,
|
|
14
|
+
structuredFailureReasons,
|
|
15
|
+
toParsedTestCounts,
|
|
16
|
+
} from './structured-test-results.js';
|
|
17
|
+
|
|
18
|
+
function results(overrides: Record<string, unknown> = {}) {
|
|
19
|
+
return {
|
|
20
|
+
format: TEST_RESULTS_FORMAT,
|
|
21
|
+
success: true,
|
|
22
|
+
ranJest: true,
|
|
23
|
+
passed: 25,
|
|
24
|
+
failed: 0,
|
|
25
|
+
skipped: 1,
|
|
26
|
+
total: 26,
|
|
27
|
+
suitesPassed: 3,
|
|
28
|
+
suitesFailed: 0,
|
|
29
|
+
suitesTotal: 3,
|
|
30
|
+
failures: [],
|
|
31
|
+
omittedFailures: 0,
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('decodeStructuredTestResults', () => {
|
|
37
|
+
it('decodes a results table', () => {
|
|
38
|
+
const decoded = decodeStructuredTestResults(results());
|
|
39
|
+
|
|
40
|
+
expect(decoded).toEqual({
|
|
41
|
+
success: true,
|
|
42
|
+
ranJest: true,
|
|
43
|
+
passed: 25,
|
|
44
|
+
failed: 0,
|
|
45
|
+
skipped: 1,
|
|
46
|
+
total: 26,
|
|
47
|
+
suitesPassed: 3,
|
|
48
|
+
suitesFailed: 0,
|
|
49
|
+
suitesTotal: 3,
|
|
50
|
+
failures: [],
|
|
51
|
+
omittedFailures: 0,
|
|
52
|
+
error: undefined,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('ignores a value that is not tagged as results', () => {
|
|
57
|
+
// A probe run (--script-text) returns whatever it likes, and none of it is
|
|
58
|
+
// a verdict on anything.
|
|
59
|
+
expect(decodeStructuredTestResults({ success: true })).toBeUndefined();
|
|
60
|
+
expect(
|
|
61
|
+
decodeStructuredTestResults({ format: 'something-else', success: true })
|
|
62
|
+
).toBeUndefined();
|
|
63
|
+
expect(
|
|
64
|
+
decodeStructuredTestResults('nevermore-test-results@1')
|
|
65
|
+
).toBeUndefined();
|
|
66
|
+
expect(decodeStructuredTestResults(undefined)).toBeUndefined();
|
|
67
|
+
expect(decodeStructuredTestResults([results()])).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('reads only an explicit true as a pass', () => {
|
|
71
|
+
// Anything else means the verdict did not survive the trip, and a lost
|
|
72
|
+
// verdict must not read as a passing one.
|
|
73
|
+
expect(
|
|
74
|
+
decodeStructuredTestResults(results({ success: 'yes' }))?.success
|
|
75
|
+
).toBe(false);
|
|
76
|
+
expect(
|
|
77
|
+
decodeStructuredTestResults(results({ success: undefined }))?.success
|
|
78
|
+
).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('treats a malformed count as zero rather than as a number', () => {
|
|
82
|
+
const decoded = decodeStructuredTestResults(
|
|
83
|
+
results({ passed: 'lots', total: null, failed: Number.NaN })
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(decoded?.passed).toBe(0);
|
|
87
|
+
expect(decoded?.total).toBe(0);
|
|
88
|
+
expect(decoded?.failed).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('keeps only well-formed failures', () => {
|
|
92
|
+
const decoded = decodeStructuredTestResults(
|
|
93
|
+
results({
|
|
94
|
+
failures: [
|
|
95
|
+
{ name: 'Maid does a thing', message: 'expected true' },
|
|
96
|
+
{ name: 'Maid does another thing' },
|
|
97
|
+
{ message: 'no name' },
|
|
98
|
+
'not a failure',
|
|
99
|
+
],
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(decoded?.failures).toEqual([
|
|
104
|
+
{ name: 'Maid does a thing', message: 'expected true' },
|
|
105
|
+
{ name: 'Maid does another thing', message: undefined },
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('accepts an empty failure list however the transport spelled it', () => {
|
|
110
|
+
// An empty Lua table marshals as an array on the bridge and can arrive as an
|
|
111
|
+
// object from the cloud.
|
|
112
|
+
expect(
|
|
113
|
+
decodeStructuredTestResults(results({ failures: {} }))?.failures
|
|
114
|
+
).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('findStructuredTestResults', () => {
|
|
119
|
+
it('finds results among other returned values', () => {
|
|
120
|
+
const found = findStructuredTestResults([
|
|
121
|
+
42,
|
|
122
|
+
'log',
|
|
123
|
+
results({ passed: 9 }),
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
expect(found?.passed).toBe(9);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('is undefined when the script returned nothing', () => {
|
|
130
|
+
// A test script written before this convention returns nil, which is the
|
|
131
|
+
// case that has to keep falling back to the logs.
|
|
132
|
+
expect(findStructuredTestResults([])).toBeUndefined();
|
|
133
|
+
expect(findStructuredTestResults(undefined)).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('structuredFailureReasons', () => {
|
|
138
|
+
it('says nothing about a passing run', () => {
|
|
139
|
+
expect(
|
|
140
|
+
structuredFailureReasons(decodeStructuredTestResults(results())!)
|
|
141
|
+
).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('words counts the way the log parser does, so the overlap merges', () => {
|
|
145
|
+
const reasons = structuredFailureReasons(
|
|
146
|
+
decodeStructuredTestResults(
|
|
147
|
+
results({ success: false, failed: 2, suitesFailed: 1 })
|
|
148
|
+
)!
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(reasons).toContain('2 test(s) failed');
|
|
152
|
+
expect(reasons).toContain('1 test suite(s) failed');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('explains a failure no count can account for', () => {
|
|
156
|
+
const reasons = structuredFailureReasons(
|
|
157
|
+
decodeStructuredTestResults(results({ success: false }))!
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
expect(reasons).toEqual(['the test runner reported the run as failed']);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('carries the message the runner supplied', () => {
|
|
164
|
+
const reasons = structuredFailureReasons(
|
|
165
|
+
decodeStructuredTestResults(
|
|
166
|
+
results({
|
|
167
|
+
success: false,
|
|
168
|
+
error: '[NevermoreTestRunner] Jest run failed',
|
|
169
|
+
})
|
|
170
|
+
)!
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
expect(reasons).toContain('[NevermoreTestRunner] Jest run failed');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('structuredFailureReasons wording', () => {
|
|
178
|
+
it('never builds a reason out of zeros', () => {
|
|
179
|
+
// "0 test(s) and 0 test suite(s) failed" was a real failure reason once. A
|
|
180
|
+
// reason saying nothing failed is unreadable as either verdict, and it is
|
|
181
|
+
// what made the bug behind it hard to see.
|
|
182
|
+
for (const overrides of [
|
|
183
|
+
{ success: false },
|
|
184
|
+
{ success: false, failed: 0, suitesFailed: 0 },
|
|
185
|
+
{
|
|
186
|
+
success: false,
|
|
187
|
+
error: '[NevermoreTestRunner] the run was interrupted',
|
|
188
|
+
},
|
|
189
|
+
]) {
|
|
190
|
+
const reasons = structuredFailureReasons(
|
|
191
|
+
decodeStructuredTestResults(results(overrides))!
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
expect(reasons.length).toBeGreaterThan(0);
|
|
195
|
+
for (const reason of reasons) {
|
|
196
|
+
expect(reason).not.toMatch(/\b0 test\(s\)/);
|
|
197
|
+
expect(reason).not.toMatch(/\b0 test suite\(s\)/);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('does not restate the counts the reasons already carry', () => {
|
|
203
|
+
const reasons = structuredFailureReasons(
|
|
204
|
+
decodeStructuredTestResults(
|
|
205
|
+
results({
|
|
206
|
+
success: false,
|
|
207
|
+
failed: 2,
|
|
208
|
+
suitesFailed: 1,
|
|
209
|
+
error:
|
|
210
|
+
'[NevermoreTestRunner] 2 test(s) failed, 1 test suite(s) failed',
|
|
211
|
+
})
|
|
212
|
+
)!
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
expect(reasons).toEqual(['1 test suite(s) failed', '2 test(s) failed']);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('describeUnexplainedVerdict', () => {
|
|
220
|
+
it('is silent about a passing run', () => {
|
|
221
|
+
// The direction that broke: a fully passing result must read as a pass and
|
|
222
|
+
// raise nothing at all.
|
|
223
|
+
expect(
|
|
224
|
+
describeUnexplainedVerdict(decodeStructuredTestResults(results())!)
|
|
225
|
+
).toBeUndefined();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('is silent about a failure its counts explain', () => {
|
|
229
|
+
expect(
|
|
230
|
+
describeUnexplainedVerdict(
|
|
231
|
+
decodeStructuredTestResults(results({ success: false, failed: 2 }))!
|
|
232
|
+
)
|
|
233
|
+
).toBeUndefined();
|
|
234
|
+
expect(
|
|
235
|
+
describeUnexplainedVerdict(
|
|
236
|
+
decodeStructuredTestResults(
|
|
237
|
+
results({ success: false, suitesFailed: 1 })
|
|
238
|
+
)!
|
|
239
|
+
)
|
|
240
|
+
).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('describes a failure no count supports', () => {
|
|
244
|
+
// The exact signature of a runner reading the wrong field: plausible counts,
|
|
245
|
+
// and every package failed anyway.
|
|
246
|
+
const description = describeUnexplainedVerdict(
|
|
247
|
+
decodeStructuredTestResults(
|
|
248
|
+
results({ success: false, passed: 311, failed: 0, total: 311 })
|
|
249
|
+
)!
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(description).toContain('nothing failed');
|
|
253
|
+
expect(description).toContain('311 passed');
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('toParsedTestCounts', () => {
|
|
258
|
+
it('narrows to the counts the reporters render', () => {
|
|
259
|
+
expect(toParsedTestCounts(decodeStructuredTestResults(results())!)).toEqual(
|
|
260
|
+
{
|
|
261
|
+
passed: 25,
|
|
262
|
+
failed: 0,
|
|
263
|
+
total: 26,
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { type ParsedTestCounts } from './test-log-parser.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tag NevermoreTestRunnerUtils stamps on the results table a test script
|
|
5
|
+
* returns. A test place also runs probe scripts (`--script-text`) that return
|
|
6
|
+
* whatever they like, so results are recognized by this rather than by shape.
|
|
7
|
+
*/
|
|
8
|
+
export const TEST_RESULTS_FORMAT = 'nevermore-test-results@1';
|
|
9
|
+
|
|
10
|
+
/** One failed test, or one suite that failed before its tests could run. */
|
|
11
|
+
export interface StructuredTestFailure {
|
|
12
|
+
name: string;
|
|
13
|
+
message?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What a test run says about itself, returned as a value instead of printed.
|
|
18
|
+
*
|
|
19
|
+
* Open Cloud truncates a long run's logs, so counts scraped out of that text go
|
|
20
|
+
* missing on exactly the runs where they matter most. Mirrors the
|
|
21
|
+
* `TestRunResults` table in `NevermoreTestRunnerUtils`.
|
|
22
|
+
*/
|
|
23
|
+
export interface StructuredTestResults {
|
|
24
|
+
success: boolean;
|
|
25
|
+
/** False for a smoke test, whose counts are all zero because nothing counted. */
|
|
26
|
+
ranJest: boolean;
|
|
27
|
+
passed: number;
|
|
28
|
+
failed: number;
|
|
29
|
+
/** Pending plus todo. */
|
|
30
|
+
skipped: number;
|
|
31
|
+
total: number;
|
|
32
|
+
suitesPassed: number;
|
|
33
|
+
suitesFailed: number;
|
|
34
|
+
suitesTotal: number;
|
|
35
|
+
/** Capped by the runner; `omittedFailures` says how many did not fit. */
|
|
36
|
+
failures: StructuredTestFailure[];
|
|
37
|
+
omittedFailures: number;
|
|
38
|
+
/** Why the run failed when no individual test can say so. */
|
|
39
|
+
error?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readNumber(source: Record<string, unknown>, key: string): number {
|
|
43
|
+
const value = source[key];
|
|
44
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readFailures(value: unknown): StructuredTestFailure[] {
|
|
48
|
+
if (!Array.isArray(value)) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const failures: StructuredTestFailure[] = [];
|
|
53
|
+
for (const entry of value) {
|
|
54
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const record = entry as Record<string, unknown>;
|
|
58
|
+
if (typeof record.name !== 'string') {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
failures.push({
|
|
62
|
+
name: record.name,
|
|
63
|
+
message: typeof record.message === 'string' ? record.message : undefined,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return failures;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Decode a single returned value into results, or undefined if it is not one.
|
|
71
|
+
*
|
|
72
|
+
* Fields are read defensively rather than trusted: the value crossed a
|
|
73
|
+
* transport, and the two transports spell exotic Luau types differently, so a
|
|
74
|
+
* malformed field must not become a verdict.
|
|
75
|
+
*/
|
|
76
|
+
export function decodeStructuredTestResults(
|
|
77
|
+
value: unknown
|
|
78
|
+
): StructuredTestResults | undefined {
|
|
79
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const record = value as Record<string, unknown>;
|
|
84
|
+
if (record.format !== TEST_RESULTS_FORMAT) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
// Only an explicit `true` is a pass. A results table that lost its verdict
|
|
90
|
+
// in transit must not read as one.
|
|
91
|
+
success: record.success === true,
|
|
92
|
+
ranJest: record.ranJest === true,
|
|
93
|
+
passed: readNumber(record, 'passed'),
|
|
94
|
+
failed: readNumber(record, 'failed'),
|
|
95
|
+
skipped: readNumber(record, 'skipped'),
|
|
96
|
+
total: readNumber(record, 'total'),
|
|
97
|
+
suitesPassed: readNumber(record, 'suitesPassed'),
|
|
98
|
+
suitesFailed: readNumber(record, 'suitesFailed'),
|
|
99
|
+
suitesTotal: readNumber(record, 'suitesTotal'),
|
|
100
|
+
failures: readFailures(record.failures),
|
|
101
|
+
omittedFailures: readNumber(record, 'omittedFailures'),
|
|
102
|
+
error: typeof record.error === 'string' ? record.error : undefined,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Find the results a run returned, if it returned any.
|
|
108
|
+
*
|
|
109
|
+
* `undefined` covers both "the transport delivered no return channel" and "the
|
|
110
|
+
* script returned nothing recognizable" — a caller falls back to the logs
|
|
111
|
+
* either way, which is what a test script written before this convention gets.
|
|
112
|
+
*/
|
|
113
|
+
export function findStructuredTestResults(
|
|
114
|
+
returnValues: unknown[] | undefined
|
|
115
|
+
): StructuredTestResults | undefined {
|
|
116
|
+
if (!returnValues) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const value of returnValues) {
|
|
121
|
+
const results = decodeStructuredTestResults(value);
|
|
122
|
+
if (results) {
|
|
123
|
+
return results;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Describe a verdict the run's own counts do not support, for a warning.
|
|
131
|
+
*
|
|
132
|
+
* A run reporting failure while nothing in it failed is self-contradicting, and
|
|
133
|
+
* it is precisely what a runner reading the wrong field produces: the counts
|
|
134
|
+
* stay plausible, so nothing looks wrong except that every package fails at
|
|
135
|
+
* once. That shipped once — a runner consulted jest-lua's inverted
|
|
136
|
+
* `AggregatedResult.success` and failed every passing suite.
|
|
137
|
+
*
|
|
138
|
+
* Reported, never corrected. A runner that says it failed may know something its
|
|
139
|
+
* counts cannot express (an interrupted run, a snapshot check, a reporter
|
|
140
|
+
* error), so the verdict stands and the contradiction is made loud instead.
|
|
141
|
+
*/
|
|
142
|
+
export function describeUnexplainedVerdict(
|
|
143
|
+
results: StructuredTestResults
|
|
144
|
+
): string | undefined {
|
|
145
|
+
if (results.success || results.failed > 0 || results.suitesFailed > 0) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
`the run reports failure but its own counts show nothing failed ` +
|
|
151
|
+
`(${results.passed} passed, 0 failed, ${results.total} total, ` +
|
|
152
|
+
`0 of ${results.suitesTotal} suite(s) failed)` +
|
|
153
|
+
(results.error
|
|
154
|
+
? `. It says: ${results.error}`
|
|
155
|
+
: ' and gives no reason at all')
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Counts in the shape the reporters already render. */
|
|
160
|
+
export function toParsedTestCounts(
|
|
161
|
+
results: StructuredTestResults
|
|
162
|
+
): ParsedTestCounts {
|
|
163
|
+
return {
|
|
164
|
+
passed: results.passed,
|
|
165
|
+
failed: results.failed,
|
|
166
|
+
total: results.total,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Why the runner says the run failed. Empty when it says it passed.
|
|
172
|
+
*
|
|
173
|
+
* Phrased to match the log parser's wording so that merging the two sources
|
|
174
|
+
* collapses the overlap instead of reporting every failure twice.
|
|
175
|
+
*/
|
|
176
|
+
export function structuredFailureReasons(
|
|
177
|
+
results: StructuredTestResults
|
|
178
|
+
): string[] {
|
|
179
|
+
if (results.success) {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const reasons: string[] = [];
|
|
184
|
+
if (results.suitesFailed > 0) {
|
|
185
|
+
reasons.push(`${results.suitesFailed} test suite(s) failed`);
|
|
186
|
+
}
|
|
187
|
+
if (results.failed > 0) {
|
|
188
|
+
reasons.push(`${results.failed} test(s) failed`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The runner's own message restates those two lines when a count explains the
|
|
192
|
+
// failure, so it is only worth repeating when nothing else can say why —
|
|
193
|
+
// an interrupted run, a failed snapshot check, a result shape it could not
|
|
194
|
+
// read. That case is also the one where a reason is most needed.
|
|
195
|
+
if (reasons.length === 0) {
|
|
196
|
+
reasons.push(results.error ?? 'the test runner reported the run as failed');
|
|
197
|
+
}
|
|
198
|
+
return reasons;
|
|
199
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
-- batch-test-runner.lua
|
|
2
|
+
-- Runs all test scripts sequentially in a single execution task.
|
|
3
|
+
-- PACKAGE_SLUGS_JSON placeholder is replaced with a JSON array of slug strings at runtime.
|
|
4
|
+
|
|
5
|
+
local CollectionService = game:GetService("CollectionService")
|
|
6
|
+
local HttpService = game:GetService("HttpService")
|
|
7
|
+
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
8
|
+
local RunService = game:GetService("RunService")
|
|
9
|
+
local ServerScriptService = game:GetService("ServerScriptService")
|
|
10
|
+
|
|
11
|
+
-- stylua: ignore
|
|
12
|
+
local packageSlugs: { string } = HttpService:JSONDecode([==[{{ PACKAGE_SLUGS_JSON }}]==])
|
|
13
|
+
|
|
14
|
+
-- Discover test scripts via tags (set by combine-test-places.lua)
|
|
15
|
+
local scriptSources = {}
|
|
16
|
+
for _, slug in packageSlugs do
|
|
17
|
+
local tagged = CollectionService:GetTagged(`_BatchTest_{slug}`)
|
|
18
|
+
local inst = tagged[1]
|
|
19
|
+
if inst then
|
|
20
|
+
scriptSources[slug] = inst.Source
|
|
21
|
+
else
|
|
22
|
+
warn(`[BatchTest] Failed to find script source for {slug}`)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
-- Snapshot all package roots (keep references even when unparented)
|
|
27
|
+
local allPackages = {}
|
|
28
|
+
for _, child in ServerScriptService:GetChildren() do
|
|
29
|
+
allPackages[child.Name] = child
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
-- Tag on a NevermoreTestRunnerUtils results table. A test script returns one;
|
|
33
|
+
-- one written before the convention returns nil, and a probe returns whatever
|
|
34
|
+
-- it likes, so only a tagged table is read as results.
|
|
35
|
+
local RESULTS_FORMAT = "nevermore-test-results@1"
|
|
36
|
+
|
|
37
|
+
-- Module NevermoreTestRunnerUtils leaves a run's results in, regardless of what
|
|
38
|
+
-- the script did with them. Most test scripts discard the return value
|
|
39
|
+
-- (`if runTestsIfNeededAsync(root) then return end`), and without this their
|
|
40
|
+
-- counts exist only in Jest's printed report — the first thing a long batch's
|
|
41
|
+
-- log window drops. Read as a fallback, never in preference to what the script
|
|
42
|
+
-- actually returned.
|
|
43
|
+
--
|
|
44
|
+
-- Named rather than required by path: this script is raw source with no loader,
|
|
45
|
+
-- so it finds the module under the package it just ran and requires the
|
|
46
|
+
-- instance. Requiring the same instance twice returns the same table, which is
|
|
47
|
+
-- what makes this a mailbox and not a copy.
|
|
48
|
+
local STATE_MODULE_NAME = "NevermoreTestRunnerState.global"
|
|
49
|
+
|
|
50
|
+
--[[
|
|
51
|
+
The state module inside one package, or nil for a package that has no test
|
|
52
|
+
runner in it at all.
|
|
53
|
+
]]
|
|
54
|
+
local function findStateModule(package: Instance): ModuleScript?
|
|
55
|
+
local found = package:FindFirstChild(STATE_MODULE_NAME, true)
|
|
56
|
+
|
|
57
|
+
return if found and found:IsA("ModuleScript") then found else nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
type TestCounts = {
|
|
61
|
+
passed: number,
|
|
62
|
+
failed: number,
|
|
63
|
+
skipped: number,
|
|
64
|
+
total: number,
|
|
65
|
+
suitesPassed: number,
|
|
66
|
+
suitesFailed: number,
|
|
67
|
+
suitesTotal: number,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type Result = {
|
|
71
|
+
success: boolean,
|
|
72
|
+
slug: string,
|
|
73
|
+
durationMs: number,
|
|
74
|
+
error: string?,
|
|
75
|
+
-- Absent when the package's test script returned nothing structured, which
|
|
76
|
+
-- is the only case where a reader still has to fall back to its logs.
|
|
77
|
+
counts: TestCounts?,
|
|
78
|
+
-- False for a smoke test, whose counts are zero because nothing counted them.
|
|
79
|
+
-- Carried so a reader never has to guess whether zero means "no tests".
|
|
80
|
+
ranJest: boolean?,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
-- Counts only. This summary is printed as one log line and read back by the
|
|
84
|
+
-- CLI, so it stays short enough to survive whatever the engine does to a long
|
|
85
|
+
-- line; the failure text a run also returns is per-package and already in that
|
|
86
|
+
-- package's own log section.
|
|
87
|
+
local function toCounts(returned: any): TestCounts?
|
|
88
|
+
if type(returned) ~= "table" or returned.format ~= RESULTS_FORMAT then
|
|
89
|
+
return nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
local counts: { [string]: number } = {}
|
|
93
|
+
for _, field in { "passed", "failed", "skipped", "total", "suitesPassed", "suitesFailed", "suitesTotal" } do
|
|
94
|
+
if type(returned[field]) ~= "number" then
|
|
95
|
+
return nil
|
|
96
|
+
end
|
|
97
|
+
counts[field] = returned[field]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
return counts :: any
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
local function isStructuredFailure(returned: any): boolean
|
|
104
|
+
return type(returned) == "table" and returned.format == RESULTS_FORMAT and returned.success ~= true
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
local results: { Result } = {}
|
|
108
|
+
|
|
109
|
+
for _, slug in packageSlugs do
|
|
110
|
+
local scriptSource = scriptSources[slug]
|
|
111
|
+
if not scriptSource then
|
|
112
|
+
print("===BATCH_TEST_BEGIN " .. slug .. "===")
|
|
113
|
+
warn(`[BatchTest] {slug}: No test script found for {slug}. Is it tagged with _BatchTest_{slug}?`)
|
|
114
|
+
print("===BATCH_TEST_END " .. slug .. " FAIL 0===")
|
|
115
|
+
table.insert(results, { slug = slug, success = false, durationMs = 0, error = "No test script found" })
|
|
116
|
+
continue
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
-- ISOLATE: unparent all, show only this package
|
|
120
|
+
for _, package in allPackages do
|
|
121
|
+
package.Parent = nil
|
|
122
|
+
end
|
|
123
|
+
allPackages[slug].Parent = ServerScriptService
|
|
124
|
+
|
|
125
|
+
-- Snapshot services for cleanup
|
|
126
|
+
local preWorkspace = {}
|
|
127
|
+
for _, child in workspace:GetChildren() do
|
|
128
|
+
preWorkspace[child] = true
|
|
129
|
+
end
|
|
130
|
+
local preRunService = {}
|
|
131
|
+
for _, child in ReplicatedStorage:GetChildren() do
|
|
132
|
+
preRunService[child] = true
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
-- Yield before the BEGIN marker so deferred callbacks triggered by
|
|
136
|
+
-- reparenting flush before the marker enters the log stream. This
|
|
137
|
+
-- prevents Open Cloud log reordering from placing leaked output
|
|
138
|
+
-- inside this package's section.
|
|
139
|
+
RunService.Heartbeat:Wait()
|
|
140
|
+
|
|
141
|
+
print("===BATCH_TEST_BEGIN " .. slug .. "===")
|
|
142
|
+
|
|
143
|
+
-- Cleared before the run, so a package that leaves nothing behind cannot be
|
|
144
|
+
-- credited with the previous package's counts — worse than having none.
|
|
145
|
+
local stateModule = findStateModule(allPackages[slug])
|
|
146
|
+
local state: any = if stateModule then (require :: any)(stateModule) else nil
|
|
147
|
+
if state then
|
|
148
|
+
state.results = nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
-- Measure pcall execution time only. The surrounding cleanup/yield cost
|
|
152
|
+
-- is amortized across all packages and would otherwise dominate fast tests.
|
|
153
|
+
local startClock = os.clock()
|
|
154
|
+
|
|
155
|
+
local testOk, returned = pcall(function()
|
|
156
|
+
local fn, compileErr = loadstring(scriptSource, slug)
|
|
157
|
+
if not fn then
|
|
158
|
+
error("Compile error: " .. tostring(compileErr))
|
|
159
|
+
end
|
|
160
|
+
return fn()
|
|
161
|
+
end)
|
|
162
|
+
|
|
163
|
+
local durationMs = math.floor((os.clock() - startClock) * 1000 + 0.5)
|
|
164
|
+
|
|
165
|
+
-- Yield before the END marker for the same reason: leaked deferred
|
|
166
|
+
-- callbacks may fire during fn() and their output must land before
|
|
167
|
+
-- the marker in the log stream.
|
|
168
|
+
RunService.Heartbeat:Wait()
|
|
169
|
+
|
|
170
|
+
-- CLEANUP: restore service state
|
|
171
|
+
allPackages[slug].Parent = nil
|
|
172
|
+
for _, child in workspace:GetChildren() do
|
|
173
|
+
if not preWorkspace[child] and not child:IsA("Terrain") and not child:IsA("Camera") then
|
|
174
|
+
pcall(child.Destroy, child)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
for _, child in ReplicatedStorage:GetChildren() do
|
|
178
|
+
if not preRunService[child] then
|
|
179
|
+
pcall(child.Destroy, child)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
-- Destroy injected LoaderLinks (non-archivable, created by LoaderLinkCreator)
|
|
183
|
+
for _, desc in allPackages[slug]:GetDescendants() do
|
|
184
|
+
if not desc.Archivable and desc:IsA("ModuleScript") and desc.Name == "loader" then
|
|
185
|
+
pcall(desc.Destroy, desc)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
-- Flush lingering deferred callbacks. Heavy packages (Binder, ServiceBag)
|
|
190
|
+
-- schedule deferred callbacks via CollectionService that survive cleanup.
|
|
191
|
+
-- Yielding across multiple heartbeat frames is more reliable than a fixed
|
|
192
|
+
-- time wait, since each frame drains its deferred queue.
|
|
193
|
+
for _ = 1, 3 do
|
|
194
|
+
RunService.Heartbeat:Wait()
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
-- What the script returned, or what the runner left behind when the script
|
|
198
|
+
-- returned nothing. Both are the same table whenever both exist, so
|
|
199
|
+
-- preferring the returned one costs nothing and keeps a script that does
|
|
200
|
+
-- return its results independent of the state module.
|
|
201
|
+
local published = if state then state.results else nil
|
|
202
|
+
local reported = if testOk and toCounts(returned) then returned else published
|
|
203
|
+
|
|
204
|
+
-- A test script that does not throw has still failed if the results say so,
|
|
205
|
+
-- which is the whole reason they are read at all: a failing suite used to
|
|
206
|
+
-- announce itself by erroring, and an error carries no counts.
|
|
207
|
+
local counts = if testOk then toCounts(reported) else nil
|
|
208
|
+
local success = testOk and not isStructuredFailure(reported)
|
|
209
|
+
local failureReason: string? = nil
|
|
210
|
+
if not testOk then
|
|
211
|
+
failureReason = tostring(returned)
|
|
212
|
+
elseif not success then
|
|
213
|
+
failureReason = tostring((reported :: any).error or "the test runner reported the run as failed")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
if failureReason then
|
|
217
|
+
warn("[BatchTest] " .. slug .. ": " .. failureReason)
|
|
218
|
+
end
|
|
219
|
+
print("===BATCH_TEST_END " .. slug .. (if success then " PASS " else " FAIL ") .. tostring(durationMs) .. "===")
|
|
220
|
+
table.insert(results, {
|
|
221
|
+
slug = slug,
|
|
222
|
+
success = success,
|
|
223
|
+
durationMs = durationMs,
|
|
224
|
+
error = failureReason,
|
|
225
|
+
counts = counts,
|
|
226
|
+
ranJest = if counts then (reported :: any).ranJest == true else nil,
|
|
227
|
+
})
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
-- Restore all packages
|
|
231
|
+
for _, package in allPackages do
|
|
232
|
+
package.Parent = ServerScriptService
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
-- Printed as well as returned. The print is what a person reads in the log and
|
|
236
|
+
-- what an older CLI parses; the return value is the copy that cannot be
|
|
237
|
+
-- truncated, since an oversize return fails the task outright rather than
|
|
238
|
+
-- arriving short. Counts only, so it stays far inside that limit.
|
|
239
|
+
print("===BATCH_TEST_SUMMARY===")
|
|
240
|
+
print(HttpService:JSONEncode(results))
|
|
241
|
+
|
|
242
|
+
return results
|