@skyramp/mcp 0.2.1 → 0.2.2
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/build/playwright/registerPlaywrightTools.js +9 -0
- package/build/prompts/test-recommendation/scopeAssessment.js +106 -5
- package/build/prompts/test-recommendation/scopeAssessment.test.js +128 -1
- package/build/prompts/testbot/testbot-prompts.js +2 -2
- package/build/prompts/testbot/testbot-prompts.test.js +21 -0
- package/build/tools/test-management/analyzeChangesTool.js +8 -2
- package/build/tools/test-management/uiAnalyzeChangesTool.js +8 -2
- package/build/tools/test-management/uiAnalyzeChangesTool.test.js +47 -0
- package/build/utils/dartRouteExtractor.js +319 -0
- package/build/utils/dartRouteExtractor.test.js +307 -0
- package/build/utils/uiPageEnumerator.js +67 -0
- package/build/utils/uiPageEnumerator.test.js +222 -0
- package/node_modules/playwright/lib/mcp/skyramp/index.js +10 -0
- package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +313 -0
- package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +146 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +222 -2
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +27 -14
- package/package.json +1 -1
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dart route extraction for Flutter apps using the `go_router` package.
|
|
3
|
+
*
|
|
4
|
+
* Walks the repo's Dart sources looking for GoRoute declarations with
|
|
5
|
+
* string-literal `path:` arguments:
|
|
6
|
+
*
|
|
7
|
+
* GoRoute(path: '/login', builder: (ctx, st) => LoginScreen())
|
|
8
|
+
* GoRoute(path: "/profile/:id", builder: ...)
|
|
9
|
+
*
|
|
10
|
+
* Why regex (not a Dart compiler): Dart parsers (analyzer, dart-analyzer
|
|
11
|
+
* package) require a Dart SDK install. Regex on the GoRoute string-literal-path
|
|
12
|
+
* subset gives ~95% coverage of real customer apps with zero install cost.
|
|
13
|
+
* Constants like `path: _kHome` are deliberately skipped — emitting them
|
|
14
|
+
* as a URL `_kHome` would mislead the agent worse than dropping them.
|
|
15
|
+
*
|
|
16
|
+
* Each route also carries metadata to support diff-matching in the caller
|
|
17
|
+
* (findCandidatePagesByDartRoute):
|
|
18
|
+
*
|
|
19
|
+
* - `screenWidgets`: identifiers referenced as builder/pageBuilder targets,
|
|
20
|
+
* e.g. `AuthorsScreen`. Resolved through the file's imports to absolute
|
|
21
|
+
* paths in `screenFiles`.
|
|
22
|
+
* - `screenFiles`: the imported source files those identifiers come from.
|
|
23
|
+
* - `isRedirectOnly`: true if the GoRoute has `redirect:` but no
|
|
24
|
+
* `builder:` or `pageBuilder:` — these don't render UI, just redirect.
|
|
25
|
+
*
|
|
26
|
+
* Scope choices for slice 1:
|
|
27
|
+
* - GoRouter only (`MaterialApp.routes` and `auto_route` deferred)
|
|
28
|
+
* - String-literal `path:` only (no constant resolution)
|
|
29
|
+
* - Nested GoRoutes emit each declaration verbatim — the absolute parent
|
|
30
|
+
* plus relative children. Caller may compose them later.
|
|
31
|
+
*/
|
|
32
|
+
import * as fs from "fs";
|
|
33
|
+
import * as path from "path";
|
|
34
|
+
const MAX_CANDIDATE_FILES = 200;
|
|
35
|
+
const MAX_WALK_DEPTH = 10;
|
|
36
|
+
const SKIP_DIRS = new Set([
|
|
37
|
+
"build",
|
|
38
|
+
".dart_tool",
|
|
39
|
+
".git",
|
|
40
|
+
".idea",
|
|
41
|
+
"android",
|
|
42
|
+
"ios",
|
|
43
|
+
"macos",
|
|
44
|
+
"linux",
|
|
45
|
+
"windows",
|
|
46
|
+
"web",
|
|
47
|
+
"node_modules",
|
|
48
|
+
"test",
|
|
49
|
+
".pub-cache",
|
|
50
|
+
]);
|
|
51
|
+
/**
|
|
52
|
+
* Match a GoRoute declaration's body — from the opening `(` through to its
|
|
53
|
+
* matching `)`. We need the body to extract `path:`, screen widgets, and
|
|
54
|
+
* the redirect/builder distinction.
|
|
55
|
+
*
|
|
56
|
+
* The regex captures the literal string after `path:` and the start of the
|
|
57
|
+
* GoRoute call body; we then walk forward manually to find the matching
|
|
58
|
+
* close paren (regex can't match balanced parens reliably).
|
|
59
|
+
*/
|
|
60
|
+
const GO_ROUTE_PATH_REGEX = /\bGoRoute\s*\(\s*(?:<[^>]+>\s*)?[\s\S]*?path\s*:\s*(['"])([^'"\\]*)\1/g;
|
|
61
|
+
/** Identifier reference inside builder:/pageBuilder: bodies — `AuthorsScreen`, `Authors`. */
|
|
62
|
+
const SCREEN_WIDGET_REGEX = /\b([A-Z][A-Za-z0-9_]*)\s*\(/g;
|
|
63
|
+
/** Top-level Dart import: `import 'src/screens/authors.dart';` or `package:foo/bar.dart`. */
|
|
64
|
+
const IMPORT_REGEX = /^\s*import\s+['"]([^'"]+)['"]/gm;
|
|
65
|
+
/** Identifiers we never treat as screen widgets — Flutter framework + go_router internals. */
|
|
66
|
+
const NON_SCREEN_IDENTIFIERS = new Set([
|
|
67
|
+
"BuildContext", "GoRouterState", "GoRoute", "GoRouter", "ShellRoute",
|
|
68
|
+
"StatefulShellRoute", "ShellRouteBase", "MaterialPage", "CupertinoPage",
|
|
69
|
+
"CustomTransitionPage", "FadeTransitionPage", "Page", "Widget", "Key",
|
|
70
|
+
"ValueKey", "LocalKey", "Future", "Animation", "Duration",
|
|
71
|
+
"MaterialApp", "CupertinoApp", "Scaffold", "AppBar",
|
|
72
|
+
]);
|
|
73
|
+
export function extractDartRoutes(repositoryPath) {
|
|
74
|
+
const candidates = findDartFiles(repositoryPath);
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
const routes = [];
|
|
77
|
+
for (const file of candidates) {
|
|
78
|
+
let source;
|
|
79
|
+
try {
|
|
80
|
+
source = fs.readFileSync(file, "utf-8");
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const imports = collectImports(source, file, repositoryPath);
|
|
86
|
+
GO_ROUTE_PATH_REGEX.lastIndex = 0;
|
|
87
|
+
let match;
|
|
88
|
+
while ((match = GO_ROUTE_PATH_REGEX.exec(source)) !== null) {
|
|
89
|
+
const pathValue = match[2];
|
|
90
|
+
if (seen.has(pathValue))
|
|
91
|
+
continue;
|
|
92
|
+
seen.add(pathValue);
|
|
93
|
+
const body = extractGoRouteBody(source, match.index);
|
|
94
|
+
const inspect = inspectGoRouteBody(body);
|
|
95
|
+
const screenFiles = [];
|
|
96
|
+
for (const widget of inspect.screenWidgets) {
|
|
97
|
+
const resolved = imports.get(widget);
|
|
98
|
+
if (resolved)
|
|
99
|
+
screenFiles.push(resolved);
|
|
100
|
+
}
|
|
101
|
+
routes.push({
|
|
102
|
+
path: pathValue,
|
|
103
|
+
declaredIn: file,
|
|
104
|
+
screenWidgets: inspect.screenWidgets,
|
|
105
|
+
screenFiles,
|
|
106
|
+
isRedirectOnly: inspect.isRedirectOnly,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return routes;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Starting at `start` (the index of the matched `GoRoute` token), find the
|
|
114
|
+
* end of the GoRoute call by counting balanced `(` / `)`. Stops at the
|
|
115
|
+
* matching close paren of the outermost GoRoute(...). Strings are skipped
|
|
116
|
+
* so we don't mis-count a `)` inside a string literal.
|
|
117
|
+
*
|
|
118
|
+
* Returns the call body (between the outer parens) — empty string on failure.
|
|
119
|
+
*/
|
|
120
|
+
function extractGoRouteBody(source, start) {
|
|
121
|
+
// Find the first '(' after `GoRoute`
|
|
122
|
+
const openParen = source.indexOf("(", start);
|
|
123
|
+
if (openParen < 0)
|
|
124
|
+
return "";
|
|
125
|
+
let depth = 1;
|
|
126
|
+
let i = openParen + 1;
|
|
127
|
+
let inString = null;
|
|
128
|
+
while (i < source.length && depth > 0) {
|
|
129
|
+
const ch = source[i];
|
|
130
|
+
if (inString) {
|
|
131
|
+
if (ch === "\\") {
|
|
132
|
+
i += 2;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (ch === inString)
|
|
136
|
+
inString = null;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
if (ch === '"' || ch === "'")
|
|
140
|
+
inString = ch;
|
|
141
|
+
else if (ch === "(")
|
|
142
|
+
depth++;
|
|
143
|
+
else if (ch === ")")
|
|
144
|
+
depth--;
|
|
145
|
+
}
|
|
146
|
+
i++;
|
|
147
|
+
}
|
|
148
|
+
if (depth !== 0)
|
|
149
|
+
return "";
|
|
150
|
+
return source.slice(openParen + 1, i - 1);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Inspect a GoRoute body to find:
|
|
154
|
+
* - screen widget identifiers from builder:/pageBuilder:
|
|
155
|
+
* - whether the route is redirect-only (no builder/pageBuilder)
|
|
156
|
+
*
|
|
157
|
+
* The `routes:` nested GoRoute siblings are stripped before scanning so
|
|
158
|
+
* we don't pick up child route widgets as part of the parent's screens.
|
|
159
|
+
*/
|
|
160
|
+
function inspectGoRouteBody(body) {
|
|
161
|
+
const builderPart = extractKeyValue(body, "builder");
|
|
162
|
+
const pageBuilderPart = extractKeyValue(body, "pageBuilder");
|
|
163
|
+
const redirectPart = extractKeyValue(body, "redirect");
|
|
164
|
+
const screenWidgets = [];
|
|
165
|
+
for (const part of [builderPart, pageBuilderPart]) {
|
|
166
|
+
if (!part)
|
|
167
|
+
continue;
|
|
168
|
+
SCREEN_WIDGET_REGEX.lastIndex = 0;
|
|
169
|
+
let m;
|
|
170
|
+
while ((m = SCREEN_WIDGET_REGEX.exec(part)) !== null) {
|
|
171
|
+
const id = m[1];
|
|
172
|
+
if (NON_SCREEN_IDENTIFIERS.has(id))
|
|
173
|
+
continue;
|
|
174
|
+
if (!screenWidgets.includes(id))
|
|
175
|
+
screenWidgets.push(id);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const hasBuilder = !!builderPart || !!pageBuilderPart;
|
|
179
|
+
const hasRedirect = !!redirectPart;
|
|
180
|
+
const isRedirectOnly = hasRedirect && !hasBuilder;
|
|
181
|
+
return { screenWidgets, isRedirectOnly };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Find a `key:` value in a GoRoute body. Returns the substring after `key:`
|
|
185
|
+
* up to the next top-level comma (depth 0 brace/paren/bracket), or `null`
|
|
186
|
+
* when the key isn't present. Strings and nested groups are skipped.
|
|
187
|
+
*/
|
|
188
|
+
function extractKeyValue(body, key) {
|
|
189
|
+
const re = new RegExp(`\\b${key}\\s*:`, "g");
|
|
190
|
+
const m = re.exec(body);
|
|
191
|
+
if (!m)
|
|
192
|
+
return null;
|
|
193
|
+
let i = m.index + m[0].length;
|
|
194
|
+
// Skip whitespace
|
|
195
|
+
while (i < body.length && /\s/.test(body[i]))
|
|
196
|
+
i++;
|
|
197
|
+
const start = i;
|
|
198
|
+
let depth = 0;
|
|
199
|
+
let inString = null;
|
|
200
|
+
while (i < body.length) {
|
|
201
|
+
const ch = body[i];
|
|
202
|
+
if (inString) {
|
|
203
|
+
if (ch === "\\") {
|
|
204
|
+
i += 2;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (ch === inString)
|
|
208
|
+
inString = null;
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
if (ch === '"' || ch === "'")
|
|
212
|
+
inString = ch;
|
|
213
|
+
else if (ch === "(" || ch === "[" || ch === "{")
|
|
214
|
+
depth++;
|
|
215
|
+
else if (ch === ")" || ch === "]" || ch === "}") {
|
|
216
|
+
if (depth === 0)
|
|
217
|
+
break;
|
|
218
|
+
depth--;
|
|
219
|
+
}
|
|
220
|
+
else if (ch === "," && depth === 0)
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
i++;
|
|
224
|
+
}
|
|
225
|
+
return body.slice(start, i).trim();
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Build a `local-name → absolute-file-path` map from the file's imports.
|
|
229
|
+
*
|
|
230
|
+
* Dart imports two ways:
|
|
231
|
+
* import 'src/screens/authors.dart'; → relative within current package
|
|
232
|
+
* import 'package:foo/screens/x.dart'; → cross-package, not resolved here
|
|
233
|
+
*
|
|
234
|
+
* We only resolve relative imports (the common case for in-app screens).
|
|
235
|
+
* The "local name" for a relative import is taken as ALL identifiers that
|
|
236
|
+
* the file exports — but since we don't parse the imported file's exports,
|
|
237
|
+
* we use a coarse approximation: the importing file's `import 'X.dart';`
|
|
238
|
+
* makes available any PascalCase identifier referenced from the file's
|
|
239
|
+
* scope. We map every identifier we see in builder/pageBuilder bodies to
|
|
240
|
+
* the union of relative imports — first matching one wins.
|
|
241
|
+
*
|
|
242
|
+
* For the common Flutter pattern (one screen widget per screen file with
|
|
243
|
+
* a matching name like `AuthorsScreen`), we approximate via filename:
|
|
244
|
+
* import 'src/screens/authors.dart' → expose `AuthorsScreen`, `Authors`
|
|
245
|
+
*
|
|
246
|
+
* False positives on this approximation just mean we surface a route that
|
|
247
|
+
* shouldn't have been; false negatives just mean we fall back to "no
|
|
248
|
+
* diff-match" and surface all routes (the previous behavior).
|
|
249
|
+
*/
|
|
250
|
+
function collectImports(source, filePath, repoPath) {
|
|
251
|
+
const result = new Map();
|
|
252
|
+
const fileDir = path.dirname(filePath);
|
|
253
|
+
IMPORT_REGEX.lastIndex = 0;
|
|
254
|
+
let match;
|
|
255
|
+
while ((match = IMPORT_REGEX.exec(source)) !== null) {
|
|
256
|
+
const spec = match[1];
|
|
257
|
+
if (spec.startsWith("package:") || spec.startsWith("dart:"))
|
|
258
|
+
continue;
|
|
259
|
+
if (!spec.endsWith(".dart"))
|
|
260
|
+
continue;
|
|
261
|
+
const resolved = path.resolve(fileDir, spec);
|
|
262
|
+
if (!resolved.startsWith(repoPath))
|
|
263
|
+
continue;
|
|
264
|
+
// Filename-based heuristic: `authors.dart` exposes `Authors`, `AuthorsScreen`,
|
|
265
|
+
// `AuthorsScreenState`, etc. We register a few common shapes derived from
|
|
266
|
+
// the file's basename. Conflicts (the same identifier present in two
|
|
267
|
+
// imports) resolve to the first registration.
|
|
268
|
+
const base = path.basename(spec, ".dart");
|
|
269
|
+
const camel = snakeToCamel(base);
|
|
270
|
+
const candidates = [
|
|
271
|
+
camel,
|
|
272
|
+
camel + "Screen",
|
|
273
|
+
camel + "Page",
|
|
274
|
+
camel + "View",
|
|
275
|
+
];
|
|
276
|
+
for (const id of candidates) {
|
|
277
|
+
if (!result.has(id))
|
|
278
|
+
result.set(id, resolved);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
function snakeToCamel(s) {
|
|
284
|
+
return s
|
|
285
|
+
.split("_")
|
|
286
|
+
.map((p, i) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
287
|
+
.join("");
|
|
288
|
+
}
|
|
289
|
+
function findDartFiles(repoPath) {
|
|
290
|
+
const matches = [];
|
|
291
|
+
function walk(dir, depth) {
|
|
292
|
+
if (depth > MAX_WALK_DEPTH)
|
|
293
|
+
return;
|
|
294
|
+
if (matches.length >= MAX_CANDIDATE_FILES)
|
|
295
|
+
return;
|
|
296
|
+
let entries;
|
|
297
|
+
try {
|
|
298
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
for (const entry of entries) {
|
|
304
|
+
if (matches.length >= MAX_CANDIDATE_FILES)
|
|
305
|
+
return;
|
|
306
|
+
const full = path.join(dir, entry.name);
|
|
307
|
+
if (entry.isDirectory()) {
|
|
308
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith("."))
|
|
309
|
+
continue;
|
|
310
|
+
walk(full, depth + 1);
|
|
311
|
+
}
|
|
312
|
+
else if (entry.isFile() && entry.name.endsWith(".dart")) {
|
|
313
|
+
matches.push(full);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
walk(repoPath, 0);
|
|
318
|
+
return matches;
|
|
319
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// No external mocks needed — the extractor is pure fs + regex.
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { extractDartRoutes } from "./dartRouteExtractor.js";
|
|
6
|
+
function makeTmpRepo(files) {
|
|
7
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-dart-route-test-"));
|
|
8
|
+
for (const f of files) {
|
|
9
|
+
const fullPath = path.join(dir, f.path);
|
|
10
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
11
|
+
fs.writeFileSync(fullPath, f.content);
|
|
12
|
+
}
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
function cleanup(dir) {
|
|
16
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
describe("extractDartRoutes", () => {
|
|
19
|
+
it("returns empty when repo has no .dart files", () => {
|
|
20
|
+
const repo = makeTmpRepo([
|
|
21
|
+
{ path: "lib/main.dart", content: "void main() => runApp(MyApp());" },
|
|
22
|
+
]);
|
|
23
|
+
expect(extractDartRoutes(repo)).toEqual([]);
|
|
24
|
+
cleanup(repo);
|
|
25
|
+
});
|
|
26
|
+
it("extracts a single GoRoute with double-quoted path", () => {
|
|
27
|
+
const repo = makeTmpRepo([
|
|
28
|
+
{
|
|
29
|
+
path: "lib/router.dart",
|
|
30
|
+
content: `
|
|
31
|
+
import 'package:go_router/go_router.dart';
|
|
32
|
+
|
|
33
|
+
final router = GoRouter(routes: [
|
|
34
|
+
GoRoute(path: "/login", builder: (ctx, st) => LoginScreen()),
|
|
35
|
+
]);
|
|
36
|
+
`,
|
|
37
|
+
},
|
|
38
|
+
]);
|
|
39
|
+
const routes = extractDartRoutes(repo);
|
|
40
|
+
expect(routes).toHaveLength(1);
|
|
41
|
+
expect(routes[0].path).toBe("/login");
|
|
42
|
+
expect(routes[0].declaredIn).toMatch(/router\.dart$/);
|
|
43
|
+
cleanup(repo);
|
|
44
|
+
});
|
|
45
|
+
it("extracts a single GoRoute with single-quoted path", () => {
|
|
46
|
+
const repo = makeTmpRepo([
|
|
47
|
+
{
|
|
48
|
+
path: "lib/app.dart",
|
|
49
|
+
content: `GoRoute(path: '/home', builder: (c, s) => Home())`,
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
const routes = extractDartRoutes(repo);
|
|
53
|
+
expect(routes).toHaveLength(1);
|
|
54
|
+
expect(routes[0].path).toBe("/home");
|
|
55
|
+
cleanup(repo);
|
|
56
|
+
});
|
|
57
|
+
it("extracts multiple GoRoutes from one file", () => {
|
|
58
|
+
const repo = makeTmpRepo([
|
|
59
|
+
{
|
|
60
|
+
path: "lib/router.dart",
|
|
61
|
+
content: `
|
|
62
|
+
final router = GoRouter(routes: [
|
|
63
|
+
GoRoute(path: '/login', builder: (c, s) => LoginScreen()),
|
|
64
|
+
GoRoute(path: '/dashboard', builder: (c, s) => DashboardScreen()),
|
|
65
|
+
GoRoute(path: '/profile/:id', builder: (c, s) => ProfileScreen()),
|
|
66
|
+
]);
|
|
67
|
+
`,
|
|
68
|
+
},
|
|
69
|
+
]);
|
|
70
|
+
const routes = extractDartRoutes(repo);
|
|
71
|
+
expect(routes.map((r) => r.path).sort()).toEqual([
|
|
72
|
+
"/dashboard",
|
|
73
|
+
"/login",
|
|
74
|
+
"/profile/:id",
|
|
75
|
+
]);
|
|
76
|
+
cleanup(repo);
|
|
77
|
+
});
|
|
78
|
+
it("extracts nested GoRoutes (sub-routes inside parent)", () => {
|
|
79
|
+
const repo = makeTmpRepo([
|
|
80
|
+
{
|
|
81
|
+
path: "lib/router.dart",
|
|
82
|
+
content: `
|
|
83
|
+
GoRoute(
|
|
84
|
+
path: '/settings',
|
|
85
|
+
builder: (c, s) => SettingsScreen(),
|
|
86
|
+
routes: [
|
|
87
|
+
GoRoute(path: 'account', builder: (c, s) => AccountScreen()),
|
|
88
|
+
GoRoute(path: 'privacy', builder: (c, s) => PrivacyScreen()),
|
|
89
|
+
],
|
|
90
|
+
);
|
|
91
|
+
`,
|
|
92
|
+
},
|
|
93
|
+
]);
|
|
94
|
+
const routes = extractDartRoutes(repo);
|
|
95
|
+
expect(routes.map((r) => r.path).sort()).toEqual([
|
|
96
|
+
"/settings",
|
|
97
|
+
"account",
|
|
98
|
+
"privacy",
|
|
99
|
+
]);
|
|
100
|
+
cleanup(repo);
|
|
101
|
+
});
|
|
102
|
+
it("walks lib/ and skips build, .dart_tool, ios, android, macos, web", () => {
|
|
103
|
+
const repo = makeTmpRepo([
|
|
104
|
+
{
|
|
105
|
+
path: "lib/router.dart",
|
|
106
|
+
content: `GoRoute(path: '/real', builder: (c, s) => X())`,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
path: "build/app/intermediates/router.dart",
|
|
110
|
+
content: `GoRoute(path: '/build-fake', builder: (c, s) => X())`,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
path: ".dart_tool/something.dart",
|
|
114
|
+
content: `GoRoute(path: '/tool-fake', builder: (c, s) => X())`,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
path: "ios/Generated.dart",
|
|
118
|
+
content: `GoRoute(path: '/ios-fake', builder: (c, s) => X())`,
|
|
119
|
+
},
|
|
120
|
+
]);
|
|
121
|
+
const routes = extractDartRoutes(repo);
|
|
122
|
+
expect(routes.map((r) => r.path)).toEqual(["/real"]);
|
|
123
|
+
cleanup(repo);
|
|
124
|
+
});
|
|
125
|
+
it("ignores GoRoute calls without a string-literal path (constants, expressions)", () => {
|
|
126
|
+
const repo = makeTmpRepo([
|
|
127
|
+
{
|
|
128
|
+
path: "lib/router.dart",
|
|
129
|
+
content: `
|
|
130
|
+
const _kHome = '/home';
|
|
131
|
+
final router = GoRouter(routes: [
|
|
132
|
+
GoRoute(path: _kHome, builder: (c, s) => H()),
|
|
133
|
+
GoRoute(path: '/login', builder: (c, s) => L()),
|
|
134
|
+
]);
|
|
135
|
+
`,
|
|
136
|
+
},
|
|
137
|
+
]);
|
|
138
|
+
const routes = extractDartRoutes(repo);
|
|
139
|
+
expect(routes.map((r) => r.path)).toEqual(["/login"]);
|
|
140
|
+
cleanup(repo);
|
|
141
|
+
});
|
|
142
|
+
it("returns empty for non-Flutter Dart files (no GoRoute calls)", () => {
|
|
143
|
+
const repo = makeTmpRepo([
|
|
144
|
+
{
|
|
145
|
+
path: "bin/server.dart",
|
|
146
|
+
content: `
|
|
147
|
+
void main() async {
|
|
148
|
+
final server = await HttpServer.bind('localhost', 8080);
|
|
149
|
+
await for (final req in server) { /* ... */ }
|
|
150
|
+
}
|
|
151
|
+
`,
|
|
152
|
+
},
|
|
153
|
+
]);
|
|
154
|
+
expect(extractDartRoutes(repo)).toEqual([]);
|
|
155
|
+
cleanup(repo);
|
|
156
|
+
});
|
|
157
|
+
it("deduplicates identical GoRoute paths declared multiple times", () => {
|
|
158
|
+
const repo = makeTmpRepo([
|
|
159
|
+
{
|
|
160
|
+
path: "lib/a.dart",
|
|
161
|
+
content: `GoRoute(path: '/login', builder: (c, s) => A())`,
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
path: "lib/b.dart",
|
|
165
|
+
content: `GoRoute(path: '/login', builder: (c, s) => B())`,
|
|
166
|
+
},
|
|
167
|
+
]);
|
|
168
|
+
const routes = extractDartRoutes(repo);
|
|
169
|
+
expect(routes).toHaveLength(1);
|
|
170
|
+
expect(routes[0].path).toBe("/login");
|
|
171
|
+
cleanup(repo);
|
|
172
|
+
});
|
|
173
|
+
// Real-world: extract all routes from the books example we built locally.
|
|
174
|
+
// This guards the regex against the actual customer-style GoRouter shape
|
|
175
|
+
// — multi-line declarations with redirect+pageBuilder mixed in.
|
|
176
|
+
it("extracts the books example's six routes", () => {
|
|
177
|
+
const repo = makeTmpRepo([
|
|
178
|
+
{
|
|
179
|
+
path: "lib/main.dart",
|
|
180
|
+
content: `
|
|
181
|
+
import 'package:go_router/go_router.dart';
|
|
182
|
+
|
|
183
|
+
late final GoRouter _router = GoRouter(
|
|
184
|
+
routes: <GoRoute>[
|
|
185
|
+
GoRoute(path: '/', redirect: (_, _) => '/books'),
|
|
186
|
+
GoRoute(
|
|
187
|
+
path: '/signin',
|
|
188
|
+
pageBuilder: (BuildContext context, GoRouterState state) => SignIn(),
|
|
189
|
+
),
|
|
190
|
+
GoRoute(path: '/books', redirect: (_, _) => '/books/popular'),
|
|
191
|
+
GoRoute(
|
|
192
|
+
path: '/book/:bookId',
|
|
193
|
+
redirect: (BuildContext context, GoRouterState state) =>
|
|
194
|
+
'/books/all/' + state.pathParameters['bookId']!,
|
|
195
|
+
),
|
|
196
|
+
GoRoute(
|
|
197
|
+
path: '/books/:kind(new|all|popular)',
|
|
198
|
+
pageBuilder: (BuildContext context, GoRouterState state) => Books(),
|
|
199
|
+
routes: <GoRoute>[
|
|
200
|
+
GoRoute(
|
|
201
|
+
path: ':bookId',
|
|
202
|
+
builder: (BuildContext context, GoRouterState state) => BookDetails(),
|
|
203
|
+
),
|
|
204
|
+
],
|
|
205
|
+
),
|
|
206
|
+
GoRoute(
|
|
207
|
+
path: '/author/:authorId',
|
|
208
|
+
redirect: (BuildContext context, GoRouterState state) =>
|
|
209
|
+
'/authors/' + state.pathParameters['authorId']!,
|
|
210
|
+
),
|
|
211
|
+
GoRoute(
|
|
212
|
+
path: '/authors',
|
|
213
|
+
pageBuilder: (BuildContext context, GoRouterState state) => Authors(),
|
|
214
|
+
routes: <GoRoute>[
|
|
215
|
+
GoRoute(
|
|
216
|
+
path: ':authorId',
|
|
217
|
+
builder: (BuildContext context, GoRouterState state) => AuthorDetails(),
|
|
218
|
+
),
|
|
219
|
+
],
|
|
220
|
+
),
|
|
221
|
+
GoRoute(
|
|
222
|
+
path: '/settings',
|
|
223
|
+
pageBuilder: (BuildContext context, GoRouterState state) => Settings(),
|
|
224
|
+
),
|
|
225
|
+
],
|
|
226
|
+
);
|
|
227
|
+
`,
|
|
228
|
+
},
|
|
229
|
+
]);
|
|
230
|
+
const routes = extractDartRoutes(repo);
|
|
231
|
+
const paths = routes.map((r) => r.path).sort();
|
|
232
|
+
// Top-level absolute paths.
|
|
233
|
+
expect(paths).toEqual([
|
|
234
|
+
"/",
|
|
235
|
+
"/author/:authorId",
|
|
236
|
+
"/authors",
|
|
237
|
+
"/book/:bookId",
|
|
238
|
+
"/books",
|
|
239
|
+
"/books/:kind(new|all|popular)",
|
|
240
|
+
"/settings",
|
|
241
|
+
"/signin",
|
|
242
|
+
":authorId",
|
|
243
|
+
":bookId",
|
|
244
|
+
].sort());
|
|
245
|
+
cleanup(repo);
|
|
246
|
+
});
|
|
247
|
+
// ── New shape: screenWidgets, screenFiles, isRedirectOnly ─────────────────
|
|
248
|
+
it("flags redirect-only routes (no builder/pageBuilder)", () => {
|
|
249
|
+
const repo = makeTmpRepo([
|
|
250
|
+
{
|
|
251
|
+
path: "lib/router.dart",
|
|
252
|
+
content: `
|
|
253
|
+
GoRoute(path: '/', redirect: (_, _) => '/home'),
|
|
254
|
+
GoRoute(path: '/home', builder: (c, s) => Home()),
|
|
255
|
+
`,
|
|
256
|
+
},
|
|
257
|
+
]);
|
|
258
|
+
const routes = extractDartRoutes(repo);
|
|
259
|
+
const root = routes.find((r) => r.path === "/");
|
|
260
|
+
const home = routes.find((r) => r.path === "/home");
|
|
261
|
+
expect(root?.isRedirectOnly).toBe(true);
|
|
262
|
+
expect(home?.isRedirectOnly).toBe(false);
|
|
263
|
+
cleanup(repo);
|
|
264
|
+
});
|
|
265
|
+
it("captures screen widget identifiers from builder/pageBuilder", () => {
|
|
266
|
+
const repo = makeTmpRepo([
|
|
267
|
+
{
|
|
268
|
+
path: "lib/router.dart",
|
|
269
|
+
content: `
|
|
270
|
+
GoRoute(path: '/login', builder: (c, s) => LoginScreen()),
|
|
271
|
+
GoRoute(path: '/profile', pageBuilder: (c, s) => MaterialPage(child: ProfileScreen())),
|
|
272
|
+
`,
|
|
273
|
+
},
|
|
274
|
+
]);
|
|
275
|
+
const routes = extractDartRoutes(repo);
|
|
276
|
+
const login = routes.find((r) => r.path === "/login");
|
|
277
|
+
const profile = routes.find((r) => r.path === "/profile");
|
|
278
|
+
expect(login?.screenWidgets).toContain("LoginScreen");
|
|
279
|
+
expect(profile?.screenWidgets).toContain("ProfileScreen");
|
|
280
|
+
// Ignored framework identifiers should NOT appear.
|
|
281
|
+
expect(login?.screenWidgets).not.toContain("BuildContext");
|
|
282
|
+
expect(profile?.screenWidgets).not.toContain("MaterialPage");
|
|
283
|
+
cleanup(repo);
|
|
284
|
+
});
|
|
285
|
+
it("resolves screenWidgets to imported file paths via filename heuristic", () => {
|
|
286
|
+
const repo = makeTmpRepo([
|
|
287
|
+
{
|
|
288
|
+
path: "lib/main.dart",
|
|
289
|
+
content: `
|
|
290
|
+
import 'src/screens/authors.dart';
|
|
291
|
+
import 'src/screens/settings.dart';
|
|
292
|
+
|
|
293
|
+
GoRoute(path: '/authors', builder: (c, s) => AuthorsScreen()),
|
|
294
|
+
GoRoute(path: '/settings', builder: (c, s) => SettingsScreen()),
|
|
295
|
+
`,
|
|
296
|
+
},
|
|
297
|
+
{ path: "lib/src/screens/authors.dart", content: "// AuthorsScreen widget" },
|
|
298
|
+
{ path: "lib/src/screens/settings.dart", content: "// SettingsScreen widget" },
|
|
299
|
+
]);
|
|
300
|
+
const routes = extractDartRoutes(repo);
|
|
301
|
+
const authors = routes.find((r) => r.path === "/authors");
|
|
302
|
+
const settings = routes.find((r) => r.path === "/settings");
|
|
303
|
+
expect(authors?.screenFiles[0]).toMatch(/lib\/src\/screens\/authors\.dart$/);
|
|
304
|
+
expect(settings?.screenFiles[0]).toMatch(/lib\/src\/screens\/settings\.dart$/);
|
|
305
|
+
cleanup(repo);
|
|
306
|
+
});
|
|
307
|
+
});
|