@quenty/nevermore-cli 4.49.0 → 4.50.1

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/commands/tools-command/ci-post-test-results.d.ts.map +1 -1
  3. package/dist/commands/tools-command/ci-post-test-results.js +7 -3
  4. package/dist/commands/tools-command/ci-post-test-results.js.map +1 -1
  5. package/dist/utils/batch/changed-packages-utils.d.ts +8 -0
  6. package/dist/utils/batch/changed-packages-utils.d.ts.map +1 -1
  7. package/dist/utils/batch/changed-packages-utils.js +9 -11
  8. package/dist/utils/batch/changed-packages-utils.js.map +1 -1
  9. package/dist/utils/testing/batch-run.e2e.test.js +2 -2
  10. package/dist/utils/testing/batch-run.e2e.test.js.map +1 -1
  11. package/dist/utils/testing/parsers/annotation-workspace.d.ts +17 -0
  12. package/dist/utils/testing/parsers/annotation-workspace.d.ts.map +1 -0
  13. package/dist/utils/testing/parsers/annotation-workspace.js +47 -0
  14. package/dist/utils/testing/parsers/annotation-workspace.js.map +1 -0
  15. package/dist/utils/testing/parsers/index.d.ts +0 -1
  16. package/dist/utils/testing/parsers/index.d.ts.map +1 -1
  17. package/dist/utils/testing/parsers/index.js +0 -1
  18. package/dist/utils/testing/parsers/index.js.map +1 -1
  19. package/dist/utils/testing/parsers/jest-lua-parser.d.ts +14 -29
  20. package/dist/utils/testing/parsers/jest-lua-parser.d.ts.map +1 -1
  21. package/dist/utils/testing/parsers/jest-lua-parser.js +85 -100
  22. package/dist/utils/testing/parsers/jest-lua-parser.js.map +1 -1
  23. package/dist/utils/testing/parsers/jest-lua-parser.test.js +359 -56
  24. package/dist/utils/testing/parsers/jest-lua-parser.test.js.map +1 -1
  25. package/dist/utils/testing/parsers/roblox-path-resolver.d.ts +23 -17
  26. package/dist/utils/testing/parsers/roblox-path-resolver.d.ts.map +1 -1
  27. package/dist/utils/testing/parsers/roblox-path-resolver.js +89 -54
  28. package/dist/utils/testing/parsers/roblox-path-resolver.js.map +1 -1
  29. package/dist/utils/testing/parsers/roblox-path-resolver.test.js +76 -36
  30. package/dist/utils/testing/parsers/roblox-path-resolver.test.js.map +1 -1
  31. package/dist/utils/testing/runner/combined-project-generator.d.ts.map +1 -1
  32. package/dist/utils/testing/runner/combined-project-generator.js +5 -6
  33. package/dist/utils/testing/runner/combined-project-generator.js.map +1 -1
  34. package/dist/utils/testing/runner/test-project-slug.d.ts +11 -0
  35. package/dist/utils/testing/runner/test-project-slug.d.ts.map +1 -0
  36. package/dist/utils/testing/runner/test-project-slug.js +11 -0
  37. package/dist/utils/testing/runner/test-project-slug.js.map +1 -0
  38. package/package.json +6 -6
  39. package/src/commands/tools-command/ci-post-test-results.ts +7 -2
  40. package/src/utils/batch/changed-packages-utils.ts +21 -12
  41. package/src/utils/testing/batch-run.e2e.test.ts +6 -2
  42. package/src/utils/testing/parsers/annotation-workspace.ts +74 -0
  43. package/src/utils/testing/parsers/index.ts +0 -1
  44. package/src/utils/testing/parsers/jest-lua-parser.test.ts +433 -65
  45. package/src/utils/testing/parsers/jest-lua-parser.ts +131 -109
  46. package/src/utils/testing/parsers/roblox-path-resolver.test.ts +144 -61
  47. package/src/utils/testing/parsers/roblox-path-resolver.ts +117 -56
  48. package/src/utils/testing/runner/combined-project-generator.ts +9 -13
  49. package/src/utils/testing/runner/test-project-slug.ts +18 -0
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -1,76 +1,79 @@
1
1
  /**
2
2
  * Parser for Jest-lua verbose output (`verbose=true, ci=true`).
3
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.
4
+ * Emits one diagnostic per `●` failure block and per `Stack Begin`/`Stack End`
5
+ * runtime error, at the innermost frame that resolves to a repo file,
6
+ * preferring the package under test. Diagnostics with no resolvable frame
7
+ * carry no file. Unexpected input is skipped, never thrown on.
28
8
  */
