@test-station/plugin-source-analysis 0.1.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/package.json +18 -0
- package/src/index.js +371 -0
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@test-station/plugin-source-analysis",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"acorn": "^8.15.0"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "node ../../scripts/check-package.mjs ./src/index.js",
|
|
16
|
+
"lint": "node ../../scripts/lint-syntax.mjs ./src"
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import * as acorn from 'acorn';
|
|
4
|
+
|
|
5
|
+
export const id = 'source-analysis';
|
|
6
|
+
export const description = 'Enriches test results with assertions, setup, mocks, and source snippets extracted from test files.';
|
|
7
|
+
|
|
8
|
+
export function createSourceAnalysisPlugin(options = {}) {
|
|
9
|
+
const cache = new Map();
|
|
10
|
+
const config = {
|
|
11
|
+
includeSharedSetup: options.includeSharedSetup !== false,
|
|
12
|
+
includeSharedMocks: options.includeSharedMocks !== false,
|
|
13
|
+
includeSourceSnippet: options.includeSourceSnippet !== false,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
id,
|
|
18
|
+
description,
|
|
19
|
+
phase: 5,
|
|
20
|
+
async enrichTest({ test }) {
|
|
21
|
+
if (!test?.file || !looksLikeJavaScriptFile(test.file)) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const analysis = analyzeTestFile(test.file, cache);
|
|
26
|
+
if (!analysis) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const matched = matchAnalyzedTest(analysis, test);
|
|
31
|
+
const sharedSetup = config.includeSharedSetup
|
|
32
|
+
? analysis.sharedSetup.map((entry) => entry.summary)
|
|
33
|
+
: [];
|
|
34
|
+
const sharedMocks = config.includeSharedMocks
|
|
35
|
+
? analysis.sharedMocks.map((entry) => entry.summary)
|
|
36
|
+
: [];
|
|
37
|
+
|
|
38
|
+
const patch = {
|
|
39
|
+
rawDetails: {
|
|
40
|
+
sourceAnalysis: {
|
|
41
|
+
file: analysis.filePath,
|
|
42
|
+
matched: Boolean(matched),
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
if (matched) {
|
|
48
|
+
patch.assertions = matched.assertions || [];
|
|
49
|
+
patch.setup = [
|
|
50
|
+
...sharedSetup,
|
|
51
|
+
...(matched.setup || []).map((entry) => entry.summary),
|
|
52
|
+
];
|
|
53
|
+
patch.mocks = [
|
|
54
|
+
...sharedMocks,
|
|
55
|
+
...(matched.mocks || []).map((entry) => entry.summary),
|
|
56
|
+
];
|
|
57
|
+
if (config.includeSourceSnippet && matched.snippet) {
|
|
58
|
+
patch.sourceSnippet = matched.snippet;
|
|
59
|
+
}
|
|
60
|
+
if (!test.fullName && matched.fullName) {
|
|
61
|
+
patch.fullName = matched.fullName;
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
patch.setup = sharedSetup;
|
|
65
|
+
patch.mocks = sharedMocks;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return patch;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function analyzeTestFile(filePath, cache) {
|
|
74
|
+
const resolvedPath = realPathSafe(filePath);
|
|
75
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (cache.has(resolvedPath)) {
|
|
79
|
+
return cache.get(resolvedPath);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const source = fs.readFileSync(resolvedPath, 'utf8');
|
|
83
|
+
const ast = parseSource(source);
|
|
84
|
+
if (!ast) {
|
|
85
|
+
cache.set(resolvedPath, null);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const analysis = {
|
|
90
|
+
filePath: resolvedPath,
|
|
91
|
+
source,
|
|
92
|
+
lines: source.split(/\r?\n/),
|
|
93
|
+
sharedSetup: [],
|
|
94
|
+
sharedMocks: [],
|
|
95
|
+
tests: [],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
traverseNode(ast, analysis, { titles: [], currentTest: null });
|
|
99
|
+
cache.set(resolvedPath, analysis);
|
|
100
|
+
return analysis;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseSource(source) {
|
|
104
|
+
const parseOptions = {
|
|
105
|
+
ecmaVersion: 'latest',
|
|
106
|
+
locations: true,
|
|
107
|
+
allowHashBang: true,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
return acorn.parse(source, {
|
|
112
|
+
...parseOptions,
|
|
113
|
+
sourceType: 'module',
|
|
114
|
+
});
|
|
115
|
+
} catch {
|
|
116
|
+
try {
|
|
117
|
+
return acorn.parse(source, {
|
|
118
|
+
...parseOptions,
|
|
119
|
+
sourceType: 'script',
|
|
120
|
+
});
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function traverseNode(node, analysis, context) {
|
|
128
|
+
if (!node || typeof node.type !== 'string') {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (node.type === 'CallExpression') {
|
|
133
|
+
const classification = classifyCall(node.callee);
|
|
134
|
+
const callback = getCallbackFunction(node.arguments || []);
|
|
135
|
+
const title = getCallTitle(node.arguments || []);
|
|
136
|
+
|
|
137
|
+
if (classification === 'describe' && callback?.body?.type === 'BlockStatement') {
|
|
138
|
+
const nextContext = {
|
|
139
|
+
...context,
|
|
140
|
+
titles: title ? [...context.titles, title] : [...context.titles],
|
|
141
|
+
};
|
|
142
|
+
for (const statement of callback.body.body) {
|
|
143
|
+
traverseNode(statement, analysis, nextContext);
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (classification === 'hook') {
|
|
149
|
+
const entry = {
|
|
150
|
+
summary: summarizeHook(node, analysis),
|
|
151
|
+
snippet: extractSnippet(node, analysis),
|
|
152
|
+
};
|
|
153
|
+
if (context.currentTest) {
|
|
154
|
+
context.currentTest.setup.push(entry);
|
|
155
|
+
} else {
|
|
156
|
+
analysis.sharedSetup.push(entry);
|
|
157
|
+
}
|
|
158
|
+
if (callback?.body?.type === 'BlockStatement') {
|
|
159
|
+
for (const statement of callback.body.body) {
|
|
160
|
+
traverseNode(statement, analysis, context);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (classification === 'test') {
|
|
167
|
+
const resolvedTitle = title || dynamicLabel('test');
|
|
168
|
+
const record = {
|
|
169
|
+
title: resolvedTitle,
|
|
170
|
+
fullName: [...context.titles, resolvedTitle].join(' '),
|
|
171
|
+
line: node.loc?.start?.line || null,
|
|
172
|
+
column: (node.loc?.start?.column || 0) + 1,
|
|
173
|
+
snippet: extractSnippet(node, analysis),
|
|
174
|
+
assertions: [],
|
|
175
|
+
setup: [],
|
|
176
|
+
mocks: [],
|
|
177
|
+
};
|
|
178
|
+
analysis.tests.push(record);
|
|
179
|
+
if (callback?.body?.type === 'BlockStatement') {
|
|
180
|
+
for (const statement of callback.body.body) {
|
|
181
|
+
traverseNode(statement, analysis, {
|
|
182
|
+
...context,
|
|
183
|
+
currentTest: record,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (context.currentTest) {
|
|
191
|
+
if (isAssertionCall(node)) {
|
|
192
|
+
context.currentTest.assertions.push(trimForReport(extractSnippet(node, analysis), 240));
|
|
193
|
+
}
|
|
194
|
+
const mockSummary = summarizeMock(node);
|
|
195
|
+
if (mockSummary) {
|
|
196
|
+
context.currentTest.mocks.push({
|
|
197
|
+
summary: mockSummary,
|
|
198
|
+
snippet: extractSnippet(node, analysis),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
const sharedMock = summarizeMock(node);
|
|
203
|
+
if (sharedMock) {
|
|
204
|
+
analysis.sharedMocks.push({
|
|
205
|
+
summary: sharedMock,
|
|
206
|
+
snippet: extractSnippet(node, analysis),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const value of Object.values(node)) {
|
|
213
|
+
if (!value) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (Array.isArray(value)) {
|
|
217
|
+
for (const item of value) {
|
|
218
|
+
if (item && typeof item.type === 'string') {
|
|
219
|
+
traverseNode(item, analysis, context);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (value && typeof value.type === 'string') {
|
|
225
|
+
traverseNode(value, analysis, context);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function classifyCall(callee) {
|
|
231
|
+
const chain = getCalleeChain(callee);
|
|
232
|
+
if (chain.length === 0) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
if (chain[0] === 'describe' || (chain[0] === 'test' && chain.includes('describe'))) {
|
|
236
|
+
return 'describe';
|
|
237
|
+
}
|
|
238
|
+
if (['beforeEach', 'beforeAll', 'afterEach', 'afterAll'].includes(chain[0])) {
|
|
239
|
+
return 'hook';
|
|
240
|
+
}
|
|
241
|
+
if (['test', 'it'].includes(chain[0])) {
|
|
242
|
+
return 'test';
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function getCalleeChain(node) {
|
|
248
|
+
if (!node) {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
if (node.type === 'Identifier') {
|
|
252
|
+
return [node.name];
|
|
253
|
+
}
|
|
254
|
+
if (node.type === 'MemberExpression' && !node.computed) {
|
|
255
|
+
return [...getCalleeChain(node.object), node.property.name];
|
|
256
|
+
}
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function getCallbackFunction(args) {
|
|
261
|
+
for (let index = args.length - 1; index >= 0; index -= 1) {
|
|
262
|
+
const arg = args[index];
|
|
263
|
+
if (arg?.type === 'ArrowFunctionExpression' || arg?.type === 'FunctionExpression') {
|
|
264
|
+
return arg;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function getCallTitle(args) {
|
|
271
|
+
const first = args[0];
|
|
272
|
+
if (!first) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
if (first.type === 'Literal' && typeof first.value === 'string') {
|
|
276
|
+
return first.value;
|
|
277
|
+
}
|
|
278
|
+
if (first.type === 'TemplateLiteral' && first.expressions.length === 0) {
|
|
279
|
+
return first.quasis.map((part) => part.value.cooked || '').join('');
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function dynamicLabel(kind) {
|
|
285
|
+
return `[dynamic ${kind}]`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isAssertionCall(node) {
|
|
289
|
+
const chain = getCalleeChain(node.callee);
|
|
290
|
+
if (chain[0] === 'expect' || chain[0] === 'assert') {
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
return chain[0] === 't' && chain.length > 1;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function summarizeMock(node) {
|
|
297
|
+
const chain = getCalleeChain(node.callee);
|
|
298
|
+
if ((chain[0] === 'vi' || chain[0] === 'jest') && chain[1] === 'mock') {
|
|
299
|
+
return `mock module ${getLiteralPreview(node.arguments?.[0])}`;
|
|
300
|
+
}
|
|
301
|
+
if (chain[0] === 'vi' && chain[1] === 'spyOn') {
|
|
302
|
+
return `spyOn ${getLiteralPreview(node.arguments?.[0])}.${getLiteralPreview(node.arguments?.[1])}`;
|
|
303
|
+
}
|
|
304
|
+
if (chain[0] === 'mock' && chain[1] === 'module') {
|
|
305
|
+
return `mock module ${getLiteralPreview(node.arguments?.[0])}`;
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function summarizeHook(node, analysis) {
|
|
311
|
+
const chain = getCalleeChain(node.callee);
|
|
312
|
+
const label = chain[0] || 'hook';
|
|
313
|
+
return `${label}: ${trimForReport(extractSnippet(node, analysis), 200)}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function getLiteralPreview(node) {
|
|
317
|
+
if (!node) {
|
|
318
|
+
return '<dynamic>';
|
|
319
|
+
}
|
|
320
|
+
if (node.type === 'Literal') {
|
|
321
|
+
return typeof node.value === 'string' ? node.value : String(node.value);
|
|
322
|
+
}
|
|
323
|
+
if (node.type === 'Identifier') {
|
|
324
|
+
return node.name;
|
|
325
|
+
}
|
|
326
|
+
return '<dynamic>';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function extractSnippet(node, analysis) {
|
|
330
|
+
if (!node?.loc) {
|
|
331
|
+
return '';
|
|
332
|
+
}
|
|
333
|
+
const startLine = Math.max(1, node.loc.start.line);
|
|
334
|
+
const endLine = Math.min(analysis.lines.length, node.loc.end.line);
|
|
335
|
+
const snippet = analysis.lines.slice(startLine - 1, endLine).join('\n').trim();
|
|
336
|
+
return trimForReport(snippet, 320);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function matchAnalyzedTest(analysis, test) {
|
|
340
|
+
if (Number.isFinite(test.line)) {
|
|
341
|
+
const byLine = analysis.tests.find((entry) => entry.line === test.line);
|
|
342
|
+
if (byLine) {
|
|
343
|
+
return byLine;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (typeof test.fullName === 'string' && test.fullName.length > 0) {
|
|
347
|
+
return analysis.tests.find((entry) => entry.fullName === test.fullName)
|
|
348
|
+
|| analysis.tests.find((entry) => entry.title === test.name);
|
|
349
|
+
}
|
|
350
|
+
return analysis.tests.find((entry) => entry.title === test.name) || null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function looksLikeJavaScriptFile(filePath) {
|
|
354
|
+
return /\.[cm]?[jt]sx?$/.test(String(filePath || ''));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function realPathSafe(filePath) {
|
|
358
|
+
try {
|
|
359
|
+
return fs.realpathSync(filePath);
|
|
360
|
+
} catch {
|
|
361
|
+
return path.resolve(filePath);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function trimForReport(value, maxLength) {
|
|
366
|
+
const input = String(value || '').trim();
|
|
367
|
+
if (input.length <= maxLength) {
|
|
368
|
+
return input;
|
|
369
|
+
}
|
|
370
|
+
return `${input.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
|
371
|
+
}
|