@skyramp/mcp 0.2.8 → 0.2.10-rc.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 (88) hide show
  1. package/build/index.js +1 -8
  2. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +28 -1
  3. package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +1 -0
  4. package/build/prompts/personas.d.ts +2 -2
  5. package/build/prompts/personas.js +2 -2
  6. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
  7. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +6 -1
  8. package/build/prompts/sut-setup/shared.d.ts +20 -0
  9. package/build/prompts/sut-setup/shared.js +67 -7
  10. package/build/prompts/test-maintenance/actionsInstructions.js +5 -1
  11. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +15 -2
  12. package/build/prompts/test-maintenance/drift-analysis-prompt.js +63 -3
  13. package/build/prompts/test-maintenance/driftAnalysisSections.js +7 -25
  14. package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +20 -0
  15. package/build/prompts/test-maintenance/driftAnalysisShared.js +80 -0
  16. package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +32 -0
  17. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +212 -0
  18. package/build/prompts/test-recommendation/recommendationShared.js +1 -1
  19. package/build/prompts/test-recommendation/scopeAssessment.js +12 -2
  20. package/build/prompts/testbot/testbot-prompts.js +10 -9
  21. package/build/resources/testbotResource.js +1 -1
  22. package/build/services/TestDiscoveryService.d.ts +12 -10
  23. package/build/services/TestDiscoveryService.js +137 -83
  24. package/build/services/TestDiscoveryService.test.js +370 -15
  25. package/build/services/TestExecutionService.d.ts +1 -1
  26. package/build/services/TestExecutionService.js +7 -7
  27. package/build/services/TestExecutionService.test.js +4 -1
  28. package/build/tools/executeSkyrampTestTool.js +40 -71
  29. package/build/tools/submitReportTool.d.ts +25 -25
  30. package/build/tools/submitReportTool.js +123 -34
  31. package/build/tools/submitReportTool.test.js +413 -94
  32. package/build/tools/test-management/actionsTool.js +177 -77
  33. package/build/tools/test-management/actionsTool.test.d.ts +1 -0
  34. package/build/tools/test-management/actionsTool.test.js +297 -0
  35. package/build/tools/test-management/analyzeChangesTool.d.ts +9 -0
  36. package/build/tools/test-management/analyzeChangesTool.js +74 -169
  37. package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
  38. package/build/tools/test-management/analyzeTestHealthTool.js +63 -22
  39. package/build/tools/test-management/analyzeTestHealthTool.test.d.ts +1 -0
  40. package/build/tools/test-management/analyzeTestHealthTool.test.js +268 -0
  41. package/build/tools/workspace/initializeWorkspaceTool.js +37 -24
  42. package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
  43. package/build/types/FrontendIntegration.d.ts +25 -0
  44. package/build/types/FrontendIntegration.js +19 -0
  45. package/build/types/RepositoryAnalysis.d.ts +8 -8
  46. package/build/types/TestAnalysis.d.ts +18 -49
  47. package/build/types/TestAnalysis.js +0 -28
  48. package/build/types/TestTypes.d.ts +3 -1
  49. package/build/types/TestTypes.js +3 -1
  50. package/build/types/TestbotReport.d.ts +1 -1
  51. package/build/utils/AnalysisStateManager.d.ts +16 -22
  52. package/build/utils/docker.test.js +1 -1
  53. package/build/utils/featureFlags.d.ts +1 -1
  54. package/build/utils/featureFlags.js +1 -1
  55. package/build/utils/frontendIntegration.d.ts +9 -0
  56. package/build/utils/frontendIntegration.js +237 -0
  57. package/build/utils/frontendIntegration.test.d.ts +1 -0
  58. package/build/utils/frontendIntegration.test.js +229 -0
  59. package/build/utils/pr-comment-parser.d.ts +3 -3
  60. package/build/utils/pr-comment-parser.js +6 -6
  61. package/build/utils/pr-comment-parser.test.js +3 -3
  62. package/build/utils/repoScanner.d.ts +7 -0
  63. package/build/utils/repoScanner.js +14 -6
  64. package/build/utils/repoScanner.test.js +9 -9
  65. package/build/utils/routeParsers.d.ts +32 -0
  66. package/build/utils/routeParsers.js +205 -2
  67. package/build/utils/routeParsers.test.js +279 -29
  68. package/build/utils/sourceRouteExtractor.js +174 -5
  69. package/build/utils/sourceRouteExtractor.test.js +173 -0
  70. package/build/utils/utils.d.ts +11 -0
  71. package/build/utils/utils.js +19 -0
  72. package/build/utils/utils.test.js +23 -1
  73. package/build/utils/versions.d.ts +3 -3
  74. package/build/utils/versions.js +1 -1
  75. package/build/workspace/workspace.d.ts +21 -21
  76. package/build/workspace/workspace.js +7 -4
  77. package/build/workspace/workspace.test.js +65 -6
  78. package/node_modules/playwright/lib/mcp/browser/tools/files.js +8 -3
  79. package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
  80. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -1
  81. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +33 -9
  82. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +7 -1
  83. package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +44 -0
  84. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.B_7ywgmr.js → index.d4gkVSou.js} +1 -1
  85. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  86. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  87. package/node_modules/playwright/package.json +1 -1
  88. package/package.json +3 -3