29
9
 
30
10
  import * as fs from 'fs';
11
+ import * as path from 'path';
31
12
  import { type Diagnostic } from '@quenty/cli-output-helpers/reporting';
32
13
  import { OutputHelper } from '@quenty/cli-output-helpers';
33
14
  import type { SourcemapResolver } from '../../sourcemap/index.js';
34
- import { resolveRobloxTestPath } from './roblox-path-resolver.js';
15
+ import type { AnnotationWorkspace } from './annotation-workspace.js';
16
+ import { tryResolveRobloxSourceFile } from './roblox-path-resolver.js';
35
17
 
36
- /** Matches `at ServerScriptService.pkg.Path.spec:45` (with line number) */
37
- const AT_LOCATION_PATTERN = /^\s+at\s+(.+):(\d+)\s*$/;
18
+ /** `at ServerScriptService.pkg.Path.spec:45`, line optional */
19
+ const AT_FRAME_PATTERN = /^\s+at\s+(\S+?)(?::(\d+))?\s*$/;
38
20
 
39
- /** Matches `at ServerScriptService.pkg.Path.spec` (without line number) */
40
- const AT_LOCATION_NO_LINE_PATTERN = /^\s+at\s+(\S+)\s*$/;
21
+ /** `ServerScriptService.pkg.Path:1263 function requireModule`, any service root */
22
+ const BARE_FRAME_PATTERN =
23
+ /^\s+(\[string "[^"]+"\]|[A-Za-z_][\w-]*(?:\.[^\s:"]+)+):(\d+)(?:\s+function\s+\S+)?\s*$/;
41
24
 
42
- /** Matches `Script 'ServerScriptService.pkg.Path.spec', Line 23` */
43
- const SCRIPT_LOCATION_PATTERN = /Script '([^']+)', Line (\d+)/;
25
+ /** `Script '[string "ServerScriptService.pkg.Path"]', Line 23` */
26
+ const SCRIPT_FRAME_PATTERN = /Script '(.+?)', Line (\d+)/;
44
27
 
45
- /** Matches the Jest failure header: `● TestSuite > nested > test name` */
28
+ /** `<chunk>:214:` location prefixes inside error messages */
29
+ const MESSAGE_LOCATION_PATTERN =
30
+ /(\[string "[^"]+"\]|ServerScriptService\.[^\s:"]+):(\d+):/g;
31
+
32
+ /** `● TestSuite › nested › test name` */
46
33
  const FAILURE_HEADER_PATTERN = /^\s*●\s+(.+)$/;
47
34
 
35
+ const TITLE_SEPARATOR_PATTERN = / [›>] /;
36
+
48
37
  const enum State {
49
38
  IDLE,
50
39
  FAILURE_BLOCK,
51
40
  STACK_BLOCK,
52
41
  }
53
42
 
43
+ interface Frame {
44
+ instancePath: string;
45
+ line?: number;
46
+ }
47
+
54
48
  interface FailureContext {
55
49
  title: string;
56
50
  messageLines: string[];
57
- file?: string;
58
- line?: number;
51
+ frames: Frame[];
59
52
  }
60
53
 
61
54
  interface StackContext {
62
55
  errorMessage: string;
63
- file?: string;
56
+ frames: Frame[];
57
+ }
58
+
59
+ interface ResolvedLocation {
60
+ file: string;
64
61
  line?: number;
62
+ isPackageUnderTest: boolean;
63
+ }
64
+
65
+ export interface JestLuaParseOptions {
66
+ /** npm name of the package under test, e.g. `@quenty/saveslot`. */
67
+ packageName: string;
68
+ sourcemapResolver?: SourcemapResolver;
69
+ workspace?: AnnotationWorkspace;
70
+ /** Repo root that holds the workspace. Defaults to cwd. */
71
+ repoRoot?: string;
65
72
  }
