@vitronai/themis 0.1.0-beta.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 +41 -0
- package/LICENSE +21 -0
- package/README.md +285 -0
- package/benchmark-gate.json +10 -0
- package/bin/themis.js +8 -0
- package/docs/api.md +210 -0
- package/docs/publish.md +55 -0
- package/docs/release-policy.md +54 -0
- package/docs/schemas/agent-result.v1.json +277 -0
- package/docs/schemas/failures.v1.json +78 -0
- package/docs/vscode-extension.md +40 -0
- package/docs/why-themis.md +111 -0
- package/globals.d.ts +22 -0
- package/globals.js +1 -0
- package/index.d.ts +190 -0
- package/index.js +17 -0
- package/package.json +90 -0
- package/src/artifacts.js +207 -0
- package/src/assets/themisBg.png +0 -0
- package/src/assets/themisLogo.png +0 -0
- package/src/assets/themisReport.png +0 -0
- package/src/cli.js +395 -0
- package/src/config.js +52 -0
- package/src/discovery.js +34 -0
- package/src/environment.js +108 -0
- package/src/expect.js +175 -0
- package/src/init.js +22 -0
- package/src/module-loader.js +489 -0
- package/src/reporter.js +2141 -0
- package/src/runner.js +168 -0
- package/src/runtime.js +472 -0
- package/src/snapshots.js +90 -0
- package/src/stability.js +98 -0
- package/src/test-utils.js +201 -0
- package/src/verdict.js +71 -0
- package/src/watch.js +154 -0
- package/src/worker.js +26 -0
package/src/runner.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { Worker } = require('worker_threads');
|
|
3
|
+
const { performance } = require('perf_hooks');
|
|
4
|
+
|
|
5
|
+
async function runTests(files, options = {}) {
|
|
6
|
+
const startedAt = performance.now();
|
|
7
|
+
const startedAtIso = new Date().toISOString();
|
|
8
|
+
const maxWorkers = resolveMaxWorkers(options.maxWorkers);
|
|
9
|
+
const queue = [...files];
|
|
10
|
+
const workers = [];
|
|
11
|
+
const fileResults = [];
|
|
12
|
+
|
|
13
|
+
for (let i = 0; i < Math.min(maxWorkers, files.length); i += 1) {
|
|
14
|
+
workers.push(runNext(queue, fileResults, options));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
await Promise.all(workers);
|
|
18
|
+
|
|
19
|
+
fileResults.sort((a, b) => a.file.localeCompare(b.file));
|
|
20
|
+
|
|
21
|
+
const tests = fileResults.flatMap((entry) => entry.tests);
|
|
22
|
+
const passed = tests.filter((test) => test.status === 'passed').length;
|
|
23
|
+
const failed = tests.filter((test) => test.status === 'failed').length;
|
|
24
|
+
const skipped = tests.filter((test) => test.status === 'skipped').length;
|
|
25
|
+
const durationMs = Math.round((performance.now() - startedAt) * 100) / 100;
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
meta: {
|
|
29
|
+
startedAt: startedAtIso,
|
|
30
|
+
finishedAt: new Date().toISOString(),
|
|
31
|
+
maxWorkers
|
|
32
|
+
},
|
|
33
|
+
files: fileResults,
|
|
34
|
+
summary: {
|
|
35
|
+
total: tests.length,
|
|
36
|
+
passed,
|
|
37
|
+
failed,
|
|
38
|
+
skipped,
|
|
39
|
+
durationMs
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function runNext(queue, fileResults, options) {
|
|
45
|
+
while (queue.length > 0) {
|
|
46
|
+
const file = queue.shift();
|
|
47
|
+
if (!file) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const result = await runFileInWorker(file, options);
|
|
51
|
+
fileResults.push(result);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runFileInWorker(file, options = {}) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const worker = new Worker(path.join(__dirname, 'worker.js'), {
|
|
58
|
+
workerData: {
|
|
59
|
+
file,
|
|
60
|
+
match: options.match || null,
|
|
61
|
+
allowedFullNames: Array.isArray(options.allowedFullNames) ? options.allowedFullNames : null,
|
|
62
|
+
noMemes: Boolean(options.noMemes),
|
|
63
|
+
cwd: options.cwd || process.cwd(),
|
|
64
|
+
environment: options.environment || 'node',
|
|
65
|
+
setupFiles: Array.isArray(options.setupFiles) ? options.setupFiles : [],
|
|
66
|
+
tsconfigPath: options.tsconfigPath === undefined ? undefined : options.tsconfigPath,
|
|
67
|
+
updateSnapshots: Boolean(options.updateSnapshots)
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
let settled = false;
|
|
72
|
+
|
|
73
|
+
const cleanup = () => {
|
|
74
|
+
worker.removeListener('message', onMessage);
|
|
75
|
+
worker.removeListener('error', onError);
|
|
76
|
+
worker.removeListener('exit', onExit);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const settle = (result) => {
|
|
80
|
+
if (settled) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
settled = true;
|
|
84
|
+
cleanup();
|
|
85
|
+
resolve(result);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const onMessage = (payload) => {
|
|
89
|
+
if (payload.ok) {
|
|
90
|
+
settle(payload.result);
|
|
91
|
+
} else {
|
|
92
|
+
settle({
|
|
93
|
+
file,
|
|
94
|
+
tests: [
|
|
95
|
+
{
|
|
96
|
+
name: 'worker',
|
|
97
|
+
fullName: `${file} worker`,
|
|
98
|
+
status: 'failed',
|
|
99
|
+
durationMs: 0,
|
|
100
|
+
error: payload.error
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const onError = (error) => {
|
|
108
|
+
settle({
|
|
109
|
+
file,
|
|
110
|
+
tests: [
|
|
111
|
+
{
|
|
112
|
+
name: 'worker',
|
|
113
|
+
fullName: `${file} worker`,
|
|
114
|
+
status: 'failed',
|
|
115
|
+
durationMs: 0,
|
|
116
|
+
error: {
|
|
117
|
+
message: String(error.message || error),
|
|
118
|
+
stack: String(error.stack || error)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
]
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const onExit = (code) => {
|
|
126
|
+
if (settled) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const normalizedCode = Number(code);
|
|
131
|
+
const message = normalizedCode === 0
|
|
132
|
+
? 'Worker exited before reporting test results'
|
|
133
|
+
: `Worker exited with code ${normalizedCode} before reporting test results`;
|
|
134
|
+
|
|
135
|
+
settle({
|
|
136
|
+
file,
|
|
137
|
+
tests: [
|
|
138
|
+
{
|
|
139
|
+
name: 'worker',
|
|
140
|
+
fullName: `${file} worker`,
|
|
141
|
+
status: 'failed',
|
|
142
|
+
durationMs: 0,
|
|
143
|
+
error: {
|
|
144
|
+
message,
|
|
145
|
+
stack: message
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
worker.once('message', onMessage);
|
|
153
|
+
worker.once('error', onError);
|
|
154
|
+
worker.once('exit', onExit);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resolveMaxWorkers(value) {
|
|
159
|
+
const parsed = Number(value);
|
|
160
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
return Math.floor(parsed);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
runTests
|
|
168
|
+
};
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
const { performance } = require('perf_hooks');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { createExpect } = require('./expect');
|
|
4
|
+
const { createModuleLoader } = require('./module-loader');
|
|
5
|
+
const { installTestEnvironment } = require('./environment');
|
|
6
|
+
const { createSnapshotState } = require('./snapshots');
|
|
7
|
+
const { createTestUtils } = require('./test-utils');
|
|
8
|
+
|
|
9
|
+
const INTENT_PHASE_ALIASES = {
|
|
10
|
+
arrange: ['arrange', 'given', 'context', 'setup'],
|
|
11
|
+
act: ['act', 'when', 'run', 'infer'],
|
|
12
|
+
assert: ['assert', 'then', 'verify'],
|
|
13
|
+
cleanup: ['cleanup', 'finally', 'teardown']
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const MEME_INTENT_PHASE_ALIASES = {
|
|
17
|
+
arrange: ['cook'],
|
|
18
|
+
act: ['yeet'],
|
|
19
|
+
assert: ['vibecheck'],
|
|
20
|
+
cleanup: ['wipe']
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function createSuite(name, parent = null) {
|
|
24
|
+
return {
|
|
25
|
+
name,
|
|
26
|
+
parent,
|
|
27
|
+
suites: [],
|
|
28
|
+
tests: [],
|
|
29
|
+
hooks: {
|
|
30
|
+
beforeAll: [],
|
|
31
|
+
beforeEach: [],
|
|
32
|
+
afterEach: [],
|
|
33
|
+
afterAll: []
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function collectAndRun(filePath, options = {}) {
|
|
39
|
+
const root = createSuite('__root__', null);
|
|
40
|
+
let currentSuite = root;
|
|
41
|
+
const projectRoot = path.resolve(options.cwd || process.cwd());
|
|
42
|
+
const setupFiles = resolveSetupFiles(options.setupFiles, projectRoot);
|
|
43
|
+
const moduleLoader = createModuleLoader({
|
|
44
|
+
cwd: projectRoot,
|
|
45
|
+
tsconfigPath: options.tsconfigPath
|
|
46
|
+
});
|
|
47
|
+
const environment = installTestEnvironment(options.environment || 'node');
|
|
48
|
+
const snapshotState = createSnapshotState(path.resolve(filePath), {
|
|
49
|
+
updateSnapshots: Boolean(options.updateSnapshots)
|
|
50
|
+
});
|
|
51
|
+
const testUtils = createTestUtils({ moduleLoader });
|
|
52
|
+
const runtimeExpect = createExpect({ snapshotState });
|
|
53
|
+
|
|
54
|
+
if (typeof environment.beforeEach === 'function') {
|
|
55
|
+
root.hooks.beforeEach.push(environment.beforeEach);
|
|
56
|
+
}
|
|
57
|
+
if (typeof environment.afterEach === 'function') {
|
|
58
|
+
root.hooks.afterEach.push(environment.afterEach);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const previousGlobals = installGlobals({
|
|
62
|
+
describe(name, fn) {
|
|
63
|
+
if (typeof fn !== 'function') {
|
|
64
|
+
throw new Error(`describe(${name}) requires a callback`);
|
|
65
|
+
}
|
|
66
|
+
const suite = createSuite(name, currentSuite);
|
|
67
|
+
currentSuite.suites.push(suite);
|
|
68
|
+
const parent = currentSuite;
|
|
69
|
+
currentSuite = suite;
|
|
70
|
+
try {
|
|
71
|
+
fn();
|
|
72
|
+
} finally {
|
|
73
|
+
currentSuite = parent;
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
test(name, fn) {
|
|
77
|
+
if (typeof fn !== 'function') {
|
|
78
|
+
throw new Error(`test(${name}) requires a callback`);
|
|
79
|
+
}
|
|
80
|
+
currentSuite.tests.push({ name, fn });
|
|
81
|
+
},
|
|
82
|
+
intent(name, define) {
|
|
83
|
+
if (typeof define !== 'function') {
|
|
84
|
+
throw new Error(`intent(${name}) requires a callback`);
|
|
85
|
+
}
|
|
86
|
+
const intentTest = createIntentTest(name, define, {
|
|
87
|
+
noMemes: Boolean(options.noMemes)
|
|
88
|
+
});
|
|
89
|
+
currentSuite.tests.push(intentTest);
|
|
90
|
+
},
|
|
91
|
+
beforeAll(fn) {
|
|
92
|
+
currentSuite.hooks.beforeAll.push(fn);
|
|
93
|
+
},
|
|
94
|
+
beforeEach(fn) {
|
|
95
|
+
currentSuite.hooks.beforeEach.push(fn);
|
|
96
|
+
},
|
|
97
|
+
afterEach(fn) {
|
|
98
|
+
currentSuite.hooks.afterEach.push(fn);
|
|
99
|
+
},
|
|
100
|
+
afterAll(fn) {
|
|
101
|
+
currentSuite.hooks.afterAll.push(fn);
|
|
102
|
+
},
|
|
103
|
+
expect: runtimeExpect,
|
|
104
|
+
...testUtils
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
let loadError = null;
|
|
108
|
+
try {
|
|
109
|
+
for (const setupFile of setupFiles) {
|
|
110
|
+
moduleLoader.loadFile(setupFile);
|
|
111
|
+
}
|
|
112
|
+
moduleLoader.loadFile(filePath);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
loadError = normalizeError(error);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (loadError) {
|
|
118
|
+
restoreGlobals(previousGlobals);
|
|
119
|
+
testUtils.restoreAllMocks();
|
|
120
|
+
environment.teardown();
|
|
121
|
+
moduleLoader.restore();
|
|
122
|
+
return {
|
|
123
|
+
file: filePath,
|
|
124
|
+
tests: [
|
|
125
|
+
{
|
|
126
|
+
name: 'load',
|
|
127
|
+
fullName: `${filePath} load`,
|
|
128
|
+
status: 'failed',
|
|
129
|
+
durationMs: 0,
|
|
130
|
+
error: loadError
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const results = [];
|
|
137
|
+
const runOptions = {
|
|
138
|
+
matchRegex: options.match ? new RegExp(options.match) : null,
|
|
139
|
+
allowedFullNames: toSet(options.allowedFullNames),
|
|
140
|
+
snapshotState
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return runSuite(root, [root], results, runOptions)
|
|
144
|
+
.then(() => {
|
|
145
|
+
snapshotState.save();
|
|
146
|
+
return { file: filePath, tests: results };
|
|
147
|
+
})
|
|
148
|
+
.finally(() => {
|
|
149
|
+
restoreGlobals(previousGlobals);
|
|
150
|
+
testUtils.restoreAllMocks();
|
|
151
|
+
environment.teardown();
|
|
152
|
+
moduleLoader.restore();
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function resolveSetupFiles(setupFiles, cwd) {
|
|
157
|
+
if (!Array.isArray(setupFiles) || setupFiles.length === 0) {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return setupFiles.map((file) => path.resolve(cwd, file));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function runSuite(suite, lineage, results, options) {
|
|
165
|
+
const nextLineage = suite.name === '__root__' ? lineage : [...lineage, suite];
|
|
166
|
+
|
|
167
|
+
let beforeAllFailed = false;
|
|
168
|
+
for (const hook of suite.hooks.beforeAll) {
|
|
169
|
+
try {
|
|
170
|
+
await hook();
|
|
171
|
+
} catch (error) {
|
|
172
|
+
beforeAllFailed = true;
|
|
173
|
+
pushHookFailure(results, nextLineage, 'beforeAll', error);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!beforeAllFailed) {
|
|
179
|
+
for (const test of suite.tests) {
|
|
180
|
+
const start = performance.now();
|
|
181
|
+
let status = 'passed';
|
|
182
|
+
let error = null;
|
|
183
|
+
const testName = [...formatLineage(nextLineage), test.name].join(' > ');
|
|
184
|
+
let beforeEachSucceeded = false;
|
|
185
|
+
const shouldRun = shouldRunTest(testName, options);
|
|
186
|
+
|
|
187
|
+
if (!shouldRun) {
|
|
188
|
+
results.push({
|
|
189
|
+
name: test.name,
|
|
190
|
+
fullName: testName,
|
|
191
|
+
status: 'skipped',
|
|
192
|
+
durationMs: 0,
|
|
193
|
+
error: null
|
|
194
|
+
});
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (options.snapshotState) {
|
|
199
|
+
options.snapshotState.setCurrentTestName(testName);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const beforeEachHooks = collectHooks(nextLineage, 'beforeEach', false);
|
|
204
|
+
for (const hook of beforeEachHooks) {
|
|
205
|
+
await hook();
|
|
206
|
+
}
|
|
207
|
+
beforeEachSucceeded = true;
|
|
208
|
+
|
|
209
|
+
await test.fn();
|
|
210
|
+
} catch (err) {
|
|
211
|
+
status = 'failed';
|
|
212
|
+
error = normalizeError(err);
|
|
213
|
+
} finally {
|
|
214
|
+
if (options.snapshotState) {
|
|
215
|
+
options.snapshotState.clearCurrentTestName();
|
|
216
|
+
}
|
|
217
|
+
if (beforeEachSucceeded) {
|
|
218
|
+
const afterEachHooks = collectHooks(nextLineage, 'afterEach', true);
|
|
219
|
+
for (const hook of afterEachHooks) {
|
|
220
|
+
try {
|
|
221
|
+
await hook();
|
|
222
|
+
} catch (err) {
|
|
223
|
+
if (status !== 'failed') {
|
|
224
|
+
status = 'failed';
|
|
225
|
+
error = normalizeError(err);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
|
233
|
+
results.push({
|
|
234
|
+
name: test.name,
|
|
235
|
+
fullName: testName,
|
|
236
|
+
status,
|
|
237
|
+
durationMs,
|
|
238
|
+
error
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const child of suite.suites) {
|
|
243
|
+
await runSuite(child, nextLineage, results, options);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const hook of suite.hooks.afterAll) {
|
|
248
|
+
try {
|
|
249
|
+
await hook();
|
|
250
|
+
} catch (error) {
|
|
251
|
+
pushHookFailure(results, nextLineage, 'afterAll', error);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function collectHooks(lineage, kind, reverse) {
|
|
257
|
+
const suites = reverse ? [...lineage].reverse() : lineage;
|
|
258
|
+
const hooks = [];
|
|
259
|
+
for (const suite of suites) {
|
|
260
|
+
hooks.push(...suite.hooks[kind]);
|
|
261
|
+
}
|
|
262
|
+
return hooks;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function installGlobals(api) {
|
|
266
|
+
const names = [
|
|
267
|
+
'describe',
|
|
268
|
+
'test',
|
|
269
|
+
'it',
|
|
270
|
+
'intent',
|
|
271
|
+
'beforeAll',
|
|
272
|
+
'beforeEach',
|
|
273
|
+
'afterEach',
|
|
274
|
+
'afterAll',
|
|
275
|
+
'expect',
|
|
276
|
+
'fn',
|
|
277
|
+
'spyOn',
|
|
278
|
+
'mock',
|
|
279
|
+
'unmock',
|
|
280
|
+
'clearAllMocks',
|
|
281
|
+
'resetAllMocks',
|
|
282
|
+
'restoreAllMocks'
|
|
283
|
+
];
|
|
284
|
+
const previous = {};
|
|
285
|
+
for (const name of names) {
|
|
286
|
+
previous[name] = global[name];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
global.describe = api.describe;
|
|
290
|
+
global.test = api.test;
|
|
291
|
+
global.it = api.test;
|
|
292
|
+
global.intent = api.intent;
|
|
293
|
+
global.beforeAll = api.beforeAll;
|
|
294
|
+
global.beforeEach = api.beforeEach;
|
|
295
|
+
global.afterEach = api.afterEach;
|
|
296
|
+
global.afterAll = api.afterAll;
|
|
297
|
+
global.expect = api.expect;
|
|
298
|
+
global.fn = api.fn;
|
|
299
|
+
global.spyOn = api.spyOn;
|
|
300
|
+
global.mock = api.mock;
|
|
301
|
+
global.unmock = api.unmock;
|
|
302
|
+
global.clearAllMocks = api.clearAllMocks;
|
|
303
|
+
global.resetAllMocks = api.resetAllMocks;
|
|
304
|
+
global.restoreAllMocks = api.restoreAllMocks;
|
|
305
|
+
|
|
306
|
+
return previous;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function createIntentTest(name, define, options = {}) {
|
|
310
|
+
const phases = [];
|
|
311
|
+
let mode = 'arrange';
|
|
312
|
+
const phaseAliases = resolveIntentPhaseAliases(options);
|
|
313
|
+
|
|
314
|
+
const pushPhase = (kind, alias, description, fn) => {
|
|
315
|
+
let normalizedDescription = description;
|
|
316
|
+
let normalizedFn = fn;
|
|
317
|
+
|
|
318
|
+
if (typeof normalizedDescription === 'function') {
|
|
319
|
+
normalizedFn = normalizedDescription;
|
|
320
|
+
normalizedDescription = `${alias} phase ${phases.filter((phase) => phase.kind === kind).length + 1}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (typeof normalizedFn !== 'function') {
|
|
324
|
+
throw new Error(`${kind}(...) requires a callback`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (kind === 'arrange' && mode !== 'arrange') {
|
|
328
|
+
throw new Error('arrange(...) cannot be declared after act(...) or assert(...)');
|
|
329
|
+
}
|
|
330
|
+
if (kind === 'act') {
|
|
331
|
+
if (mode === 'assert') {
|
|
332
|
+
throw new Error('act(...) cannot be declared after assert(...)');
|
|
333
|
+
}
|
|
334
|
+
mode = 'act';
|
|
335
|
+
}
|
|
336
|
+
if (kind === 'assert') {
|
|
337
|
+
mode = 'assert';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
phases.push({ kind, alias, description: String(normalizedDescription), fn: normalizedFn });
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const dsl = {};
|
|
344
|
+
for (const [kind, aliases] of Object.entries(phaseAliases)) {
|
|
345
|
+
for (const alias of aliases) {
|
|
346
|
+
dsl[alias] = (description, fn) => {
|
|
347
|
+
pushPhase(kind, alias, description, fn);
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
define(dsl);
|
|
353
|
+
|
|
354
|
+
if (!phases.some((phase) => phase.kind === 'assert')) {
|
|
355
|
+
throw new Error(`intent(${name}) must define at least one assert(...) phase`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
name,
|
|
360
|
+
async fn() {
|
|
361
|
+
const context = {};
|
|
362
|
+
const cleanupPhases = phases.filter((phase) => phase.kind === 'cleanup');
|
|
363
|
+
const runPhases = phases.filter((phase) => phase.kind !== 'cleanup');
|
|
364
|
+
|
|
365
|
+
let runError = null;
|
|
366
|
+
for (const phase of runPhases) {
|
|
367
|
+
try {
|
|
368
|
+
await phase.fn(context);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
runError = annotatePhaseError(error, phase);
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let cleanupError = null;
|
|
376
|
+
for (const phase of cleanupPhases) {
|
|
377
|
+
try {
|
|
378
|
+
await phase.fn(context);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
cleanupError = annotatePhaseError(error, phase);
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (runError) {
|
|
386
|
+
throw runError;
|
|
387
|
+
}
|
|
388
|
+
if (cleanupError) {
|
|
389
|
+
throw cleanupError;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function resolveIntentPhaseAliases(options = {}) {
|
|
396
|
+
const aliases = {};
|
|
397
|
+
const includeMemes = !options.noMemes;
|
|
398
|
+
|
|
399
|
+
for (const [kind, baseAliases] of Object.entries(INTENT_PHASE_ALIASES)) {
|
|
400
|
+
const merged = [...baseAliases];
|
|
401
|
+
if (includeMemes) {
|
|
402
|
+
merged.push(...(MEME_INTENT_PHASE_ALIASES[kind] || []));
|
|
403
|
+
}
|
|
404
|
+
aliases[kind] = merged;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return aliases;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function annotatePhaseError(error, phase) {
|
|
411
|
+
const message = `Intent phase failed: ${String(phase.alias || phase.kind).toUpperCase()} ${phase.description}`;
|
|
412
|
+
if (error && typeof error === 'object' && error.message) {
|
|
413
|
+
error.message = `${message}\n${error.message}`;
|
|
414
|
+
return error;
|
|
415
|
+
}
|
|
416
|
+
return new Error(message);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function restoreGlobals(previous) {
|
|
420
|
+
for (const [name, value] of Object.entries(previous)) {
|
|
421
|
+
if (value === undefined) {
|
|
422
|
+
delete global[name];
|
|
423
|
+
} else {
|
|
424
|
+
global[name] = value;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function normalizeError(error) {
|
|
430
|
+
return {
|
|
431
|
+
message: String(error?.message || error),
|
|
432
|
+
stack: String(error?.stack || error)
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function pushHookFailure(results, lineage, hookName, error) {
|
|
437
|
+
const fullName = [...formatLineage(lineage), hookName].join(' > ') || hookName;
|
|
438
|
+
results.push({
|
|
439
|
+
name: hookName,
|
|
440
|
+
fullName,
|
|
441
|
+
status: 'failed',
|
|
442
|
+
durationMs: 0,
|
|
443
|
+
error: normalizeError(error)
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function formatLineage(lineage) {
|
|
448
|
+
return lineage
|
|
449
|
+
.map((item) => item.name)
|
|
450
|
+
.filter((name) => name !== '__root__');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function toSet(values) {
|
|
454
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
return new Set(values);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function shouldRunTest(testName, options) {
|
|
461
|
+
if (options.allowedFullNames && !options.allowedFullNames.has(testName)) {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
if (options.matchRegex && !options.matchRegex.test(testName)) {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
module.exports = {
|
|
471
|
+
collectAndRun
|
|
472
|
+
};
|