@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
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import * as fs from "fs";
|
|
31
31
|
import * as path from "path";
|
|
32
|
+
import { extractDartRoutes } from "./dartRouteExtractor.js";
|
|
32
33
|
import { extractSourceRoutes } from "./sourceRouteExtractor.js";
|
|
34
|
+
import { hasFlutterSdkDep } from "../prompts/test-recommendation/scopeAssessment.js";
|
|
33
35
|
import { readWorkspaceConfigRaw } from "./workspaceAuth.js";
|
|
34
36
|
// ── Strategy 1: framework route grep ────────────────────────────────────────
|
|
35
37
|
/**
|
|
@@ -228,6 +230,68 @@ export function findCandidatePagesBySourceRoute(frontendFiles, baseUrl, reposito
|
|
|
228
230
|
}
|
|
229
231
|
return Array.from(byUrl.values());
|
|
230
232
|
}
|
|
233
|
+
// ── Strategy 2.5: Dart GoRouter routes ─────────────────────────────────────
|
|
234
|
+
/**
|
|
235
|
+
* Strategy 2.5: walk the repo's Dart files for GoRoute(path: '...') calls.
|
|
236
|
+
* Gated on `hasFlutterSdkDep(repoPath)` so non-Flutter Dart projects (e.g.
|
|
237
|
+
* shelf_router servers) don't accidentally emit URLs.
|
|
238
|
+
*
|
|
239
|
+
* Diff-matching: each GoRoute references its screen widget via
|
|
240
|
+
* `builder:`/`pageBuilder:` (e.g. `AuthorsScreen`). The extractor resolves
|
|
241
|
+
* those identifiers through the route file's imports to absolute screen
|
|
242
|
+
* file paths. We surface only routes whose screen file appears in the
|
|
243
|
+
* changed-file list. Falls back to all-routes when no route's screen
|
|
244
|
+
* file matches — preserves coverage for "router untouched, screen edited"
|
|
245
|
+
* cases when the import-resolution heuristic doesn't find a match.
|
|
246
|
+
*
|
|
247
|
+
* Filters applied:
|
|
248
|
+
* 1. Drop redirect-only routes (`redirect:` with no `builder:`/`pageBuilder:`).
|
|
249
|
+
* These have no UI to render — navigating there is wasted work.
|
|
250
|
+
* 2. Drop nested relative children (paths that don't start with `/`).
|
|
251
|
+
* Without parent-path composition the URL is meaningless.
|
|
252
|
+
*
|
|
253
|
+
* Returns empty when the repo isn't Flutter or no GoRoute calls are
|
|
254
|
+
* found — caller falls through to root-fallback.
|
|
255
|
+
*/
|
|
256
|
+
export function findCandidatePagesByDartRoute(repositoryPath, baseUrl, frontendFiles) {
|
|
257
|
+
if (!hasFlutterSdkDep(repositoryPath))
|
|
258
|
+
return [];
|
|
259
|
+
const dartRoutes = extractDartRoutes(repositoryPath);
|
|
260
|
+
if (dartRoutes.length === 0)
|
|
261
|
+
return [];
|
|
262
|
+
// Strip redirect-only and relative routes — they're never valid candidates.
|
|
263
|
+
const renderable = dartRoutes.filter((r) => !r.isRedirectOnly && r.path.startsWith("/"));
|
|
264
|
+
if (renderable.length === 0)
|
|
265
|
+
return [];
|
|
266
|
+
// Diff-match each route against frontendFiles via its screenFiles.
|
|
267
|
+
const changedAbs = new Set(frontendFiles.map((f) => path.resolve(repositoryPath, f)));
|
|
268
|
+
const matched = renderable.filter((r) => r.screenFiles.some((sf) => changedAbs.has(sf)));
|
|
269
|
+
// Fall back to all renderable routes when nothing diff-matches. The
|
|
270
|
+
// import-resolution heuristic isn't perfect (custom widget naming, multiple
|
|
271
|
+
// screens per file, factory functions); a zero-match should not collapse
|
|
272
|
+
// the candidate list to empty when the repo clearly has UI changes.
|
|
273
|
+
const surfaced = matched.length > 0 ? matched : renderable;
|
|
274
|
+
const normalizedBase = baseUrl.replace(/\/$/, "");
|
|
275
|
+
const byUrl = new Map();
|
|
276
|
+
for (const route of surfaced) {
|
|
277
|
+
const fullUrl = normalizedBase + route.path;
|
|
278
|
+
const sourceFile = path.relative(repositoryPath, route.declaredIn);
|
|
279
|
+
const existing = byUrl.get(fullUrl);
|
|
280
|
+
if (existing) {
|
|
281
|
+
if (!existing.sourcedFrom.includes(sourceFile)) {
|
|
282
|
+
existing.sourcedFrom.push(sourceFile);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
byUrl.set(fullUrl, {
|
|
287
|
+
url: fullUrl,
|
|
288
|
+
sourcedFrom: [sourceFile],
|
|
289
|
+
strategy: "dart-go-router",
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return Array.from(byUrl.values());
|
|
294
|
+
}
|
|
231
295
|
// ── Strategy 3: root fallback ───────────────────────────────────────────────
|
|
232
296
|
/**
|
|
233
297
|
* Strategy 3: when strategies 1+2 yield nothing, fall back to the workspace's
|
|
@@ -314,6 +378,9 @@ export async function enumerateCandidateUiPages(repositoryPath, frontendFiles) {
|
|
|
314
378
|
const fromSource = findCandidatePagesBySourceRoute(frontendFiles, baseUrl, repositoryPath);
|
|
315
379
|
if (fromSource.length > 0)
|
|
316
380
|
return fromSource;
|
|
381
|
+
const fromDart = findCandidatePagesByDartRoute(repositoryPath, baseUrl, frontendFiles);
|
|
382
|
+
if (fromDart.length > 0)
|
|
383
|
+
return fromDart;
|
|
317
384
|
const fallback = await findRootFallbackPage(repositoryPath);
|
|
318
385
|
return fallback ? [fallback] : [];
|
|
319
386
|
}
|
|
@@ -240,6 +240,48 @@ describe("pickFrontendBaseUrl", () => {
|
|
|
240
240
|
});
|
|
241
241
|
expect(await pickFrontendBaseUrl("/repo")).toBe("http://localhost:8000");
|
|
242
242
|
});
|
|
243
|
+
// Flutter support — the load-bearing assumption from the Confluence plan.
|
|
244
|
+
// init_workspace writes a single service with framework: playwright for a
|
|
245
|
+
// Flutter web app. Heuristic 1 must match by serviceName suffix
|
|
246
|
+
// (e.g. "birdle-frontend") because Heuristic 2 only knows JS/TS frontend
|
|
247
|
+
// frameworks. Without this, the agent never reaches the browser regardless
|
|
248
|
+
// of the .dart classifier fix.
|
|
249
|
+
it("picks the Flutter playwright-framework service by name suffix", async () => {
|
|
250
|
+
readSpy.mockResolvedValue({
|
|
251
|
+
services: [
|
|
252
|
+
{
|
|
253
|
+
serviceName: "birdle-frontend",
|
|
254
|
+
language: "typescript",
|
|
255
|
+
framework: "playwright",
|
|
256
|
+
api: { baseUrl: "http://localhost:8080" },
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
expect(await pickFrontendBaseUrl("/repo")).toBe("http://localhost:8080");
|
|
261
|
+
});
|
|
262
|
+
// Edge case: Flutter service has no "frontend"/"ui"/"app"/"web" suffix
|
|
263
|
+
// (so H1 misses) and `framework: playwright` isn't in H2's TS/JS framework
|
|
264
|
+
// allow-list. The chosen URL must come from H3 (first service with baseUrl).
|
|
265
|
+
// We add a second service to prove H3 picked the first, not H1 by accident.
|
|
266
|
+
it("falls back to first service when name+framework don't match H1 or H2", async () => {
|
|
267
|
+
readSpy.mockResolvedValue({
|
|
268
|
+
services: [
|
|
269
|
+
{
|
|
270
|
+
serviceName: "birdle",
|
|
271
|
+
language: "typescript",
|
|
272
|
+
framework: "playwright",
|
|
273
|
+
api: { baseUrl: "http://localhost:8080" },
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
serviceName: "other-service",
|
|
277
|
+
language: "go",
|
|
278
|
+
framework: "gin",
|
|
279
|
+
api: { baseUrl: "http://localhost:9000" },
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
});
|
|
283
|
+
expect(await pickFrontendBaseUrl("/repo")).toBe("http://localhost:8080");
|
|
284
|
+
});
|
|
243
285
|
});
|
|
244
286
|
// ---------------------------------------------------------------------------
|
|
245
287
|
// findRootFallbackPage
|
|
@@ -375,6 +417,186 @@ export default function App() { return <Routes><Route path="/cart" element={<Car
|
|
|
375
417
|
});
|
|
376
418
|
fs.rmSync(repo, { recursive: true, force: true });
|
|
377
419
|
});
|
|
420
|
+
// Flutter end-to-end: pubspec.yaml declares the Flutter SDK, .dart files
|
|
421
|
+
// appear in the diff (passed in by callers after isFrontendFile filtering),
|
|
422
|
+
// workspace.yml has a baseUrl. Strategies 1, 2, and 2.5 don't match
|
|
423
|
+
// (no framework config; TS extractor doesn't see .dart; no GoRoute calls).
|
|
424
|
+
// Result: root-fallback with the workspace baseUrl.
|
|
425
|
+
it("returns root-fallback for a Flutter repo with no GoRoute declarations", async () => {
|
|
426
|
+
readSpy.mockResolvedValue({
|
|
427
|
+
services: [
|
|
428
|
+
{
|
|
429
|
+
serviceName: "birdle-frontend",
|
|
430
|
+
framework: "playwright",
|
|
431
|
+
api: { baseUrl: "http://localhost:8080" },
|
|
432
|
+
},
|
|
433
|
+
],
|
|
434
|
+
});
|
|
435
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-flutter-enum-"));
|
|
436
|
+
fs.writeFileSync(path.join(repo, "pubspec.yaml"), "name: birdle\ndependencies:\n flutter:\n sdk: flutter\n");
|
|
437
|
+
fs.mkdirSync(path.join(repo, "lib"), { recursive: true });
|
|
438
|
+
fs.writeFileSync(path.join(repo, "lib", "main.dart"), "void main() => runApp(MyApp());");
|
|
439
|
+
const pages = await enumerateCandidateUiPages(repo, ["lib/main.dart"]);
|
|
440
|
+
expect(pages).toHaveLength(1);
|
|
441
|
+
expect(pages[0]).toMatchObject({
|
|
442
|
+
url: "http://localhost:8080",
|
|
443
|
+
strategy: "root-fallback",
|
|
444
|
+
});
|
|
445
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
446
|
+
});
|
|
447
|
+
// Strategy 2.5: Flutter repo with GoRouter declarations and no diff-match
|
|
448
|
+
// (frontendFiles names a screen file that's not imported by the router)
|
|
449
|
+
// surfaces every absolute route as a candidate URL via the all-routes
|
|
450
|
+
// fallback. Diff-match narrowing is exercised by the next test.
|
|
451
|
+
it("returns dart-go-router URLs for a Flutter repo with GoRoute declarations", async () => {
|
|
452
|
+
readSpy.mockResolvedValue({
|
|
453
|
+
services: [
|
|
454
|
+
{
|
|
455
|
+
serviceName: "books-frontend",
|
|
456
|
+
framework: "playwright",
|
|
457
|
+
api: { baseUrl: "http://localhost:8080" },
|
|
458
|
+
},
|
|
459
|
+
],
|
|
460
|
+
});
|
|
461
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-go-router-"));
|
|
462
|
+
fs.writeFileSync(path.join(repo, "pubspec.yaml"), "name: books\ndependencies:\n flutter:\n sdk: flutter\n go_router: ^14.0.0\n");
|
|
463
|
+
fs.mkdirSync(path.join(repo, "lib"), { recursive: true });
|
|
464
|
+
fs.writeFileSync(path.join(repo, "lib", "main.dart"), `
|
|
465
|
+
final router = GoRouter(routes: [
|
|
466
|
+
GoRoute(path: '/signin', builder: (c, s) => SignIn()),
|
|
467
|
+
GoRoute(path: '/authors', builder: (c, s) => Authors()),
|
|
468
|
+
GoRoute(path: '/settings', builder: (c, s) => Settings()),
|
|
469
|
+
]);
|
|
470
|
+
`);
|
|
471
|
+
const pages = await enumerateCandidateUiPages(repo, ["lib/screens/authors.dart"]);
|
|
472
|
+
const urls = pages.map((p) => p.url).sort();
|
|
473
|
+
expect(urls).toEqual([
|
|
474
|
+
"http://localhost:8080/authors",
|
|
475
|
+
"http://localhost:8080/settings",
|
|
476
|
+
"http://localhost:8080/signin",
|
|
477
|
+
]);
|
|
478
|
+
expect(pages.every((p) => p.strategy === "dart-go-router")).toBe(true);
|
|
479
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
480
|
+
});
|
|
481
|
+
// Diff-matching: Strategy 2.5 should narrow candidate URLs to only those
|
|
482
|
+
// whose screen file is in frontendFiles. The fixture has 3 routes; only
|
|
483
|
+
// /authors's screen file (lib/src/screens/authors.dart) is in the diff.
|
|
484
|
+
it("narrows GoRouter candidates to routes whose screen file is in the diff", async () => {
|
|
485
|
+
readSpy.mockResolvedValue({
|
|
486
|
+
services: [
|
|
487
|
+
{
|
|
488
|
+
serviceName: "books-frontend",
|
|
489
|
+
framework: "playwright",
|
|
490
|
+
api: { baseUrl: "http://localhost:8080" },
|
|
491
|
+
},
|
|
492
|
+
],
|
|
493
|
+
});
|
|
494
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-go-router-diff-"));
|
|
495
|
+
fs.writeFileSync(path.join(repo, "pubspec.yaml"), "name: books\ndependencies:\n flutter:\n sdk: flutter\n");
|
|
496
|
+
fs.mkdirSync(path.join(repo, "lib", "src", "screens"), { recursive: true });
|
|
497
|
+
fs.writeFileSync(path.join(repo, "lib", "main.dart"), `
|
|
498
|
+
import 'src/screens/authors.dart';
|
|
499
|
+
import 'src/screens/settings.dart';
|
|
500
|
+
import 'src/screens/sign_in.dart';
|
|
501
|
+
|
|
502
|
+
final router = GoRouter(routes: [
|
|
503
|
+
GoRoute(path: '/sign_in', builder: (c, s) => SignInScreen()),
|
|
504
|
+
GoRoute(path: '/authors', builder: (c, s) => AuthorsScreen()),
|
|
505
|
+
GoRoute(path: '/settings', builder: (c, s) => SettingsScreen()),
|
|
506
|
+
]);
|
|
507
|
+
`);
|
|
508
|
+
fs.writeFileSync(path.join(repo, "lib", "src", "screens", "authors.dart"), "// authors");
|
|
509
|
+
fs.writeFileSync(path.join(repo, "lib", "src", "screens", "settings.dart"), "// settings");
|
|
510
|
+
fs.writeFileSync(path.join(repo, "lib", "src", "screens", "sign_in.dart"), "// signin");
|
|
511
|
+
const pages = await enumerateCandidateUiPages(repo, ["lib/src/screens/authors.dart"]);
|
|
512
|
+
const urls = pages.map((p) => p.url).sort();
|
|
513
|
+
// Only /authors should surface — its screen file is the only one in the diff.
|
|
514
|
+
expect(urls).toEqual(["http://localhost:8080/authors"]);
|
|
515
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
516
|
+
});
|
|
517
|
+
// Redirect-only routes (`redirect:` with no builder) must not surface — they
|
|
518
|
+
// have no UI to navigate to. Same fixture shape but the only diff-matching
|
|
519
|
+
// route is redirect-only, so we should fall back to the non-redirect-only
|
|
520
|
+
// routes.
|
|
521
|
+
it("filters out redirect-only GoRoutes", async () => {
|
|
522
|
+
readSpy.mockResolvedValue({
|
|
523
|
+
services: [
|
|
524
|
+
{
|
|
525
|
+
serviceName: "frontend",
|
|
526
|
+
framework: "playwright",
|
|
527
|
+
api: { baseUrl: "http://localhost:8080" },
|
|
528
|
+
},
|
|
529
|
+
],
|
|
530
|
+
});
|
|
531
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-go-router-redirect-"));
|
|
532
|
+
fs.writeFileSync(path.join(repo, "pubspec.yaml"), "name: app\ndependencies:\n flutter:\n sdk: flutter\n");
|
|
533
|
+
fs.mkdirSync(path.join(repo, "lib"), { recursive: true });
|
|
534
|
+
fs.writeFileSync(path.join(repo, "lib", "main.dart"), `
|
|
535
|
+
final router = GoRouter(routes: [
|
|
536
|
+
GoRoute(path: '/', redirect: (_, _) => '/home'),
|
|
537
|
+
GoRoute(path: '/home', builder: (c, s) => Home()),
|
|
538
|
+
]);
|
|
539
|
+
`);
|
|
540
|
+
const pages = await enumerateCandidateUiPages(repo, ["lib/main.dart"]);
|
|
541
|
+
const urls = pages.map((p) => p.url).sort();
|
|
542
|
+
// / is redirect-only — must not appear. Only /home is renderable.
|
|
543
|
+
expect(urls).toEqual(["http://localhost:8080/home"]);
|
|
544
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
545
|
+
});
|
|
546
|
+
// Fallback path: when no route's screen file matches the diff (e.g. router
|
|
547
|
+
// file edited but our import-resolution heuristic missed the screen), surface
|
|
548
|
+
// ALL renderable routes rather than collapsing to empty.
|
|
549
|
+
it("falls back to all renderable routes when no screen file matches the diff", async () => {
|
|
550
|
+
readSpy.mockResolvedValue({
|
|
551
|
+
services: [
|
|
552
|
+
{
|
|
553
|
+
serviceName: "frontend",
|
|
554
|
+
framework: "playwright",
|
|
555
|
+
api: { baseUrl: "http://localhost:8080" },
|
|
556
|
+
},
|
|
557
|
+
],
|
|
558
|
+
});
|
|
559
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-go-router-fallback-"));
|
|
560
|
+
fs.writeFileSync(path.join(repo, "pubspec.yaml"), "name: app\ndependencies:\n flutter:\n sdk: flutter\n");
|
|
561
|
+
fs.mkdirSync(path.join(repo, "lib"), { recursive: true });
|
|
562
|
+
fs.writeFileSync(path.join(repo, "lib", "main.dart"), `
|
|
563
|
+
final router = GoRouter(routes: [
|
|
564
|
+
GoRoute(path: '/foo', builder: (c, s) => FooScreen()),
|
|
565
|
+
GoRoute(path: '/bar', builder: (c, s) => BarScreen()),
|
|
566
|
+
]);
|
|
567
|
+
`);
|
|
568
|
+
// Diff touches only main.dart (the router itself, not any screen). The
|
|
569
|
+
// import-resolution heuristic finds zero matches against frontendFiles, so
|
|
570
|
+
// we surface all renderable routes.
|
|
571
|
+
const pages = await enumerateCandidateUiPages(repo, ["lib/main.dart"]);
|
|
572
|
+
const urls = pages.map((p) => p.url).sort();
|
|
573
|
+
expect(urls).toEqual([
|
|
574
|
+
"http://localhost:8080/bar",
|
|
575
|
+
"http://localhost:8080/foo",
|
|
576
|
+
]);
|
|
577
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
578
|
+
});
|
|
579
|
+
// Negative gate: pure-Dart server with a `GoRoute(`-shaped string in source
|
|
580
|
+
// (e.g. shelf_router uses similar names in tests) must NOT trigger Strategy
|
|
581
|
+
// 2.5 — pubspec.yaml lacks `sdk: flutter`, hasFlutterSdkDep returns false.
|
|
582
|
+
it("does not invoke Dart extractor when pubspec.yaml lacks sdk: flutter", async () => {
|
|
583
|
+
readSpy.mockResolvedValue({
|
|
584
|
+
services: [
|
|
585
|
+
{
|
|
586
|
+
serviceName: "server-frontend",
|
|
587
|
+
framework: "playwright",
|
|
588
|
+
api: { baseUrl: "http://localhost:8080" },
|
|
589
|
+
},
|
|
590
|
+
],
|
|
591
|
+
});
|
|
592
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-dart-server-"));
|
|
593
|
+
fs.writeFileSync(path.join(repo, "pubspec.yaml"), "name: server\ndependencies:\n shelf: ^1.0.0\n");
|
|
594
|
+
fs.mkdirSync(path.join(repo, "lib"), { recursive: true });
|
|
595
|
+
fs.writeFileSync(path.join(repo, "lib", "routes.dart"), `GoRoute(path: '/api/v1', handler: (req) => Response.ok(''))`);
|
|
596
|
+
const pages = await enumerateCandidateUiPages(repo, ["lib/routes.dart"]);
|
|
597
|
+
expect(pages.map((p) => p.strategy)).toEqual(["root-fallback"]);
|
|
598
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
599
|
+
});
|
|
378
600
|
});
|
|
379
601
|
// ---------------------------------------------------------------------------
|
|
380
602
|
// detectsFilesystemRouting
|
|
@@ -20,17 +20,27 @@ var skyramp_exports = {};
|
|
|
20
20
|
__export(skyramp_exports, {
|
|
21
21
|
ARGS_ONLY_TOOLS: () => import_types.ARGS_ONLY_TOOLS,
|
|
22
22
|
TraceRecordingBackend: () => import_traceRecordingBackend.TraceRecordingBackend,
|
|
23
|
+
actionsFromPath: () => import_skyRampImport.actionsFromPath,
|
|
23
24
|
buildJsonlContent: () => import_skyRampExport.buildJsonlContent,
|
|
25
|
+
loadTraceMcpTool: () => import_loadTraceTool.loadTraceMcpTool,
|
|
26
|
+
loadTraceSchema: () => import_loadTraceTool.loadTraceSchema,
|
|
27
|
+
parseJsonl: () => import_skyRampImport.parseJsonl,
|
|
24
28
|
writeSkyrampZip: () => import_skyRampExport.writeSkyrampZip
|
|
25
29
|
});
|
|
26
30
|
module.exports = __toCommonJS(skyramp_exports);
|
|
27
31
|
var import_traceRecordingBackend = require("./traceRecordingBackend");
|
|
28
32
|
var import_types = require("./types");
|
|
29
33
|
var import_skyRampExport = require("../test/skyRampExport");
|
|
34
|
+
var import_loadTraceTool = require("./loadTraceTool");
|
|
35
|
+
var import_skyRampImport = require("./skyRampImport");
|
|
30
36
|
// Annotate the CommonJS export names for ESM import in node:
|
|
31
37
|
0 && (module.exports = {
|
|
32
38
|
ARGS_ONLY_TOOLS,
|
|
33
39
|
TraceRecordingBackend,
|
|
40
|
+
actionsFromPath,
|
|
34
41
|
buildJsonlContent,
|
|
42
|
+
loadTraceMcpTool,
|
|
43
|
+
loadTraceSchema,
|
|
44
|
+
parseJsonl,
|
|
35
45
|
writeSkyrampZip
|
|
36
46
|
});
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var loadTraceTool_exports = {};
|
|
20
|
+
__export(loadTraceTool_exports, {
|
|
21
|
+
decodeModifiers: () => decodeModifiers,
|
|
22
|
+
describeStep: () => describeStep,
|
|
23
|
+
describeStopReason: () => describeStopReason,
|
|
24
|
+
listStepsFrom: () => listStepsFrom,
|
|
25
|
+
loadTraceMcpTool: () => loadTraceMcpTool,
|
|
26
|
+
loadTraceSchema: () => loadTraceSchema,
|
|
27
|
+
replayActions: () => replayActions,
|
|
28
|
+
urlMatchesPattern: () => urlMatchesPattern
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(loadTraceTool_exports);
|
|
31
|
+
var import_mcpBundle = require("playwright-core/lib/mcpBundle");
|
|
32
|
+
var import_utils = require("playwright-core/lib/utils");
|
|
33
|
+
var import_tool = require("../sdk/tool");
|
|
34
|
+
const loadTraceSchema = {
|
|
35
|
+
name: "skyramp_load_trace",
|
|
36
|
+
title: "Load and replay a Skyramp trace",
|
|
37
|
+
description: [
|
|
38
|
+
"Load a previously recorded Skyramp trace (.zip or .jsonl/.txt) and replay its actions against the live browser to restore state, then continue recording from that point.",
|
|
39
|
+
"The replayed actions are merged into the current recording, so subsequent browser_* steps append to them and skyramp_export_zip writes a combined trace.",
|
|
40
|
+
"STOPPING EARLY: when the user wants to stop partway through the loaded trace before continuing, set ONE of stopAtStep / stopAtUrl / stopBefore based on their prompt. Omit all three to replay the whole trace.",
|
|
41
|
+
"- stopAtStep: replay steps 1..N then stop (N is 1-based, from the numbered step list).",
|
|
42
|
+
"- stopAtUrl: stop once the active page URL matches this glob/regex/substring (checked after each action).",
|
|
43
|
+
'- stopBefore: stop right BEFORE the first action whose description contains this text (case-insensitive), e.g. "Checkout" to stop before clicking Checkout.',
|
|
44
|
+
"If unsure where to stop, first call with dryRun:true to read the numbered step list, then call again with the chosen stop parameter."
|
|
45
|
+
].join(" "),
|
|
46
|
+
inputSchema: import_mcpBundle.z.object({
|
|
47
|
+
path: import_mcpBundle.z.string().describe("Absolute path to the trace file (.zip, .jsonl, or .txt)."),
|
|
48
|
+
stopAtStep: import_mcpBundle.z.number().int().positive().optional().describe("Replay steps 1..N (1-based), then stop before continuing."),
|
|
49
|
+
stopAtUrl: import_mcpBundle.z.string().optional().describe("Stop once the active page URL matches this glob/regex/substring."),
|
|
50
|
+
stopBefore: import_mcpBundle.z.string().optional().describe("Stop before the first action whose description contains this text (case-insensitive)."),
|
|
51
|
+
dryRun: import_mcpBundle.z.boolean().optional().describe("Parse and list the steps WITHOUT replaying. Use to choose a stop point."),
|
|
52
|
+
speed: import_mcpBundle.z.enum(["fast", "slow"]).optional().describe('Replay speed. "slow" adds a 1s delay between actions (default "fast").')
|
|
53
|
+
}),
|
|
54
|
+
// Replaying drives the live browser and mutates the recording, so this is an
|
|
55
|
+
// action tool (readOnlyHint:false / destructiveHint:true), not readOnly.
|
|
56
|
+
type: "action"
|
|
57
|
+
};
|
|
58
|
+
function loadTraceMcpTool() {
|
|
59
|
+
return (0, import_tool.toMcpTool)(loadTraceSchema);
|
|
60
|
+
}
|
|
61
|
+
function decodeModifiers(mask) {
|
|
62
|
+
if (!mask)
|
|
63
|
+
return [];
|
|
64
|
+
const out = [];
|
|
65
|
+
if (mask & 1) out.push("Alt");
|
|
66
|
+
if ((mask & 6) === 6) {
|
|
67
|
+
out.push("ControlOrMeta");
|
|
68
|
+
} else {
|
|
69
|
+
if (mask & 2) out.push("Control");
|
|
70
|
+
if (mask & 4) out.push("Meta");
|
|
71
|
+
}
|
|
72
|
+
if (mask & 8) out.push("Shift");
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
const METADATA_ONLY_ACTIONS = /* @__PURE__ */ new Set([
|
|
76
|
+
"marker",
|
|
77
|
+
"comment",
|
|
78
|
+
"beginBlock",
|
|
79
|
+
"endBlock",
|
|
80
|
+
"openPage",
|
|
81
|
+
"closePage",
|
|
82
|
+
"modalOpen",
|
|
83
|
+
"modalClose",
|
|
84
|
+
"iframeLoad",
|
|
85
|
+
"visualSnapshot",
|
|
86
|
+
"tableSnapshot",
|
|
87
|
+
"domSnapshot",
|
|
88
|
+
"selectArea",
|
|
89
|
+
"penTool",
|
|
90
|
+
"assertApiRequest"
|
|
91
|
+
]);
|
|
92
|
+
function describeStep(action, index) {
|
|
93
|
+
const a = action.action;
|
|
94
|
+
const onPage = action.frame.pageAlias && action.frame.pageAlias !== "page" ? ` [${action.frame.pageAlias}]` : "";
|
|
95
|
+
let detail = "";
|
|
96
|
+
if (a.name === "navigate")
|
|
97
|
+
detail = a.url;
|
|
98
|
+
else if (a.selector)
|
|
99
|
+
detail = (0, import_utils.asLocator)("javascript", a.selector);
|
|
100
|
+
if ((a.name === "fill" || a.name === "assertText") && a.text !== void 0)
|
|
101
|
+
detail += ` = ${JSON.stringify(a.text)}`;
|
|
102
|
+
else if (a.name === "assertValue" && a.value !== void 0)
|
|
103
|
+
detail += ` = ${JSON.stringify(a.value)}`;
|
|
104
|
+
else if (a.name === "press" && a.key !== void 0)
|
|
105
|
+
detail += ` ${a.key}`;
|
|
106
|
+
return `#${index + 1} ${a.name}${onPage}${detail ? ` ${detail}` : ""}`;
|
|
107
|
+
}
|
|
108
|
+
function listStepsFrom(allActions, fromIndex) {
|
|
109
|
+
if (fromIndex >= allActions.length)
|
|
110
|
+
return "(none remaining)";
|
|
111
|
+
return allActions.slice(fromIndex).map((a, i) => describeStep(a, fromIndex + i)).join("\n");
|
|
112
|
+
}
|
|
113
|
+
const ENTER_FRAME = " >> internal:control=enter-frame >> ";
|
|
114
|
+
function buildFullSelector(framePath, selector) {
|
|
115
|
+
return [...framePath ?? [], selector].join(ENTER_FRAME);
|
|
116
|
+
}
|
|
117
|
+
function describeStopReason(result) {
|
|
118
|
+
switch (result.stopReason) {
|
|
119
|
+
case "done":
|
|
120
|
+
return "Reached the end of the trace.";
|
|
121
|
+
case "stopAtStep":
|
|
122
|
+
return `Stopped at the requested step (${result.completedCount}).`;
|
|
123
|
+
case "stopAtUrl":
|
|
124
|
+
return "Stopped because the active page URL matched the requested pattern.";
|
|
125
|
+
case "stopBefore":
|
|
126
|
+
return "Stopped just before the requested action.";
|
|
127
|
+
case "error":
|
|
128
|
+
return "Stopped because an action failed.";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function replayActions(actionsList, callbacks, params) {
|
|
132
|
+
const delay = params.speed === "slow" ? 1e3 : 0;
|
|
133
|
+
const stopBefore = params.stopBefore?.toLowerCase();
|
|
134
|
+
let completedCount = 0;
|
|
135
|
+
for (let i = 0; i < actionsList.length; i++) {
|
|
136
|
+
const action = actionsList[i];
|
|
137
|
+
if (stopBefore && describeStep(action, i).toLowerCase().includes(stopBefore))
|
|
138
|
+
return { completedCount, stopIndex: i, stopReason: "stopBefore" };
|
|
139
|
+
const alias = action.frame.pageAlias || "page";
|
|
140
|
+
const page = callbacks.pageForAlias(alias);
|
|
141
|
+
if (!METADATA_ONLY_ACTIONS.has(action.action.name)) {
|
|
142
|
+
if (!page) {
|
|
143
|
+
return {
|
|
144
|
+
completedCount,
|
|
145
|
+
stopIndex: i,
|
|
146
|
+
stopReason: "error",
|
|
147
|
+
error: { message: `No live page for alias "${alias}"`, actionName: action.action.name, stepIndex: i + 1 }
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
await performClientAction(page, action);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return {
|
|
154
|
+
completedCount,
|
|
155
|
+
stopIndex: i,
|
|
156
|
+
stopReason: "error",
|
|
157
|
+
error: { message: e.message, actionName: action.action.name, stepIndex: i + 1 }
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
completedCount = i + 1;
|
|
162
|
+
if (params.stopAtStep !== void 0 && completedCount >= params.stopAtStep)
|
|
163
|
+
return { completedCount, stopIndex: i + 1, stopReason: "stopAtStep" };
|
|
164
|
+
if (params.stopAtUrl) {
|
|
165
|
+
const currentUrl = page?.url() ?? "";
|
|
166
|
+
if (urlMatchesPattern(currentUrl, params.stopAtUrl))
|
|
167
|
+
return { completedCount, stopIndex: i + 1, stopReason: "stopAtUrl" };
|
|
168
|
+
}
|
|
169
|
+
if (delay)
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
171
|
+
}
|
|
172
|
+
return { completedCount, stopIndex: actionsList.length, stopReason: "done" };
|
|
173
|
+
}
|
|
174
|
+
function urlMatchesPattern(currentUrl, pattern) {
|
|
175
|
+
const regexLiteral = /^\/(.*)\/([a-z]*)$/i.exec(pattern);
|
|
176
|
+
if (regexLiteral) {
|
|
177
|
+
try {
|
|
178
|
+
return new RegExp(regexLiteral[1], regexLiteral[2]).test(currentUrl);
|
|
179
|
+
} catch {
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (/[*{]/.test(pattern))
|
|
183
|
+
return (0, import_utils.urlMatches)(void 0, currentUrl, pattern);
|
|
184
|
+
return currentUrl.toLowerCase().includes(pattern.toLowerCase());
|
|
185
|
+
}
|
|
186
|
+
const DEFAULT_ACTION_TIMEOUT = 15e3;
|
|
187
|
+
async function performClientAction(page, actionInContext) {
|
|
188
|
+
const action = actionInContext.action;
|
|
189
|
+
const opts = { timeout: DEFAULT_ACTION_TIMEOUT };
|
|
190
|
+
if (action.name === "navigate") {
|
|
191
|
+
await page.goto(action.url, { ...opts, waitUntil: "domcontentloaded" });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (action.name === "waitForTimeout") {
|
|
195
|
+
await new Promise((resolve) => setTimeout(resolve, action.duration ?? 0));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (action.name === "waitForSelector") {
|
|
199
|
+
const state = action.state === "hidden" ? "hidden" : "visible";
|
|
200
|
+
const waitSelector = buildFullSelector(actionInContext.frame.framePath, action.selector);
|
|
201
|
+
await page.locator(waitSelector).first().waitFor({ state, ...opts }).catch(() => {
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (action.name === "dragAndDrop" || action.name === "dragTo") {
|
|
206
|
+
const fp = actionInContext.frame.framePath;
|
|
207
|
+
const sourceSel = buildFullSelector(fp, action.source ?? action.selector);
|
|
208
|
+
const targetSel = buildFullSelector(fp, action.target ?? action.targetSelector);
|
|
209
|
+
await page.locator(sourceSel).dragTo(page.locator(targetSel), opts);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (action.name === "press" && !action.selector) {
|
|
213
|
+
const modifiers = decodeModifiers(action.modifiers);
|
|
214
|
+
const shortcut = [...modifiers, action.key].join("+");
|
|
215
|
+
await page.keyboard.press(shortcut);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!action.selector)
|
|
219
|
+
return;
|
|
220
|
+
const selector = buildFullSelector(actionInContext.frame.framePath, action.selector);
|
|
221
|
+
const locator = page.locator(selector);
|
|
222
|
+
switch (action.name) {
|
|
223
|
+
case "click": {
|
|
224
|
+
const clickOpts = { ...opts };
|
|
225
|
+
if (action.button && action.button !== "left")
|
|
226
|
+
clickOpts.button = action.button;
|
|
227
|
+
if (action.clickCount && action.clickCount > 1)
|
|
228
|
+
clickOpts.clickCount = action.clickCount;
|
|
229
|
+
if (action.position)
|
|
230
|
+
clickOpts.position = action.position;
|
|
231
|
+
const modifiers = decodeModifiers(action.modifiers);
|
|
232
|
+
if (modifiers.length)
|
|
233
|
+
clickOpts.modifiers = modifiers;
|
|
234
|
+
await locator.click(clickOpts);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
case "hover":
|
|
238
|
+
await locator.hover({ ...opts, ...action.position ? { position: action.position } : {} });
|
|
239
|
+
return;
|
|
240
|
+
case "fill":
|
|
241
|
+
await locator.fill(action.text ?? "", opts);
|
|
242
|
+
return;
|
|
243
|
+
case "pressSequentially":
|
|
244
|
+
await locator.pressSequentially(action.text ?? "", opts);
|
|
245
|
+
return;
|
|
246
|
+
case "press": {
|
|
247
|
+
const modifiers = decodeModifiers(action.modifiers);
|
|
248
|
+
const shortcut = [...modifiers, action.key].join("+");
|
|
249
|
+
await locator.press(shortcut, opts);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
case "check":
|
|
253
|
+
await locator.check(opts);
|
|
254
|
+
return;
|
|
255
|
+
case "uncheck":
|
|
256
|
+
await locator.uncheck(opts);
|
|
257
|
+
return;
|
|
258
|
+
case "select":
|
|
259
|
+
await locator.selectOption(action.options ?? [], opts);
|
|
260
|
+
return;
|
|
261
|
+
case "setInputFiles":
|
|
262
|
+
await locator.setInputFiles(action.files ?? [], opts);
|
|
263
|
+
return;
|
|
264
|
+
case "fileChooser": {
|
|
265
|
+
const fileChooserPromise = page.waitForEvent("filechooser");
|
|
266
|
+
await locator.click(opts);
|
|
267
|
+
const fileChooser = await fileChooserPromise;
|
|
268
|
+
await fileChooser.setFiles(action.files ?? []);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// Assertions: re-run against the live page so a drifted trace fails loudly
|
|
272
|
+
// (faithful to the manual recorder's replay).
|
|
273
|
+
case "assertText": {
|
|
274
|
+
const actual = await locator.textContent(opts) ?? "";
|
|
275
|
+
const substring = action.substring !== false;
|
|
276
|
+
const ok = substring ? actual.includes(action.text) : actual.trim() === String(action.text).trim();
|
|
277
|
+
if (!ok)
|
|
278
|
+
throw new Error(`assertText failed: expected text to ${substring ? "contain" : "equal"} ${JSON.stringify(action.text)}, got ${JSON.stringify(actual)}`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
case "assertValue": {
|
|
282
|
+
const actual = await locator.inputValue(opts);
|
|
283
|
+
if (actual !== action.value)
|
|
284
|
+
throw new Error(`assertValue failed: expected ${JSON.stringify(action.value)}, got ${JSON.stringify(actual)}`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
case "assertChecked": {
|
|
288
|
+
const checked = await locator.isChecked(opts);
|
|
289
|
+
if (checked !== !!action.checked)
|
|
290
|
+
throw new Error(`assertChecked failed: expected checked=${!!action.checked}, got ${checked}`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
case "assertVisible": {
|
|
294
|
+
const visible = await locator.isVisible(opts);
|
|
295
|
+
if (!visible)
|
|
296
|
+
throw new Error(`assertVisible failed: element is not visible`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
default:
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
304
|
+
0 && (module.exports = {
|
|
305
|
+
decodeModifiers,
|
|
306
|
+
describeStep,
|
|
307
|
+
describeStopReason,
|
|
308
|
+
listStepsFrom,
|
|
309
|
+
loadTraceMcpTool,
|
|
310
|
+
loadTraceSchema,
|
|
311
|
+
replayActions,
|
|
312
|
+
urlMatchesPattern
|
|
313
|
+
});
|