66
73
 
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
74
  function _findTestLineInFile(
72
75
  filePath: string,
73
- testName: string
76
+ title: string
74
77
  ): number | undefined {
75
78
  let content: string;
76
79
  try {
@@ -80,31 +83,67 @@ function _findTestLineInFile(
80
83
  }
81
84
 
82
85
  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
- }
86
+ const names = title.split(TITLE_SEPARATOR_PATTERN).map((name) => name.trim());
87
+ for (const name of names.reverse()) {
88
+ const index = lines.findIndex(
89
+ (line) => line.includes(`("${name}"`) || line.includes(`('${name}'`)
90
+ );
91
+ if (index !== -1) return index + 1;
90
92
  }
91
93
 
92
94
  return undefined;
93
95
  }
94
96
 
97
+ function _parseFrameLine(rawLine: string): Frame | undefined {
98
+ const match =
99
+ rawLine.match(BARE_FRAME_PATTERN) ?? rawLine.match(AT_FRAME_PATTERN);
100
+ if (!match) return undefined;
101
+ return {
102
+ instancePath: match[1],
103
+ line: match[2] ? parseInt(match[2], 10) : undefined,
104
+ };
105
+ }
106
+
107
+ function _parseMessageLocations(message: string): Frame[] {
108
+ return [...message.matchAll(MESSAGE_LOCATION_PATTERN)].map((match) => ({
109
+ instancePath: match[1],
110
+ line: parseInt(match[2], 10),
111
+ }));
112
+ }
113
+
114
+ function _resolveLocation(
115
+ frames: Frame[],
116
+ options: JestLuaParseOptions
117
+ ): ResolvedLocation | undefined {
118
+ let fallback: ResolvedLocation | undefined;
119
+
120
+ for (const frame of frames) {
121
+ const resolved = tryResolveRobloxSourceFile(frame.instancePath, options);
122
+ if (!resolved) continue;
123
+
124
+ const location = { ...resolved, line: frame.line };
125
+ if (resolved.isPackageUnderTest) {
126
+ return location;
127
+ }
128
+ fallback ??= location;
129
+ }
130
+
131
+ return fallback;
132
+ }
133
+
95
134
  /**
96
135
  * Parse Jest-lua verbose output into `Diagnostic[]`.
97
136
  *
98
137
  * @param raw - Raw test output (may include ANSI codes)
99
- * @param options.packageName - Package name for fallback path resolution
100
138
  */