@@ -562,4 +562,177 @@ export default function App() {
562
562
  expect(routes[0].componentFile).toMatch(/src\/pages\/Cart\/index\.tsx$/);
563
563
  cleanup(repo);
564
564
  });
565
+ // ── Nested React Router routes ────────────────────────────────────────────
566
+ it("composes nested JSX <Route> child paths with their parent", () => {
567
+ // React Router v6 child paths are relative to the parent route. Without
568
+ // composition the extractor emits bare segments ("settings") that aren't
569
+ // navigable URLs.
570
+ const repo = makeTmpRepo([
571
+ {
572
+ path: "src/App.tsx",
573
+ content: `
574
+ import { Routes, Route } from 'react-router-dom';
575
+ import Dashboard from './pages/Dashboard';
576
+ import Settings from './pages/Settings';
577
+ import Users from './pages/Users';
578
+
579
+ export default function App() {
580
+ return (
581
+ <Routes>
582
+ <Route path="/dashboard" element={<Dashboard />}>
583
+ <Route path="settings" element={<Settings />} />
584
+ <Route path="users" element={<Users />} />
585
+ </Route>
586
+ </Routes>
587
+ );
588
+ }
589
+ `,
590
+ },
591
+ ]);
592
+ const routes = extractSourceRoutes(repo);
593
+ expect(routes.map((r) => r.path).sort()).toEqual([
594
+ "/dashboard",
595
+ "/dashboard/settings",
596
+ "/dashboard/users",
597
+ ]);
598
+ cleanup(repo);
599
+ });
600
+ it("composes relative child paths under a root layout route", () => {
601
+ // Canonical RR v6 shape: a path-bearing parent that itself has children,
602
+ // with relative child paths. The parent must not count its own path twice.
603
+ const repo = makeTmpRepo([
604
+ {
605
+ path: "src/App.tsx",
606
+ content: `
607
+ import { Routes, Route } from 'react-router-dom';
608
+ import Layout from './Layout';
609
+ import Dashboard from './pages/Dashboard';
610
+ import Settings from './pages/Settings';
611
+
612
+ export default function App() {
613
+ return (
614
+ <Routes>
615
+ <Route path="/" element={<Layout />}>
616
+ <Route path="dashboard" element={<Dashboard />}>
617
+ <Route path="settings" element={<Settings />} />
618
+ </Route>
619
+ </Route>
620
+ </Routes>
621
+ );
622
+ }
623
+ `,
624
+ },
625
+ ]);
626
+ const routes = extractSourceRoutes(repo);
627
+ expect(routes.find((r) => r.componentName === "Dashboard").path).toBe("/dashboard");
628
+ expect(routes.find((r) => r.componentName === "Settings").path).toBe("/dashboard/settings");
629
+ cleanup(repo);
630
+ });
631
+ it("resolves a JSX index route to its parent's path", () => {
632
+ // <Route index> has no path attribute; it renders at the parent's path.
633
+ const repo = makeTmpRepo([
634
+ {
635
+ path: "src/App.tsx",
636
+ content: `
637
+ import { Routes, Route } from 'react-router-dom';
638
+ import Dashboard from './pages/Dashboard';
639
+ import DashboardHome from './pages/DashboardHome';
640
+
641
+ export default function App() {
642
+ return (
643
+ <Routes>
644
+ <Route path="/dashboard" element={<Dashboard />}>
645
+ <Route index element={<DashboardHome />} />
646
+ </Route>
647
+ </Routes>
648
+ );
649
+ }
650
+ `,
651
+ },
652
+ ]);
653
+ const routes = extractSourceRoutes(repo);
654
+ const home = routes.find((r) => r.componentName === "DashboardHome");
655
+ expect(home).toBeDefined();
656
+ expect(home.path).toBe("/dashboard");
657
+ cleanup(repo);
658
+ });
659
+ it("extracts createBrowserRouter data-router routes with composed nested paths", () => {
660
+ // The data-router API uses object literals with `element:` (JSX) and nests
661
+ // via `children:` — neither the JSX-element path nor the Vue object path
662
+ // handled these, so the whole tree was missed (root-only fallback).
663
+ const repo = makeTmpRepo([
664
+ {
665
+ path: "src/router.tsx",
666
+ content: `
667
+ import { createBrowserRouter } from 'react-router-dom';
668
+ import Root from './Root';
669
+ import Dashboard from './pages/Dashboard';
670
+ import Settings from './pages/Settings';
671
+ import Profile from './pages/Profile';
672
+
673
+ export const router = createBrowserRouter([
674
+ {
675
+ path: '/',
676
+ element: <Root />,
677
+ children: [
678
+ { path: 'dashboard', element: <Dashboard /> },
679
+ {
680
+ path: 'settings',
681
+ element: <Settings />,
682
+ children: [
683
+ { path: 'profile', element: <Profile /> },
684
+ ],
685
+ },
686
+ ],
687
+ },
688
+ ]);
689
+ `,
690
+ },
691
+ ]);
692
+ const routes = extractSourceRoutes(repo);
693
+ expect(routes.map((r) => r.path).sort()).toEqual([
694
+ "/",
695
+ "/dashboard",
696
+ "/settings",
697
+ "/settings/profile",
698
+ ]);
699
+ expect(routes.find((r) => r.path === "/settings/profile").componentName).toBe("Profile");
700
+ cleanup(repo);
701
+ });
702
+ it("extracts createStaticRouter (SSR) routes with composed nested paths", () => {
703
+ // SSR apps build routes with createStaticRouter — same route-array shape as
704
+ // createBrowserRouter, so nested composition must work identically.
705
+ const repo = makeTmpRepo([
706
+ {
707
+ path: "src/router.tsx",
708
+ content: `
709
+ import { createStaticRouter } from 'react-router-dom/server';
710
+ import Root from './Root';
711
+ import Reports from './pages/Reports';
712
+ import Detail from './pages/Detail';
713
+
714
+ export const router = createStaticRouter([
715
+ {
716
+ path: '/',
717
+ element: <Root />,
718
+ children: [
719
+ { path: 'reports', element: <Reports />,
720
+ children: [
721
+ { path: ':id', element: <Detail /> },
722
+ ],
723
+ },
724
+ ],
725
+ },
726
+ ]);
727
+ `,
728
+ },
729
+ ]);
730
+ const routes = extractSourceRoutes(repo);
731
+ expect(routes.map((r) => r.path).sort()).toEqual([
732
+ "/",
733
+ "/reports",
734
+ "/reports/:id",
735
+ ]);
736
+ cleanup(repo);
737
+ });
565
738
  });
