@quenty/nevermore-cli 4.18.0 → 4.19.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 +11 -0
- package/dist/commands/tools-command/ci-post-test-results.d.ts.map +1 -1
- package/dist/commands/tools-command/ci-post-test-results.js +18 -0
- package/dist/commands/tools-command/ci-post-test-results.js.map +1 -1
- package/dist/commands/tools-command/strip-sourcemap-jest-command.d.ts.map +1 -1
- package/dist/commands/tools-command/strip-sourcemap-jest-command.js.map +1 -1
- package/dist/utils/linting/parsers/moonwave-parser.d.ts.map +1 -1
- package/dist/utils/linting/parsers/moonwave-parser.js +2 -6
- package/dist/utils/linting/parsers/moonwave-parser.js.map +1 -1
- package/dist/utils/sourcemap/index.d.ts +4 -0
- package/dist/utils/sourcemap/index.d.ts.map +1 -0
- package/dist/utils/sourcemap/index.js +3 -0
- package/dist/utils/sourcemap/index.js.map +1 -0
- package/dist/utils/sourcemap/sourcemap-loader.d.ts +10 -0
- package/dist/utils/sourcemap/sourcemap-loader.d.ts.map +1 -0
- package/dist/utils/sourcemap/sourcemap-loader.js +29 -0
- package/dist/utils/sourcemap/sourcemap-loader.js.map +1 -0
- package/dist/utils/sourcemap/sourcemap-resolver.d.ts +30 -0
- package/dist/utils/sourcemap/sourcemap-resolver.d.ts.map +1 -0
- package/dist/utils/sourcemap/sourcemap-resolver.js +58 -0
- package/dist/utils/sourcemap/sourcemap-resolver.js.map +1 -0
- package/dist/utils/sourcemap/sourcemap-resolver.test.d.ts +2 -0
- package/dist/utils/sourcemap/sourcemap-resolver.test.d.ts.map +1 -0
- package/dist/utils/sourcemap/sourcemap-resolver.test.js +106 -0
- package/dist/utils/sourcemap/sourcemap-resolver.test.js.map +1 -0
- package/dist/utils/sourcemap/sourcemap-types.d.ts +13 -0
- package/dist/utils/sourcemap/sourcemap-types.d.ts.map +1 -0
- package/dist/utils/sourcemap/sourcemap-types.js +2 -0
- package/dist/utils/sourcemap/sourcemap-types.js.map +1 -0
- package/dist/utils/testing/parsers/index.d.ts +3 -0
- package/dist/utils/testing/parsers/index.d.ts.map +1 -0
- package/dist/utils/testing/parsers/index.js +3 -0
- package/dist/utils/testing/parsers/index.js.map +1 -0
- package/dist/utils/testing/parsers/jest-lua-parser.d.ts +41 -0
- package/dist/utils/testing/parsers/jest-lua-parser.d.ts.map +1 -0
- package/dist/utils/testing/parsers/jest-lua-parser.js +222 -0
- package/dist/utils/testing/parsers/jest-lua-parser.js.map +1 -0
- package/dist/utils/testing/parsers/jest-lua-parser.test.d.ts +2 -0
- package/dist/utils/testing/parsers/jest-lua-parser.test.d.ts.map +1 -0
- package/dist/utils/testing/parsers/jest-lua-parser.test.js +297 -0
- package/dist/utils/testing/parsers/jest-lua-parser.test.js.map +1 -0
- package/dist/utils/testing/parsers/roblox-path-resolver.d.ts +24 -0
- package/dist/utils/testing/parsers/roblox-path-resolver.d.ts.map +1 -0
- package/dist/utils/testing/parsers/roblox-path-resolver.js +71 -0
- package/dist/utils/testing/parsers/roblox-path-resolver.js.map +1 -0
- package/dist/utils/testing/parsers/roblox-path-resolver.test.d.ts +2 -0
- package/dist/utils/testing/parsers/roblox-path-resolver.test.d.ts.map +1 -0
- package/dist/utils/testing/parsers/roblox-path-resolver.test.js +76 -0
- package/dist/utils/testing/parsers/roblox-path-resolver.test.js.map +1 -0
- package/dist/utils/testing/test-log-parser.d.ts.map +1 -1
- package/dist/utils/testing/test-log-parser.js +2 -1
- package/dist/utils/testing/test-log-parser.js.map +1 -1
- package/package.json +6 -6
- package/src/commands/tools-command/ci-post-test-results.ts +26 -0
- package/src/commands/tools-command/strip-sourcemap-jest-command.ts +1 -7
- package/src/utils/linting/parsers/moonwave-parser.ts +2 -7
- package/src/utils/sourcemap/index.ts +3 -0
- package/src/utils/sourcemap/sourcemap-loader.ts +33 -0
- package/src/utils/sourcemap/sourcemap-resolver.test.ts +150 -0
- package/src/utils/sourcemap/sourcemap-resolver.ts +75 -0
- package/src/utils/sourcemap/sourcemap-types.ts +12 -0
- package/src/utils/testing/parsers/index.ts +2 -0
- package/src/utils/testing/parsers/jest-lua-parser.test.ts +350 -0
- package/src/utils/testing/parsers/jest-lua-parser.ts +279 -0
- package/src/utils/testing/parsers/roblox-path-resolver.test.ts +129 -0
- package/src/utils/testing/parsers/roblox-path-resolver.ts +85 -0
- package/src/utils/testing/test-log-parser.ts +3 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { parseJestLuaOutput } from './jest-lua-parser.js';
|
|
3
|
+
import { SourcemapResolver } from '../../sourcemap/index.js';
|
|
4
|
+
import type { SourcemapNode } from '../../sourcemap/index.js';
|
|
5
|
+
|
|
6
|
+
// Mock fs.readFileSync for _findTestLineInFile
|
|
7
|
+
vi.mock('fs', () => ({
|
|
8
|
+
readFileSync: vi.fn(() => {
|
|
9
|
+
throw new Error('ENOENT');
|
|
10
|
+
}),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
describe('parseJestLuaOutput', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('parses a single assertion failure with at location', () => {
|
|
19
|
+
const input = [
|
|
20
|
+
' ● ObservableList > add and remove > should track count',
|
|
21
|
+
'',
|
|
22
|
+
' expect(received).toEqual(expected)',
|
|
23
|
+
'',
|
|
24
|
+
' Expected: 3',
|
|
25
|
+
' Received: 2',
|
|
26
|
+
'',
|
|
27
|
+
' at ServerScriptService.observablecollection.Shared.ObservableList.spec:45',
|
|
28
|
+
].join('\n');
|
|
29
|
+
|
|
30
|
+
const result = parseJestLuaOutput(input, {
|
|
31
|
+
packageName: 'observablecollection',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(result).toHaveLength(1);
|
|
35
|
+
expect(result[0]).toEqual({
|
|
36
|
+
file: 'src/observablecollection/src/Shared/ObservableList.spec.lua',
|
|
37
|
+
line: 45,
|
|
38
|
+
severity: 'error',
|
|
39
|
+
title: 'ObservableList > add and remove > should track count',
|
|
40
|
+
message: expect.stringContaining('expect(received).toEqual(expected)'),
|
|
41
|
+
source: 'jest-lua',
|
|
42
|
+
});
|
|
43
|
+
expect(result[0].message).toContain('Expected: 3');
|
|
44
|
+
expect(result[0].message).toContain('Received: 2');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('parses multiple failures in one log', () => {
|
|
48
|
+
const input = [
|
|
49
|
+
' ● Suite > test one',
|
|
50
|
+
'',
|
|
51
|
+
' Expected: 1',
|
|
52
|
+
' Received: 2',
|
|
53
|
+
'',
|
|
54
|
+
' at ServerScriptService.mypkg.Shared.Foo.spec:10',
|
|
55
|
+
'',
|
|
56
|
+
' ● Suite > test two',
|
|
57
|
+
'',
|
|
58
|
+
' Expected: true',
|
|
59
|
+
' Received: false',
|
|
60
|
+
'',
|
|
61
|
+
' at ServerScriptService.mypkg.Shared.Bar.spec:20',
|
|
62
|
+
].join('\n');
|
|
63
|
+
|
|
64
|
+
const result = parseJestLuaOutput(input, { packageName: 'mypkg' });
|
|
65
|
+
|
|
66
|
+
expect(result).toHaveLength(2);
|
|
67
|
+
expect(result[0].title).toBe('Suite > test one');
|
|
68
|
+
expect(result[0].line).toBe(10);
|
|
69
|
+
expect(result[1].title).toBe('Suite > test two');
|
|
70
|
+
expect(result[1].line).toBe(20);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('parses nested 3+ level test hierarchy', () => {
|
|
74
|
+
const input = [
|
|
75
|
+
' ● Level1 > Level2 > Level3 > Level4 > deep test',
|
|
76
|
+
'',
|
|
77
|
+
' failure message',
|
|
78
|
+
'',
|
|
79
|
+
' at ServerScriptService.pkg.Shared.Deep.spec:99',
|
|
80
|
+
].join('\n');
|
|
81
|
+
|
|
82
|
+
const result = parseJestLuaOutput(input, { packageName: 'pkg' });
|
|
83
|
+
|
|
84
|
+
expect(result).toHaveLength(1);
|
|
85
|
+
expect(result[0].title).toBe(
|
|
86
|
+
'Level1 > Level2 > Level3 > Level4 > deep test'
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('falls back to line 1 when no at location and file not found', () => {
|
|
91
|
+
const input = [
|
|
92
|
+
' ● Suite > test without location',
|
|
93
|
+
'',
|
|
94
|
+
' some error happened',
|
|
95
|
+
'',
|
|
96
|
+
'Test Suites: 1 failed, 1 total',
|
|
97
|
+
].join('\n');
|
|
98
|
+
|
|
99
|
+
const result = parseJestLuaOutput(input, { packageName: 'mypkg' });
|
|
100
|
+
|
|
101
|
+
expect(result).toHaveLength(1);
|
|
102
|
+
expect(result[0].line).toBe(1);
|
|
103
|
+
expect(result[0].file).toBe('src/mypkg/src');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('searches spec file for it() call when at location has no line number', async () => {
|
|
107
|
+
const { readFileSync } = await import('fs');
|
|
108
|
+
const mockedReadFileSync = vi.mocked(readFileSync);
|
|
109
|
+
mockedReadFileSync.mockReturnValue(
|
|
110
|
+
[
|
|
111
|
+
'local require = require(script.Parent.loader).load(script)',
|
|
112
|
+
'',
|
|
113
|
+
'describe("Suite", function()',
|
|
114
|
+
' it("my test name", function()',
|
|
115
|
+
' -- test body',
|
|
116
|
+
' end)',
|
|
117
|
+
'end)',
|
|
118
|
+
].join('\n')
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const input = [
|
|
122
|
+
' ● Suite > my test name',
|
|
123
|
+
'',
|
|
124
|
+
' assertion failed',
|
|
125
|
+
'',
|
|
126
|
+
' at ServerScriptService.mypkg.Shared.Foo.spec',
|
|
127
|
+
].join('\n');
|
|
128
|
+
|
|
129
|
+
const result = parseJestLuaOutput(input, { packageName: 'mypkg' });
|
|
130
|
+
|
|
131
|
+
expect(result).toHaveLength(1);
|
|
132
|
+
expect(result[0].file).toBe('src/mypkg/src/Shared/Foo.spec.lua');
|
|
133
|
+
expect(result[0].line).toBe(4);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('parses a runtime error with Stack Begin/Stack End', () => {
|
|
137
|
+
const input = [
|
|
138
|
+
'Error: attempt to index nil with "Connect"',
|
|
139
|
+
'Stack Begin',
|
|
140
|
+
"Script 'ServerScriptService.maid.Shared.Maid.spec', Line 23",
|
|
141
|
+
'Stack End',
|
|
142
|
+
].join('\n');
|
|
143
|
+
|
|
144
|
+
const result = parseJestLuaOutput(input, { packageName: 'maid' });
|
|
145
|
+
|
|
146
|
+
expect(result).toHaveLength(1);
|
|
147
|
+
expect(result[0]).toEqual({
|
|
148
|
+
file: 'src/maid/src/Shared/Maid.spec.lua',
|
|
149
|
+
line: 23,
|
|
150
|
+
severity: 'error',
|
|
151
|
+
title: 'Runtime error',
|
|
152
|
+
message: 'Error: attempt to index nil with "Connect"',
|
|
153
|
+
source: 'jest-lua',
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('parses mixed Jest failures and runtime errors', () => {
|
|
158
|
+
const input = [
|
|
159
|
+
' ● Suite > assertion test',
|
|
160
|
+
'',
|
|
161
|
+
' Expected: 1',
|
|
162
|
+
' Received: 2',
|
|
163
|
+
'',
|
|
164
|
+
' at ServerScriptService.pkg.Shared.Foo.spec:10',
|
|
165
|
+
'',
|
|
166
|
+
'Error: attempt to call nil value',
|
|
167
|
+
'Stack Begin',
|
|
168
|
+
"Script 'ServerScriptService.pkg.Shared.Bar.spec', Line 5",
|
|
169
|
+
'Stack End',
|
|
170
|
+
].join('\n');
|
|
171
|
+
|
|
172
|
+
const result = parseJestLuaOutput(input, { packageName: 'pkg' });
|
|
173
|
+
|
|
174
|
+
expect(result).toHaveLength(2);
|
|
175
|
+
expect(result[0].title).toBe('Suite > assertion test');
|
|
176
|
+
expect(result[0].source).toBe('jest-lua');
|
|
177
|
+
expect(result[1].title).toBe('Runtime error');
|
|
178
|
+
expect(result[1].line).toBe(5);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('returns empty array for all-passing output', () => {
|
|
182
|
+
const input = [
|
|
183
|
+
'PASS ServerScriptService.maid',
|
|
184
|
+
' Maid',
|
|
185
|
+
' ✓ should create (5 ms)',
|
|
186
|
+
' ✓ should destroy (2 ms)',
|
|
187
|
+
'',
|
|
188
|
+
'Test Suites: 1 passed, 1 total',
|
|
189
|
+
'Tests: 2 passed, 2 total',
|
|
190
|
+
].join('\n');
|
|
191
|
+
|
|
192
|
+
const result = parseJestLuaOutput(input, { packageName: 'maid' });
|
|
193
|
+
|
|
194
|
+
expect(result).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('strips ANSI escape codes cleanly', () => {
|
|
198
|
+
const input = [
|
|
199
|
+
' \x1b[31m●\x1b[39m \x1b[1mSuite > test\x1b[22m',
|
|
200
|
+
'',
|
|
201
|
+
' \x1b[31mExpected: 1\x1b[39m',
|
|
202
|
+
' \x1b[32mReceived: 2\x1b[39m',
|
|
203
|
+
'',
|
|
204
|
+
' at ServerScriptService.pkg.Shared.Foo.spec:10',
|
|
205
|
+
].join('\n');
|
|
206
|
+
|
|
207
|
+
const result = parseJestLuaOutput(input, { packageName: 'pkg' });
|
|
208
|
+
|
|
209
|
+
expect(result).toHaveLength(1);
|
|
210
|
+
expect(result[0].title).toBe('Suite > test');
|
|
211
|
+
expect(result[0].line).toBe(10);
|
|
212
|
+
// Ensure no ANSI codes leaked into the message
|
|
213
|
+
expect(result[0].message).not.toMatch(/\x1b/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('handles truncated/incomplete output without crashing', () => {
|
|
217
|
+
const input = [
|
|
218
|
+
' ● Suite > incomplete test',
|
|
219
|
+
'',
|
|
220
|
+
' partial error message',
|
|
221
|
+
// No at-line, no Stack Begin, just ends
|
|
222
|
+
].join('\n');
|
|
223
|
+
|
|
224
|
+
const result = parseJestLuaOutput(input, { packageName: 'pkg' });
|
|
225
|
+
|
|
226
|
+
// Should still emit a diagnostic with fallback location
|
|
227
|
+
expect(result).toHaveLength(1);
|
|
228
|
+
expect(result[0].title).toBe('Suite > incomplete test');
|
|
229
|
+
expect(result[0].line).toBe(1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('handles empty input', () => {
|
|
233
|
+
const result = parseJestLuaOutput('', { packageName: 'pkg' });
|
|
234
|
+
expect(result).toEqual([]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('uses last stack frame for location in multi-frame stack', () => {
|
|
238
|
+
const input = [
|
|
239
|
+
'Error: bad argument',
|
|
240
|
+
'Stack Begin',
|
|
241
|
+
"Script 'ServerScriptService.pkg.Shared.Inner', Line 10",
|
|
242
|
+
"Script 'ServerScriptService.pkg.Shared.Outer.spec', Line 30",
|
|
243
|
+
'Stack End',
|
|
244
|
+
].join('\n');
|
|
245
|
+
|
|
246
|
+
const result = parseJestLuaOutput(input, { packageName: 'pkg' });
|
|
247
|
+
|
|
248
|
+
expect(result).toHaveLength(1);
|
|
249
|
+
expect(result[0].file).toBe('src/pkg/src/Shared/Outer.spec.lua');
|
|
250
|
+
expect(result[0].line).toBe(30);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('with sourcemap resolver', () => {
|
|
254
|
+
const repoRoot = '/repo';
|
|
255
|
+
|
|
256
|
+
const sourcemap: SourcemapNode = {
|
|
257
|
+
name: 'Nevermore',
|
|
258
|
+
className: 'DataModel',
|
|
259
|
+
children: [
|
|
260
|
+
{
|
|
261
|
+
name: 'mypkg',
|
|
262
|
+
className: 'Folder',
|
|
263
|
+
filePaths: [`${repoRoot}/src/mypkg/default.project.json`],
|
|
264
|
+
children: [
|
|
265
|
+
{
|
|
266
|
+
name: 'Shared',
|
|
267
|
+
className: 'Folder',
|
|
268
|
+
children: [
|
|
269
|
+
{
|
|
270
|
+
name: 'Foo',
|
|
271
|
+
className: 'ModuleScript',
|
|
272
|
+
filePaths: [`${repoRoot}/src/mypkg/src/Shared/Foo.lua`],
|
|
273
|
+
children: [
|
|
274
|
+
{
|
|
275
|
+
name: 'Foo.spec',
|
|
276
|
+
className: 'ModuleScript',
|
|
277
|
+
filePaths: [
|
|
278
|
+
`${repoRoot}/src/mypkg/src/Shared/Foo.spec.lua`,
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const resolver = SourcemapResolver.fromSourcemap(sourcemap, repoRoot);
|
|
291
|
+
|
|
292
|
+
it('uses sourcemap resolver for assertion failure paths', () => {
|
|
293
|
+
const input = [
|
|
294
|
+
' ● Suite > test one',
|
|
295
|
+
'',
|
|
296
|
+
' Expected: 1',
|
|
297
|
+
' Received: 2',
|
|
298
|
+
'',
|
|
299
|
+
' at ServerScriptService.mypkg.Shared.Foo.Foo.spec:10',
|
|
300
|
+
].join('\n');
|
|
301
|
+
|
|
302
|
+
const result = parseJestLuaOutput(input, {
|
|
303
|
+
packageName: 'mypkg',
|
|
304
|
+
sourcemapResolver: resolver,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(result).toHaveLength(1);
|
|
308
|
+
expect(result[0].file).toBe('src/mypkg/src/Shared/Foo.spec.lua');
|
|
309
|
+
expect(result[0].line).toBe(10);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('uses sourcemap resolver for runtime error paths', () => {
|
|
313
|
+
const input = [
|
|
314
|
+
'Error: attempt to index nil',
|
|
315
|
+
'Stack Begin',
|
|
316
|
+
"Script 'ServerScriptService.mypkg.Shared.Foo.Foo.spec', Line 5",
|
|
317
|
+
'Stack End',
|
|
318
|
+
].join('\n');
|
|
319
|
+
|
|
320
|
+
const result = parseJestLuaOutput(input, {
|
|
321
|
+
packageName: 'mypkg',
|
|
322
|
+
sourcemapResolver: resolver,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
expect(result).toHaveLength(1);
|
|
326
|
+
expect(result[0].file).toBe('src/mypkg/src/Shared/Foo.spec.lua');
|
|
327
|
+
expect(result[0].line).toBe(5);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('falls back to heuristic when sourcemap has no mapping', () => {
|
|
331
|
+
const input = [
|
|
332
|
+
' ● Suite > test one',
|
|
333
|
+
'',
|
|
334
|
+
' Expected: 1',
|
|
335
|
+
' Received: 2',
|
|
336
|
+
'',
|
|
337
|
+
' at ServerScriptService.unknown.Shared.Bar.spec:10',
|
|
338
|
+
].join('\n');
|
|
339
|
+
|
|
340
|
+
const result = parseJestLuaOutput(input, {
|
|
341
|
+
packageName: 'unknown',
|
|
342
|
+
sourcemapResolver: resolver,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
expect(result).toHaveLength(1);
|
|
346
|
+
// Falls back to heuristic
|
|
347
|
+
expect(result[0].file).toBe('src/unknown/src/Shared/Bar.spec.lua');
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for Jest-lua verbose output (`verbose=true, ci=true`).
|
|
3
|
+
*
|
|
4
|
+
* Handles two failure modes:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Jest assertion failures** — `●` blocks:
|
|
7
|
+
* ```
|
|
8
|
+
* ● ObservableList > add and remove > should track count
|
|
9
|
+
*
|
|
10
|
+
* expect(received).toEqual(expected)
|
|
11
|
+
*
|
|
12
|
+
* Expected: 3
|
|
13
|
+
* Received: 2
|
|
14
|
+
*
|
|
15
|
+
* at ServerScriptService.observablecollection.Shared.ObservableList.spec:45
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* 2. **Runtime errors** — `Stack Begin`/`Stack End` blocks:
|
|
19
|
+
* ```
|
|
20
|
+
* Error: attempt to index nil with "Connect"
|
|
21
|
+
* Stack Begin
|
|
22
|
+
* Script 'ServerScriptService.maid.Shared.Maid.spec', Line 23
|
|
23
|
+
* Stack End
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* The parser is intentionally lenient — it extracts what it can, skips what
|
|
27
|
+
* it can't, and never crashes on unexpected input.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import * as fs from 'fs';
|
|
31
|
+
import { type Diagnostic } from '@quenty/cli-output-helpers/reporting';
|
|
32
|
+
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
33
|
+
import type { SourcemapResolver } from '../../sourcemap/index.js';
|
|
34
|
+
import { resolveRobloxTestPath } from './roblox-path-resolver.js';
|
|
35
|
+
|
|
36
|
+
/** Matches `at ServerScriptService.pkg.Path.spec:45` (with line number) */
|
|
37
|
+
const AT_LOCATION_PATTERN = /^\s+at\s+(.+):(\d+)\s*$/;
|
|
38
|
+
|
|
39
|
+
/** Matches `at ServerScriptService.pkg.Path.spec` (without line number) */
|
|
40
|
+
const AT_LOCATION_NO_LINE_PATTERN = /^\s+at\s+(\S+)\s*$/;
|
|
41
|
+
|
|
42
|
+
/** Matches `Script 'ServerScriptService.pkg.Path.spec', Line 23` */
|
|
43
|
+
const SCRIPT_LOCATION_PATTERN = /Script '([^']+)', Line (\d+)/;
|
|
44
|
+
|
|
45
|
+
/** Matches the Jest failure header: `● TestSuite > nested > test name` */
|
|
46
|
+
const FAILURE_HEADER_PATTERN = /^\s*●\s+(.+)$/;
|
|
47
|
+
|
|
48
|
+
const enum State {
|
|
49
|
+
IDLE,
|
|
50
|
+
FAILURE_BLOCK,
|
|
51
|
+
STACK_BLOCK,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface FailureContext {
|
|
55
|
+
title: string;
|
|
56
|
+
messageLines: string[];
|
|
57
|
+
file?: string;
|
|
58
|
+
line?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface StackContext {
|
|
62
|
+
errorMessage: string;
|
|
63
|
+
file?: string;
|
|
64
|
+
line?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Search a `.spec.lua` file for an `it()` call matching `testName`.
|
|
69
|
+
* Returns the 1-indexed line number, or `undefined` if not found.
|
|
70
|
+
*/
|
|
71
|
+
function _findTestLineInFile(
|
|
72
|
+
filePath: string,
|
|
73
|
+
testName: string
|
|
74
|
+
): number | undefined {
|
|
75
|
+
let content: string;
|
|
76
|
+
try {
|
|
77
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const lines = content.split('\n');
|
|
83
|
+
for (let i = 0; i < lines.length; i++) {
|
|
84
|
+
if (
|
|
85
|
+
lines[i].includes(`it("${testName}"`) ||
|
|
86
|
+
lines[i].includes(`it('${testName}'`)
|
|
87
|
+
) {
|
|
88
|
+
return i + 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse Jest-lua verbose output into `Diagnostic[]`.
|
|
97
|
+
*
|
|
98
|
+
* @param raw - Raw test output (may include ANSI codes)
|
|
99
|
+
* @param options.packageName - Package name for fallback path resolution
|
|
100
|
+
*/
|
|
101
|
+
export function parseJestLuaOutput(
|
|
102
|
+
raw: string,
|
|
103
|
+
options: { packageName: string; sourcemapResolver?: SourcemapResolver }
|
|
104
|
+
): Diagnostic[] {
|
|
105
|
+
const diagnostics: Diagnostic[] = [];
|
|
106
|
+
const clean = OutputHelper.stripAnsi(raw);
|
|
107
|
+
const lines = clean.split('\n');
|
|
108
|
+
|
|
109
|
+
let state: State = State.IDLE;
|
|
110
|
+
let failureCtx: FailureContext | undefined;
|
|
111
|
+
let stackCtx: StackContext | undefined;
|
|
112
|
+
let lastNonEmptyLine = '';
|
|
113
|
+
|
|
114
|
+
function _emitFailure(ctx: FailureContext): void {
|
|
115
|
+
const message = ctx.messageLines
|
|
116
|
+
.join('\n')
|
|
117
|
+
.trim();
|
|
118
|
+
|
|
119
|
+
// 3-tier line resolution:
|
|
120
|
+
// 1. Primary: line number extracted from `at <path>:<line>` or similar
|
|
121
|
+
// 2. Secondary: search the resolved .spec.lua file for the it() call
|
|
122
|
+
// 3. Tertiary: fall back to line 1
|
|
123
|
+
let file: string;
|
|
124
|
+
let line: number;
|
|
125
|
+
|
|
126
|
+
if (ctx.file) {
|
|
127
|
+
file = resolveRobloxTestPath(ctx.file, options.sourcemapResolver);
|
|
128
|
+
|
|
129
|
+
if (ctx.line) {
|
|
130
|
+
line = ctx.line;
|
|
131
|
+
} else {
|
|
132
|
+
const segments = ctx.title.split(' > ');
|
|
133
|
+
const testName = segments[segments.length - 1].trim();
|
|
134
|
+
line = _findTestLineInFile(file, testName) ?? 1;
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
file = `src/${options.packageName}/src`;
|
|
138
|
+
line = 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
diagnostics.push({
|
|
142
|
+
file,
|
|
143
|
+
line,
|
|
144
|
+
severity: 'error',
|
|
145
|
+
title: ctx.title,
|
|
146
|
+
message: message || 'Test failed',
|
|
147
|
+
source: 'jest-lua',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function _emitStack(ctx: StackContext): void {
|
|
152
|
+
const file = ctx.file
|
|
153
|
+
? resolveRobloxTestPath(ctx.file, options.sourcemapResolver)
|
|
154
|
+
: `src/${options.packageName}/src`;
|
|
155
|
+
const line = ctx.line ?? 1;
|
|
156
|
+
|
|
157
|
+
diagnostics.push({
|
|
158
|
+
file,
|
|
159
|
+
line,
|
|
160
|
+
severity: 'error',
|
|
161
|
+
title: 'Runtime error',
|
|
162
|
+
message: ctx.errorMessage || 'Unknown runtime error',
|
|
163
|
+
source: 'jest-lua',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const rawLine of lines) {
|
|
168
|
+
switch (state) {
|
|
169
|
+
case State.IDLE: {
|
|
170
|
+
const failureMatch = rawLine.match(FAILURE_HEADER_PATTERN);
|
|
171
|
+
if (failureMatch) {
|
|
172
|
+
failureCtx = {
|
|
173
|
+
title: failureMatch[1].trim(),
|
|
174
|
+
messageLines: [],
|
|
175
|
+
};
|
|
176
|
+
state = State.FAILURE_BLOCK;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (rawLine.trim() === 'Stack Begin') {
|
|
181
|
+
stackCtx = {
|
|
182
|
+
errorMessage: lastNonEmptyLine,
|
|
183
|
+
};
|
|
184
|
+
state = State.STACK_BLOCK;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (rawLine.trim() !== '') {
|
|
189
|
+
lastNonEmptyLine = rawLine.trim();
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
case State.FAILURE_BLOCK: {
|
|
195
|
+
if (!failureCtx) {
|
|
196
|
+
state = State.IDLE;
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const atMatch = rawLine.match(AT_LOCATION_PATTERN);
|
|
201
|
+
if (atMatch) {
|
|
202
|
+
failureCtx.file = atMatch[1];
|
|
203
|
+
failureCtx.line = parseInt(atMatch[2], 10);
|
|
204
|
+
_emitFailure(failureCtx);
|
|
205
|
+
failureCtx = undefined;
|
|
206
|
+
state = State.IDLE;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const atNoLineMatch = rawLine.match(AT_LOCATION_NO_LINE_PATTERN);
|
|
211
|
+
if (atNoLineMatch) {
|
|
212
|
+
failureCtx.file = atNoLineMatch[1];
|
|
213
|
+
_emitFailure(failureCtx);
|
|
214
|
+
failureCtx = undefined;
|
|
215
|
+
state = State.IDLE;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const nextFailure = rawLine.match(FAILURE_HEADER_PATTERN);
|
|
220
|
+
if (nextFailure) {
|
|
221
|
+
_emitFailure(failureCtx);
|
|
222
|
+
failureCtx = {
|
|
223
|
+
title: nextFailure[1].trim(),
|
|
224
|
+
messageLines: [],
|
|
225
|
+
};
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (rawLine.trim() === 'Stack Begin') {
|
|
230
|
+
_emitFailure(failureCtx);
|
|
231
|
+
stackCtx = {
|
|
232
|
+
errorMessage: failureCtx.messageLines.length > 0
|
|
233
|
+
? failureCtx.messageLines[failureCtx.messageLines.length - 1].trim()
|
|
234
|
+
: failureCtx.title,
|
|
235
|
+
};
|
|
236
|
+
failureCtx = undefined;
|
|
237
|
+
state = State.STACK_BLOCK;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
failureCtx.messageLines.push(rawLine);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
case State.STACK_BLOCK: {
|
|
246
|
+
if (!stackCtx) {
|
|
247
|
+
state = State.IDLE;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const scriptMatch = rawLine.match(SCRIPT_LOCATION_PATTERN);
|
|
252
|
+
if (scriptMatch) {
|
|
253
|
+
stackCtx.file = scriptMatch[1];
|
|
254
|
+
stackCtx.line = parseInt(scriptMatch[2], 10);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (rawLine.trim() === 'Stack End') {
|
|
259
|
+
_emitStack(stackCtx);
|
|
260
|
+
stackCtx = undefined;
|
|
261
|
+
state = State.IDLE;
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Flush any pending context at end of input
|
|
271
|
+
if (failureCtx && state === State.FAILURE_BLOCK) {
|
|
272
|
+
_emitFailure(failureCtx);
|
|
273
|
+
}
|
|
274
|
+
if (stackCtx && state === State.STACK_BLOCK) {
|
|
275
|
+
_emitStack(stackCtx);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return diagnostics;
|
|
279
|
+
}
|