101
139
  export function parseJestLuaOutput(
102
140
  raw: string,
103
- options: { packageName: string; sourcemapResolver?: SourcemapResolver }
141
+ options: JestLuaParseOptions
104
142
  ): Diagnostic[] {
105
143
  const diagnostics: Diagnostic[] = [];
106
144
  const clean = OutputHelper.stripAnsi(raw);
107
145
  const lines = clean.split('\n');
146
+ const repoRoot = options.repoRoot ?? process.cwd();
108
147
 
109
148
  let state: State = State.IDLE;
110
149
  let failureCtx: FailureContext | undefined;
@@ -113,32 +152,19 @@ export function parseJestLuaOutput(
113
152
 
114
153
  function _emitFailure(ctx: FailureContext): void {
115
154
  const message = ctx.messageLines.join('\n').trim();
116
-
117
- // 3-tier line resolution:
118
- // 1. Primary: line number extracted from `at <path>:<line>` or similar
119
- // 2. Secondary: search the resolved .spec.lua file for the it() call
120
- // 3. Tertiary: fall back to line 1
121
- let file: string;
122
- let line: number;
123
-
124
- if (ctx.file) {
125
- file = resolveRobloxTestPath(ctx.file, options.sourcemapResolver);
126
-
127
- if (ctx.line) {
128
- line = ctx.line;
129
- } else {
130
- const segments = ctx.title.split(' > ');
131
- const testName = segments[segments.length - 1].trim();
132
- line = _findTestLineInFile(file, testName) ?? 1;
133
- }
134
- } else {
135
- file = `src/${options.packageName}/src`;
136
- line = 1;
155
+ const location = _resolveLocation(
156
+ [...ctx.frames, ..._parseMessageLocations(message)],
157
+ options
158
+ );
159
+
160
+ let line = location?.line;
161
+ if (location?.isPackageUnderTest && line === undefined) {
162
+ line = _findTestLineInFile(path.join(repoRoot, location.file), ctx.title);
137
163
  }
138
164
 
139
165
  diagnostics.push({
140
- file,
141
- line,
166
+ ...(location ? { file: location.file } : {}),
167
+ ...(line !== undefined ? { line } : {}),
142
168
  severity: 'error',
143
169
  title: ctx.title,
144
170
  message: message || 'Test failed',
@@ -147,14 +173,13 @@ export function parseJestLuaOutput(
147
173
  }
148
174
 
149
175
  function _emitStack(ctx: StackContext): void {
150
- const file = ctx.file
151
- ? resolveRobloxTestPath(ctx.file, options.sourcemapResolver)
152
- : `src/${options.packageName}/src`;
153
- const line = ctx.line ?? 1;
176
+ const location = _resolveLocation(
177
+ [...ctx.frames, ..._parseMessageLocations(ctx.errorMessage)],
178
+ options
179
+ );
154
180
 
155
181
  diagnostics.push({
156
- file,
157
- line,
182
+ ...(location ? { file: location.file, line: location.line ?? 1 } : {}),
158
183
  severity: 'error',
159
184
  title: 'Runtime error',
160
185
  message: ctx.errorMessage || 'Unknown runtime error',
@@ -162,7 +187,9 @@ export function parseJestLuaOutput(
162
187
  });
163
188
  }
164
189
 
165
- for (const rawLine of lines) {
190
+ for (let i = 0; i < lines.length; i++) {
191
+ const rawLine = lines[i];
192
+
166
193
  switch (state) {
167
194
  case State.IDLE: {
168
195
  const failureMatch = rawLine.match(FAILURE_HEADER_PATTERN);
@@ -170,6 +197,7 @@ export function parseJestLuaOutput(
170
197
  failureCtx = {
171
198
  title: failureMatch[1].trim(),
172
199
  messageLines: [],
200
+ frames: [],
173
201
  };
174
202
  state = State.FAILURE_BLOCK;
175
203
  break;
@@ -178,6 +206,7 @@ export function parseJestLuaOutput(
178
206
  if (rawLine.trim() === 'Stack Begin') {
179
207
  stackCtx = {
180
208
  errorMessage: lastNonEmptyLine,
209
+ frames: [],
181
210
  };
182
211
  state = State.STACK_BLOCK;
183
212
  break;
@@ -195,32 +224,9 @@ export function parseJestLuaOutput(
195
224
  break;
196
225
  }
197
226
 
198
- const atMatch = rawLine.match(AT_LOCATION_PATTERN);
199
- if (atMatch) {
200
- failureCtx.file = atMatch[1];
201
- failureCtx.line = parseInt(atMatch[2], 10);
202
- _emitFailure(failureCtx);
203
- failureCtx = undefined;
204
- state = State.IDLE;
205
- break;
206
- }
207
-
208
- const atNoLineMatch = rawLine.match(AT_LOCATION_NO_LINE_PATTERN);
209
- if (atNoLineMatch) {
210
- failureCtx.file = atNoLineMatch[1];
211
- _emitFailure(failureCtx);
212
- failureCtx = undefined;
213
- state = State.IDLE;
214
- break;
215
- }
216
-
217
- const nextFailure = rawLine.match(FAILURE_HEADER_PATTERN);
218
- if (nextFailure) {
219
- _emitFailure(failureCtx);
220
- failureCtx = {
221
- title: nextFailure[1].trim(),
222
- messageLines: [],
223
- };
227
+ const frame = _parseFrameLine(rawLine);
228
+ if (frame) {
229
+ failureCtx.frames.push(frame);
224
230
  break;
225
231
  }
226
232
 
@@ -233,13 +239,27 @@ export function parseJestLuaOutput(
233
239
  failureCtx.messageLines.length - 1
234
240
  ].trim()
235
241
  : failureCtx.title,
242
+ frames: [],
236
243
  };
237
244
  failureCtx = undefined;
238
245
  state = State.STACK_BLOCK;
239
246
  break;
240
247
  }
241
248
 
242
- failureCtx.messageLines.push(rawLine);
249
+ const endsBlock =
250
+ FAILURE_HEADER_PATTERN.test(rawLine) ||
251
+ (failureCtx.frames.length > 0 && rawLine.trim() !== '');
252
+ if (endsBlock) {
253
+ _emitFailure(failureCtx);
254
+ failureCtx = undefined;
255
+ state = State.IDLE;
256
+ i--;
257
+ break;
258
+ }
259
+
260
+ if (failureCtx.frames.length === 0) {
261
+ failureCtx.messageLines.push(rawLine);
262
+ }
243
263
  break;
244
264
  }
245
265
 
@@ -249,10 +269,12 @@ export function parseJestLuaOutput(
249
269
  break;
250
270
  }
251
271
 
252
- const scriptMatch = rawLine.match(SCRIPT_LOCATION_PATTERN);
272
+ const scriptMatch = rawLine.match(SCRIPT_FRAME_PATTERN);
253
273
  if (scriptMatch) {
254
- stackCtx.file = scriptMatch[1];
255
- stackCtx.line = parseInt(scriptMatch[2], 10);
274
+ stackCtx.frames.push({
275
+ instancePath: scriptMatch[1],
276
+ line: parseInt(scriptMatch[2], 10),
277
+ });
256
278
  break;
257
279
  }
258
280
 
@@ -1,12 +1,35 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { resolveRobloxTestPath } from './roblox-path-resolver.js';
1
+ import { beforeAll, describe, it, expect } from 'vitest';
2
+ import { fileURLToPath } from 'url';
3
+ import { tryResolveRobloxSourceFile } from './roblox-path-resolver.js';
3
4
  import { SourcemapResolver } from '../../sourcemap/index.js';
4
5
  import type { SourcemapNode } from '../../sourcemap/index.js';
6
+ import {
7
+ type AnnotationWorkspace,
8
+ tryLoadAnnotationWorkspaceAsync,
9
+ } from './annotation-workspace.js';
5
10
 
6
- describe('resolveRobloxTestPath', () => {
11
+ const REPO_ROOT = fileURLToPath(new URL('../../../../../../', import.meta.url));
12
+
13
+ let workspace: AnnotationWorkspace | undefined;
14
+ beforeAll(async () => {
15
+ workspace = await tryLoadAnnotationWorkspaceAsync(REPO_ROOT);
16
+ }, 30000);
17
+
18
+ function resolveFile(
19
+ instancePath: string,
20
+ packageName?: string
21
+ ): string | undefined {
22
+ return tryResolveRobloxSourceFile(instancePath, {
23
+ repoRoot: REPO_ROOT,
24
+ workspace,
25
+ packageName,
26
+ })?.file;
27
+ }
28
+
29
+ describe('tryResolveRobloxSourceFile', () => {
7
30
  it('resolves a standard ServerScriptService path', () => {
8
31
  expect(
9
- resolveRobloxTestPath(
32
+ resolveFile(
10
33
  'ServerScriptService.observablecollection.Shared.ObservableList.spec'
11
34
  )
12
35
  ).toBe('src/observablecollection/src/Shared/ObservableList.spec.lua');
@@ -14,48 +37,121 @@ describe('resolveRobloxTestPath', () => {
14
37
 
15
38
  it('resolves a nested subdirectory path', () => {
16
39
  expect(
17
- resolveRobloxTestPath('ServerScriptService.maid.Shared.Maid.spec')
18
- ).toBe('src/maid/src/Shared/Maid.spec.lua');
40
+ resolveFile(
41
+ 'ServerScriptService.observablecollection.Shared.SortedList.ObservableSortedList'
42
+ )
43
+ ).toBe(
44
+ 'src/observablecollection/src/Shared/SortedList/ObservableSortedList.lua'
45
+ );
19
46
  });
20
47
 
21
- it('resolves a deeply nested path', () => {
48
+ it('handles missing ServerScriptService prefix', () => {
49
+ expect(resolveFile('maid.Shared.MaidTaskUtils.spec')).toBe(
50
+ 'src/maid/src/Shared/MaidTaskUtils.spec.lua'
51
+ );
52
+ });
53
+
54
+ it('unwraps a chunk-name wrapper', () => {
22
55
  expect(
23
- resolveRobloxTestPath(
24
- 'ServerScriptService.blend.Client.Blend.Spring.SpringObject.spec'
56
+ resolveFile(
57
+ '[string "ServerScriptService.saveslot.Server.HasSaveSlotsDataStore"]'
25
58
  )
26
- ).toBe('src/blend/src/Client/Blend/Spring/SpringObject.spec.lua');
59
+ ).toBe('src/saveslot/src/Server/HasSaveSlotsDataStore.lua');
27
60
  });
28
61
 
29
- it('handles missing ServerScriptService prefix as fallback', () => {
62
+ it('maps a nested dependency copy to its own package', () => {
30
63
  expect(
31
- resolveRobloxTestPath('observablecollection.Shared.ObservableList.spec')
32
- ).toBe('src/observablecollection/src/Shared/ObservableList.spec.lua');
64
+ resolveFile(
65
+ 'ServerScriptService.saveslot.node_modules.@quenty.adorneedata.node_modules.@quenty.attributeutils.node_modules.@quenty.baseobject.node_modules.@quenty.maid.Shared.Maid'
66
+ )
67
+ ).toBe('src/maid/src/Shared/Maid.lua');
33
68
  });
34
69
 
35
- it('strips :LINE suffix', () => {
70
+ it('maps a dependency whose folder differs from its npm name', () => {
36
71
  expect(
37
- resolveRobloxTestPath(
38
- 'ServerScriptService.observablecollection.Shared.ObservableList.spec:45'
72
+ resolveFile(
73
+ '[string "ServerScriptService.gameproduct.node_modules.@quenty.playermock.Shared.PlayerMock"]'
39
74
  )
40
- ).toBe('src/observablecollection/src/Shared/ObservableList.spec.lua');
75
+ ).toBe('src/player-mock/src/Shared/PlayerMock.lua');
41
76
  });
42
77
 
43
- it('handles path without ServerScriptService prefix and with :LINE suffix', () => {
44
- expect(resolveRobloxTestPath('maid.Shared.Maid.spec:23')).toBe(
45
- 'src/maid/src/Shared/Maid.spec.lua'
46
- );
78
+ it('resolves a package root module to its init file', () => {
79
+ expect(
80
+ resolveFile(
81
+ '[string "ServerScriptService.studiobridge.node_modules.@quenty.nevermore-test-runner.node_modules.@quenty.loader"]'
82
+ )
83
+ ).toBe('src/loader/src/init.lua');
47
84
  });
48
85
 
49
- it('handles a bare package slug', () => {
50
- expect(resolveRobloxTestPath('ServerScriptService.maid')).toBe(
51
- 'src/maid/src'
52
- );
86
+ it('maps the test slug of the package under test to its folder', () => {
87
+ expect(
88
+ tryResolveRobloxSourceFile(
89
+ 'ServerScriptService.gameproduct.Server.Cmdr.GameProductCmdrService',
90
+ {
91
+ repoRoot: REPO_ROOT,
92
+ workspace,
93
+ packageName: '@quenty/gameproductservice',
94
+ }
95
+ )
96
+ ).toEqual({
97
+ file: 'src/gameproductservice/src/Server/Cmdr/GameProductCmdrService.lua',
98
+ isPackageUnderTest: true,
99
+ });
100
+ expect(
101
+ resolveFile(
102
+ 'ServerScriptService.playermock.Shared.PlayerMock',
103
+ '@quenty/playermock'
104
+ )
105
+ ).toBe('src/player-mock/src/Shared/PlayerMock.lua');
53
106
  });
54
107
 
55
- it('handles a single-level spec path', () => {
56
- expect(resolveRobloxTestPath('ServerScriptService.maid.Maid.spec')).toBe(
57
- 'src/maid/src/Maid.spec.lua'
58
- );
108
+ it('resolves a package outside src/ by its workspace directory', () => {
109
+ expect(
110
+ resolveFile(
111
+ 'ServerScriptService.studiobridge.commands.console.exec.execute.spec',
112
+ '@quenty/studio-bridge'
113
+ )
114
+ ).toBe('tools/studio-bridge/src/commands/console/exec/execute.spec.lua');
115
+ });
116
+
117
+ it('tries the script file names Rojo strips', () => {
118
+ expect(
119
+ resolveFile('ServerScriptService.x.node_modules.@quenty.preview-plugin')
120
+ ).toBe('plugins/preview-plugin/src/init.server.lua');
121
+ });
122
+
123
+ it('flags dependency frames as not under test', () => {
124
+ expect(
125
+ tryResolveRobloxSourceFile(
126
+ 'ServerScriptService.saveslot.node_modules.@quenty.binder.Shared.Binder',
127
+ { repoRoot: REPO_ROOT, workspace, packageName: '@quenty/saveslot' }
128
+ )
129
+ ).toEqual({
130
+ file: 'src/binder/src/Shared/Binder.lua',
131
+ isPackageUnderTest: false,
132
+ });
133
+ });
134
+
135
+ it('does not remap a sibling package frame into the package under test', () => {
136
+ expect(
137
+ resolveFile(
138
+ 'ServerScriptService.othersibling.Server.SaveSlotService',
139
+ '@quenty/saveslot'
140
+ )
141
+ ).toBeUndefined();
142
+ expect(
143
+ resolveFile('ServerScriptService.Script', '@quenty/saveslot')
144
+ ).toBeUndefined();
145
+ });
146
+
147
+ it('rejects vendored, truncated and missing paths', () => {
148
+ for (const instancePath of [
149
+ 'ServerScriptService.studiobridge.node_modules.@quenty.nevermore-test-runner.node_modules.@quentystudios.Jest._Index.jsdotlua_promise@3.5.2.promise',
150
+ '[string "ServerScriptService.gameproduct.node_modules.@quenty.playermock.node_modules.@quenty.servic..."]',
151
+ 'ServerScriptService.unknownpkg.Shared.Foo',
152
+ ]) {
153
+ expect(resolveFile(instancePath)).toBeUndefined();
154
+ }
59
155
  });
60
156
 
61
157
  describe('with sourcemap resolver', () => {
@@ -66,26 +162,19 @@ describe('resolveRobloxTestPath', () => {
66
162
  className: 'DataModel',
67
163
  children: [
68
164
  {
69
- name: 'mypkg',
165
+ name: 'playermock',
70
166
  className: 'Folder',
71
- filePaths: [`${repoRoot}/src/mypkg/default.project.json`],
167
+ filePaths: [`${repoRoot}/src/player-mock/default.project.json`],
72
168
  children: [
73
169
  {
74
170
  name: 'Shared',
75
171
  className: 'Folder',
76
172
  children: [
77
173
  {
78
- name: 'MyModule',
174
+ name: 'PlayerMock',
79
175
  className: 'ModuleScript',
80
- filePaths: [`${repoRoot}/src/mypkg/src/Shared/MyModule.lua`],
81
- children: [
82
- {
83
- name: 'MyModule.spec',
84
- className: 'ModuleScript',
85
- filePaths: [
86
- `${repoRoot}/src/mypkg/src/Shared/MyModule.spec.lua`,
87
- ],
88
- },
176
+ filePaths: [
177
+ `${repoRoot}/src/player-mock/src/Shared/PlayerMock.lua`,
89
178
  ],
90
179
  },
91
180
  ],
@@ -97,31 +186,25 @@ describe('resolveRobloxTestPath', () => {
97
186
 
98
187
  const resolver = SourcemapResolver.fromSourcemap(sourcemap, repoRoot);
99
188
 
100
- it('uses sourcemap when path is found', () => {
189
+ it('looks up dependency copies under their Rojo project name', () => {
101
190
  expect(
102
- resolveRobloxTestPath(
103
- 'ServerScriptService.mypkg.Shared.MyModule.MyModule.spec',
104
- resolver
105
- )
106
- ).toBe('src/mypkg/src/Shared/MyModule.spec.lua');
107
- });
108
-
109
- it('falls back to heuristic when sourcemap has no mapping', () => {
110
- expect(
111
- resolveRobloxTestPath(
112
- 'ServerScriptService.unknownpkg.Shared.Foo.spec',
113
- resolver
114
- )
115
- ).toBe('src/unknownpkg/src/Shared/Foo.spec.lua');
191
+ tryResolveRobloxSourceFile(
192
+ 'ServerScriptService.gameproduct.node_modules.@quenty.playermock.Shared.PlayerMock',
193
+ { repoRoot, sourcemapResolver: resolver }
194
+ )?.file
195
+ ).toBe('src/player-mock/src/Shared/PlayerMock.lua');
116
196
  });
117
197
 
118
- it('strips :LINE suffix with sourcemap', () => {
198
+ it('returns undefined when neither sourcemap nor repo has the file', () => {
119
199
  expect(
120
- resolveRobloxTestPath(
121
- 'ServerScriptService.mypkg.Shared.MyModule.MyModule.spec:42',
122
- resolver
200
+ tryResolveRobloxSourceFile(
201
+ 'ServerScriptService.unknownpkg.Shared.Foo',
202
+ {
203
+ repoRoot,
204
+ sourcemapResolver: resolver,
205
+ }
123
206
  )
124
- ).toBe('src/mypkg/src/Shared/MyModule.spec.lua');
207
+ ).toBeUndefined();
125
208
  });
126
209
  });
127
210
  });