@@ -1,6 +1,17 @@
1
1
  import { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
2
2
  export declare function readDiffFile(diffFilePath: string | undefined): string | undefined;
3
3
  export declare function toolError(message: string): CallToolResult;
4
+ /**
5
+ * Does `candidate` (an LLM- or caller-supplied name/path) identify `fullPath` (a known,
6
+ * absolute test file path)? Exact match first, then a real path-segment boundary match
7
+ * for a relative candidate (e.g. "tests/foo.py" against ".../repo/tests/foo.py") — never
8
+ * a bare substring/suffix check. Only falls back to basename equality when `candidate`
9
+ * carries no path info at all, since that's the one case where the caller genuinely gave
10
+ * us nothing more specific to disambiguate with (bare filenames can still collide across
11
+ * different directories that happen to share one — this is the caller's ambiguity, not
12
+ * something a smarter match here can resolve).
13
+ */
14
+ export declare function testFileMatches(fullPath: string, candidate: string): boolean;
4
15
  /**
5
16
  * Happy-path counterpart to `toolError`. Wraps a single text payload in the
6
17
  * MCP tool-result shape so handlers don't have to construct
@@ -18,6 +18,25 @@ export function toolError(message) {
18
18
  isError: true,
19
19
  };
20
20
  }
21
+ /**
22
+ * Does `candidate` (an LLM- or caller-supplied name/path) identify `fullPath` (a known,
23
+ * absolute test file path)? Exact match first, then a real path-segment boundary match
24
+ * for a relative candidate (e.g. "tests/foo.py" against ".../repo/tests/foo.py") — never
25
+ * a bare substring/suffix check. Only falls back to basename equality when `candidate`
26
+ * carries no path info at all, since that's the one case where the caller genuinely gave
27
+ * us nothing more specific to disambiguate with (bare filenames can still collide across
28
+ * different directories that happen to share one — this is the caller's ambiguity, not
29
+ * something a smarter match here can resolve).
30
+ */
31
+ export function testFileMatches(fullPath, candidate) {
32
+ if (fullPath === candidate)
33
+ return true;
34
+ if (path.isAbsolute(candidate))
35
+ return false;
36
+ if (candidate.includes("/"))
37
+ return fullPath.endsWith("/" + candidate);
38
+ return path.basename(fullPath) === candidate;
39
+ }
21
40
  /**
22
41
  * Happy-path counterpart to `toolError`. Wraps a single text payload in the
23
42
  * MCP tool-result shape so handlers don't have to construct
@@ -1,5 +1,27 @@
1
1
  // @ts-ignore
2
- import { validateParams, generateSkyrampHeader } from "./utils.js";
2
+ import { validateParams, generateSkyrampHeader, testFileMatches } from "./utils.js";
3
+ describe("testFileMatches", () => {
4
+ it("matches identical absolute paths", () => {
5
+ expect(testFileMatches("/repo/tests/orders_test.py", "/repo/tests/orders_test.py")).toBe(true);
6
+ });
7
+ it("does not match two different absolute paths, even with the same basename", () => {
8
+ expect(testFileMatches("/repo/frontend/tests/utils_test.py", "/repo/backend/tests/utils_test.py")).toBe(false);
9
+ });
10
+ it("matches a relative candidate against a real path-segment boundary", () => {
11
+ expect(testFileMatches("/repo/backend/tests/orders_test.py", "tests/orders_test.py")).toBe(true);
12
+ });
13
+ it("does not match a relative candidate that is only a substring, not a path-segment boundary", () => {
14
+ // "ers_test.py" is a suffix of the basename but not preceded by a path separator.
15
+ expect(testFileMatches("/repo/backend/tests/orders_test.py", "ers_test.py")).toBe(false);
16
+ });
17
+ it("falls back to basename equality only when the candidate has no path info at all", () => {
18
+ expect(testFileMatches("/repo/backend/tests/orders_test.py", "orders_test.py")).toBe(true);
19
+ });
20
+ it("bare-basename fallback is ambiguous across two files that share a basename (documented limitation)", () => {
21
+ expect(testFileMatches("/repo/frontend/tests/utils_test.py", "utils_test.py")).toBe(true);
22
+ expect(testFileMatches("/repo/backend/tests/utils_test.py", "utils_test.py")).toBe(true);
23
+ });
24
+ });
3
25
  describe("validateParams", () => {
4
26
  it("should return null for valid comma-separated key=value pairs", () => {
5
27
  const result = validateParams("foo=bar,baz=qux", "testField");
@@ -1,3 +1,3 @@
1
- export declare const SKYRAMP_IMAGE_VERSION = "v1.3.29";
2
- export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.29";
3
- export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.29";
1
+ export declare const SKYRAMP_IMAGE_VERSION = "v1.3.32";
2
+ export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.32";
3
+ export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.32";
@@ -1,3 +1,3 @@
1
- export const SKYRAMP_IMAGE_VERSION = "v1.3.29";
1
+ export const SKYRAMP_IMAGE_VERSION = "v1.3.32";
2
2
  export const EXECUTOR_DOCKER_IMAGE = `skyramp/executor:${SKYRAMP_IMAGE_VERSION}`;
3
3
  export const WORKER_DOCKER_IMAGE = `skyramp/worker:${SKYRAMP_IMAGE_VERSION}`;
@@ -99,22 +99,22 @@ export declare const workspaceConfigSchema: z.ZodObject<{
99
99
  }>>;
100
100
  metadata: z.ZodOptional<z.ZodObject<{
101
101
  schemaVersion: z.ZodString;
102
- mcpVersion: z.ZodString;
102
+ mcpVersion: z.ZodOptional<z.ZodString>;
103
103
  executorVersion: z.ZodString;
104
- createdAt: z.ZodString;
105
- updatedAt: z.ZodString;
104
+ createdAt: z.ZodOptional<z.ZodString>;
105
+ updatedAt: z.ZodOptional<z.ZodString>;
106
106
  }, "strict", z.ZodTypeAny, {
107
- createdAt: string;
108
107
  schemaVersion: string;
109
- mcpVersion: string;
110
108
  executorVersion: string;
111
- updatedAt: string;
109
+ createdAt?: string | undefined;
110
+ mcpVersion?: string | undefined;
111
+ updatedAt?: string | undefined;
112
112
  }, {
113
- createdAt: string;
114
113
  schemaVersion: string;
115
- mcpVersion: string;
116
114
  executorVersion: string;
117
- updatedAt: string;
115
+ createdAt?: string | undefined;
116
+ mcpVersion?: string | undefined;
117
+ updatedAt?: string | undefined;
118
118
  }>>;
119
119
  services: z.ZodOptional<z.ZodArray<z.ZodObject<{
120
120
  serviceName: z.ZodString;
@@ -203,11 +203,11 @@ export declare const workspaceConfigSchema: z.ZodObject<{
203
203
  }>, "many">>;
204
204
  }, "strict", z.ZodTypeAny, {
205
205
  metadata?: {
206
- createdAt: string;
207
206
  schemaVersion: string;
208
- mcpVersion: string;
209
207
  executorVersion: string;
210
- updatedAt: string;
208
+ createdAt?: string | undefined;
209
+ mcpVersion?: string | undefined;
210
+ updatedAt?: string | undefined;
211
211
  } | undefined;
212
212
  workspace?: {
213
213
  repoName?: string | undefined;
@@ -236,11 +236,11 @@ export declare const workspaceConfigSchema: z.ZodObject<{
236
236
  }[] | undefined;
237
237
  }, {
238
238
  metadata?: {
239
- createdAt: string;
240
239
  schemaVersion: string;
241
- mcpVersion: string;
242
240
  executorVersion: string;
243
- updatedAt: string;
241
+ createdAt?: string | undefined;
242
+ mcpVersion?: string | undefined;
243
+ updatedAt?: string | undefined;
244
244
  } | undefined;
245
245
  workspace?: {
246
246
  repoName?: string | undefined;
@@ -277,11 +277,11 @@ export type MetadataSection = NonNullable<WorkspaceConfig["metadata"]>;
277
277
  /** Validates a workspace configuration object against the Zod schema. */
278
278
  export declare function validateWorkspaceConfig(config: unknown): z.SafeParseReturnType<{
279
279
  metadata?: {
280
- createdAt: string;
281
280
  schemaVersion: string;
282
- mcpVersion: string;
283
281
  executorVersion: string;
284
- updatedAt: string;
282
+ createdAt?: string | undefined;
283
+ mcpVersion?: string | undefined;
284
+ updatedAt?: string | undefined;
285
285
  } | undefined;
286
286
  workspace?: {
287
287
  repoName?: string | undefined;
@@ -310,11 +310,11 @@ export declare function validateWorkspaceConfig(config: unknown): z.SafeParseRet
310
310
  }[] | undefined;
311
311
  }, {
312
312
  metadata?: {
313
- createdAt: string;
314
313
  schemaVersion: string;
315
- mcpVersion: string;
316
314
  executorVersion: string;
317
- updatedAt: string;
315
+ createdAt?: string | undefined;
316
+ mcpVersion?: string | undefined;
317
+ updatedAt?: string | undefined;
318
318
  } | undefined;
319
319
  workspace?: {
320
320
  repoName?: string | undefined;
@@ -78,10 +78,14 @@ export const workspaceConfigSchema = z
78
78
  metadata: z
79
79
  .object({
80
80
  schemaVersion: z.string(),
81
- mcpVersion: z.string(),
81
+ // mcpVersion is no longer written by skyramp_init_workspace (SKYR-3818), but
82
+ // remains an optional, recognized key: testbot reads it from pre-existing
83
+ // workspaces as a fallback MCP-version pin (testbot.git src/config.ts), and
84
+ // older files still carry it. Optional so configs without it validate.
85
+ mcpVersion: z.string().optional(),
82
86
  executorVersion: z.string(),
83
- createdAt: z.string(),
84
- updatedAt: z.string(),
87
+ createdAt: z.string().optional(),
88
+ updatedAt: z.string().optional(),
85
89
  })
86
90
  .strict()
87
91
  .optional(),
@@ -121,7 +125,6 @@ export function createDefaultConfig() {
121
125
  workspace: {},
122
126
  metadata: {
123
127
  schemaVersion: "v1",
124
- mcpVersion: "",
125
128
  executorVersion: "",
126
129
  createdAt: now,
127
130
  updatedAt: now,
@@ -7,9 +7,11 @@ import { serviceSchema, WorkspaceConfigManager, validateWorkspaceConfig, createD
7
7
  describe("workspace module (ported from @skyramp/skyramp, SKYR-3794)", () => {
8
8
  describe("serviceSchema", () => {
9
9
  it("accepts an optional repository and rejects a non-string repository", () => {
10
- expect(serviceSchema.safeParse({ serviceName: "api", repository: "org/api" }).success).toBe(true);
10
+ expect(serviceSchema.safeParse({ serviceName: "api", repository: "org/api" })
11
+ .success).toBe(true);
11
12
  expect(serviceSchema.safeParse({ serviceName: "api" }).success).toBe(true);
12
- expect(serviceSchema.safeParse({ serviceName: "api", repository: 123 }).success).toBe(false);
13
+ expect(serviceSchema.safeParse({ serviceName: "api", repository: 123 })
14
+ .success).toBe(false);
13
15
  });
14
16
  it("rejects unknown fields (strict)", () => {
15
17
  expect(serviceSchema.safeParse({ serviceName: "api", bogus: true }).success).toBe(false);
@@ -41,15 +43,72 @@ describe("workspace module (ported from @skyramp/skyramp, SKYR-3794)", () => {
41
43
  it("keeps a same-named service from a different repo distinct, and upserts in place", async () => {
42
44
  const mgr = new WorkspaceConfigManager(repoDir);
43
45
  await mgr.initialize();
44
- await mgr.addService({ serviceName: "svc", testDirectory: "tests/primary" });
45
- await mgr.addService({ serviceName: "svc", repository: "letsramp/api-insight", testDirectory: "tests/py" });
46
- await mgr.addService({ serviceName: "svc", testDirectory: "tests/primary-v2" }); // upsert primary
46
+ await mgr.addService({
47
+ serviceName: "svc",
48
+ testDirectory: "tests/primary",
49
+ });
50
+ await mgr.addService({
51
+ serviceName: "svc",
52
+ repository: "letsramp/api-insight",
53
+ testDirectory: "tests/py",
54
+ });
55
+ await mgr.addService({
56
+ serviceName: "svc",
57
+ testDirectory: "tests/primary-v2",
58
+ }); // upsert primary
47
59
  const cfg = read();
48
60
  expect(cfg.services).toHaveLength(2);
49
61
  expect(cfg.services.find((s) => !s.repository).testDirectory).toBe("tests/primary-v2");
50
- expect(cfg.services.find((s) => s.repository === "letsramp/api-insight").testDirectory).toBe("tests/py");
62
+ expect(cfg.services.find((s) => s.repository === "letsramp/api-insight")
63
+ .testDirectory).toBe("tests/py");
51
64
  // repoName auto-detected from the git remote.
52
65
  expect(cfg.workspace.repoName).toBe("letsramp/primary");
53
66
  });
54
67
  });
68
+ describe("mcpVersion: no longer written, still accepted (SKYR-3818)", () => {
69
+ let repoDir;
70
+ beforeEach(() => {
71
+ repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "ws-mcpver-"));
72
+ execFileSync("git", ["init", "-q"], { cwd: repoDir });
73
+ execFileSync("git", ["remote", "add", "origin", "https://github.com/letsramp/primary.git"], { cwd: repoDir });
74
+ });
75
+ afterEach(() => {
76
+ fs.rmSync(repoDir, { recursive: true, force: true });
77
+ });
78
+ // Writes a workspace.yml that carries metadata.mcpVersion, emulating a workspace
79
+ // initialized by an older MCP version (which still stamped the field).
80
+ const writeLegacyConfig = () => {
81
+ const dir = path.join(repoDir, WORKSPACE_DIR);
82
+ fs.mkdirSync(dir, { recursive: true });
83
+ const now = new Date().toISOString();
84
+ const legacy = {
85
+ workspace: { repoName: "letsramp/primary" },
86
+ metadata: {
87
+ schemaVersion: "v1",
88
+ mcpVersion: "0.0.64",
89
+ executorVersion: "1.2.3",
90
+ createdAt: now,
91
+ updatedAt: now,
92
+ },
93
+ services: [],
94
+ };
95
+ fs.writeFileSync(path.join(dir, WORKSPACE_FILENAME), yaml.dump(legacy), "utf8");
96
+ };
97
+ const readRaw = () => yaml.load(fs.readFileSync(path.join(repoDir, WORKSPACE_DIR, WORKSPACE_FILENAME), "utf8"), { schema: yaml.JSON_SCHEMA });
98
+ it("does not write mcpVersion into a freshly initialized workspace", async () => {
99
+ const mgr = new WorkspaceConfigManager(repoDir);
100
+ await mgr.initialize();
101
+ const onDisk = readRaw();
102
+ expect(onDisk.metadata.mcpVersion).toBeUndefined();
103
+ });
104
+ it("preserves mcpVersion from a pre-existing workspace.yml on read (testbot fallback)", async () => {
105
+ writeLegacyConfig();
106
+ const mgr = new WorkspaceConfigManager(repoDir);
107
+ const cfg = await mgr.read();
108
+ // Kept as an optional recognized key — not stripped — so testbot's
109
+ // config.ts MCP-version fallback keeps working for existing workspaces.
110
+ expect(cfg.metadata?.mcpVersion).toBe("0.0.64");
111
+ expect(cfg.metadata?.executorVersion).toBe("1.2.3");
112
+ });
113
+ });
55
114
  });
@@ -61,9 +61,14 @@ const uploadFile = (0, import_tool.defineTool)({
61
61
  const files = params.paths ?? [];
62
62
  response.addCode(`await fileChooser.setFiles(${JSON.stringify(files)})`);
63
63
  tab.clearModalState(fileChooserModal);
64
- await tab.waitForCompletion(async () => {
65
- await fileChooserModal.fileChooser.setFiles(files);
66
- });
64
+ try {
65
+ await tab.waitForCompletion(async () => {
66
+ await fileChooserModal.fileChooser.setFiles(files);
67
+ });
68
+ } catch (e) {
69
+ tab.setModalState(fileChooserModal);
70
+ throw e;
71
+ }
67
72
  }
68
73
  });
69
74
  var files_default = [
@@ -30,7 +30,7 @@ const wait = (0, import_tool.defineTool)({
30
30
  title: "Wait for",
31
31
  description: 'Wait for text to appear, disappear, or a time to pass. REQUIRED after navigating to a folder where content was just created or renamed \u2014 file/item names update asynchronously and will not be present immediately. Always call with text: "<filename>" before attempting to hover or click a newly created item.',
32
32
  inputSchema: import_mcpBundle.z.object({
33
- time: import_mcpBundle.z.number().optional().describe("The time to wait in seconds"),
33
+ time: import_mcpBundle.z.number().positive().max(30).optional().describe("The time to wait, in SECONDS (not milliseconds), max 30. e.g. 3 = three seconds. Prefer text/textGone waits over a fixed time."),
34
34
  text: import_mcpBundle.z.string().optional().describe("The text to wait for"),
35
35
  textGone: import_mcpBundle.z.string().optional().describe("The text to wait for to disappear")
36
36
  }),
@@ -54,6 +54,12 @@ const exportZipSchema = {
54
54
  }),
55
55
  type: "readOnly"
56
56
  };
57
+ function summarizeDropped(dropped) {
58
+ const counts = /* @__PURE__ */ new Map();
59
+ for (const d of dropped)
60
+ counts.set(d.toolName, (counts.get(d.toolName) ?? 0) + 1);
61
+ return [...counts.entries()].map(([tool, n]) => n > 1 ? `${tool} \xD7${n}` : tool).join(", ");
62
+ }
57
63
  function createExportZipHandler(ctx) {
58
64
  return async (params) => {
59
65
  if (!ctx.trackedActions.length) {
@@ -86,6 +92,9 @@ Requested path: ${outputZip}` }],
86
92
  await (0, import_skyRampExport.writeSkyrampZip)(outputZip, jsonlContent, ctx.harPath);
87
93
  const skippedNote = skipped.length ? `
88
94
  Skipped (not exportable): ${[...new Set(skipped)].join(", ")}` : "";
95
+ const dropped = ctx.droppedActions ?? [];
96
+ const droppedNote = dropped.length ? `
97
+ Failed & dropped (not recorded): ${summarizeDropped(dropped)}` : "";
89
98
  return {
90
99
  content: [{
91
100
  type: "text",
@@ -93,7 +102,7 @@ Skipped (not exportable): ${[...new Set(skipped)].join(", ")}` : "";
93
102
  Skyramp zip written to ${outputZip}
94
103
 
95
104
  Contains:
96
- - skyramp_playwright.txt (${actionCount} actions)${skippedNote}`
105
+ - skyramp_playwright.txt (${actionCount} actions)${skippedNote}${droppedNote}`
97
106
  }]
98
107
  };
99
108
  };
@@ -74,6 +74,7 @@ class TraceRecordingBackend {
74
74
  // per-type baseline filename counter
75
75
  constructor(options) {
76
76
  this._trackedActions = [];
77
+ this._droppedActions = [];
77
78
  this._initialized = false;
78
79
  this._pageCount = 0;
79
80
  // number of popup pages seen
@@ -92,6 +93,9 @@ class TraceRecordingBackend {
92
93
  get trackedActions() {
93
94
  return this._trackedActions;
94
95
  }
96
+ get droppedActions() {
97
+ return this._droppedActions;
98
+ }
95
99
  async initialize(clientInfo) {
96
100
  const userDataDir = this._options.userDataDir || process.env.PLAYWRIGHT_USER_DATA_DIR || void 0;
97
101
  const loadExtension = this._options.loadExtension ?? splitExtensionPaths(process.env.PLAYWRIGHT_LOAD_EXTENSION);
@@ -185,12 +189,14 @@ class TraceRecordingBackend {
185
189
  this._consumeRecorderSelectors();
186
190
  const handler = (0, import_exportTool.createExportZipHandler)({
187
191
  trackedActions: this._trackedActions,
192
+ droppedActions: this._droppedActions,
188
193
  harPath: this._harPath,
189
194
  outputDir: this._outputDir
190
195
  });
191
196
  const exportResult = await handler(parsed);
192
197
  if (!exportResult.isError) {
193
198
  this._trackedActions = [];
199
+ this._droppedActions = [];
194
200
  const ra = this._browserBackend.context?.recorderActions;
195
201
  if (ra)
196
202
  ra.length = 0;
@@ -1691,8 +1697,13 @@ Cell row ${params.row}, column ${params.column} has "${actual}", expected "${par
1691
1697
  };
1692
1698
  }
1693
1699
  _maybeTrackAction(toolName, args, result, timestamp, pageAliasBeforeAction) {
1694
- if (result.isError)
1700
+ if (result.isError) {
1701
+ const errParsed = (0, import_response.parseResponse)(result);
1702
+ const errText = (errParsed?.error ?? errParsed?.text ?? "unknown error").replace(/\s+/g, " ").trim().slice(0, 300);
1703
+ this._droppedActions.push({ toolName, error: errText });
1704
+ traceDebug(`Dropped failed action: ${toolName} args=${JSON.stringify(args).slice(0, 200)} \u2014 ${errText} (dropped total: ${this._droppedActions.length})`);
1695
1705
  return;
1706
+ }
1696
1707
  const parsed = (0, import_response.parseResponse)(result);
1697
1708
  const code = parsed?.code ?? "";
1698
1709
  if (code || import_types.ARGS_ONLY_TOOLS.has(toolName)) {
@@ -1771,15 +1782,28 @@ Cell row ${params.row}, column ${params.column} has "${actual}", expected "${par
1771
1782
  else
1772
1783
  traceDebug("Flutter placeholder clicked but semantics not confirmed active");
1773
1784
  }
1774
- /** Clean up temp directory and optionally recreate it for next session. */
1775
- _cleanupTempDir(recreate = false) {
1785
+ /**
1786
+ * Clean up temp files. When `reuse` is true (between recordings in the same
1787
+ * session), the temp directory and `_harPath` are kept STABLE and only their
1788
+ * contents are cleared; when false (session end), the directory is removed.
1789
+ *
1790
+ * SKYR-3830: `recordHar.path` is baked by value into the context factory's
1791
+ * config at initialize(). Reassigning `_harPath` to a fresh temp dir here
1792
+ * desynced it from that baked path, so every recording after the first wrote
1793
+ * its HAR to the original (now-deleted) path while export read the new
1794
+ * `_harPath` — yielding an empty HAR and zero `waitForResponse` anchors.
1795
+ * Keeping the path stable lets the next context's recordHar overwrite the
1796
+ * HAR in place on close, so subsequent exports capture network again.
1797
+ */
1798
+ _cleanupTempDir(reuse = false) {
1776
1799
  try {
1777
- import_fs.default.rmSync(this._tempDir, { recursive: true, force: true });
1778
- traceDebug(`Cleaned up temp directory: ${this._tempDir}`);
1779
- if (recreate) {
1780
- this._tempDir = import_fs.default.mkdtempSync(import_path.default.join(import_os.default.tmpdir(), "skyramp-trace-"));
1781
- this._harPath = import_path.default.join(this._tempDir, "skyramp_network.har");
1782
- traceDebug(`Created new temp directory: ${this._tempDir}`);
1800
+ if (reuse) {
1801
+ for (const entry of import_fs.default.readdirSync(this._tempDir))
1802
+ import_fs.default.rmSync(import_path.default.join(this._tempDir, entry), { recursive: true, force: true });
1803
+ traceDebug(`Cleared temp directory for reuse: ${this._tempDir}`);
1804
+ } else {
1805
+ import_fs.default.rmSync(this._tempDir, { recursive: true, force: true });
1806
+ traceDebug(`Cleaned up temp directory: ${this._tempDir}`);
1783
1807
  }
1784
1808
  } catch (e) {
1785
1809
  traceDebug(`Failed to clean up temp directory ${this._tempDir}: ${e}`);
@@ -251,7 +251,7 @@ function trackedActionToJsonl(action, pageGuid, timestamp) {
251
251
  if (textGone)
252
252
  return JSON.stringify({ name: "waitForSelector", selector: `text=${textGone}`, state: "hidden", ...base });
253
253
  if (typeof time === "number")
254
- return JSON.stringify({ name: "waitForTimeout", duration: time * 1e3, ...base });
254
+ return JSON.stringify({ name: "waitForTimeout", duration: Math.min(3e4, time * 1e3), ...base });
255
255
  return null;
256
256
  }
257
257
  if (toolName === "browser_press_key")
@@ -261,6 +261,12 @@ function trackedActionToJsonl(action, pageGuid, timestamp) {
261
261
  return null;
262
262
  return JSON.stringify({ ...args, ...base });
263
263
  }
264
+ if (toolName === "browser_evaluate") {
265
+ const fn = typeof args.function === "string" ? args.function : "";
266
+ if (!fn || args.ref && args.element)
267
+ return null;
268
+ return JSON.stringify({ name: "evaluate", expression: fn, ...base });
269
+ }
264
270
  if (!code)
265
271
  return null;
266
272
  const extracted = extractLocatorFromCode(code);