@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.
- package/build/index.js +1 -8
- package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +28 -1
- package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +1 -0
- package/build/prompts/personas.d.ts +2 -2
- package/build/prompts/personas.js +2 -2
- package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
- package/build/prompts/sut-setup/modes/dockerComposePrompt.js +6 -1
- package/build/prompts/sut-setup/shared.d.ts +20 -0
- package/build/prompts/sut-setup/shared.js +67 -7
- package/build/prompts/test-maintenance/actionsInstructions.js +5 -1
- package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +15 -2
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +63 -3
- package/build/prompts/test-maintenance/driftAnalysisSections.js +7 -25
- package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +20 -0
- package/build/prompts/test-maintenance/driftAnalysisShared.js +80 -0
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +32 -0
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +212 -0
- package/build/prompts/test-recommendation/recommendationShared.js +1 -1
- package/build/prompts/test-recommendation/scopeAssessment.js +12 -2
- package/build/prompts/testbot/testbot-prompts.js +10 -9
- package/build/resources/testbotResource.js +1 -1
- package/build/services/TestDiscoveryService.d.ts +12 -10
- package/build/services/TestDiscoveryService.js +137 -83
- package/build/services/TestDiscoveryService.test.js +370 -15
- package/build/services/TestExecutionService.d.ts +1 -1
- package/build/services/TestExecutionService.js +7 -7
- package/build/services/TestExecutionService.test.js +4 -1
- package/build/tools/executeSkyrampTestTool.js +40 -71
- package/build/tools/submitReportTool.d.ts +25 -25
- package/build/tools/submitReportTool.js +123 -34
- package/build/tools/submitReportTool.test.js +413 -94
- package/build/tools/test-management/actionsTool.js +177 -77
- package/build/tools/test-management/actionsTool.test.d.ts +1 -0
- package/build/tools/test-management/actionsTool.test.js +297 -0
- package/build/tools/test-management/analyzeChangesTool.d.ts +9 -0
- package/build/tools/test-management/analyzeChangesTool.js +74 -169
- package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
- package/build/tools/test-management/analyzeTestHealthTool.js +63 -22
- package/build/tools/test-management/analyzeTestHealthTool.test.d.ts +1 -0
- package/build/tools/test-management/analyzeTestHealthTool.test.js +268 -0
- package/build/tools/workspace/initializeWorkspaceTool.js +37 -24
- package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
- package/build/types/FrontendIntegration.d.ts +25 -0
- package/build/types/FrontendIntegration.js +19 -0
- package/build/types/RepositoryAnalysis.d.ts +8 -8
- package/build/types/TestAnalysis.d.ts +18 -49
- package/build/types/TestAnalysis.js +0 -28
- package/build/types/TestTypes.d.ts +3 -1
- package/build/types/TestTypes.js +3 -1
- package/build/types/TestbotReport.d.ts +1 -1
- package/build/utils/AnalysisStateManager.d.ts +16 -22
- package/build/utils/docker.test.js +1 -1
- package/build/utils/featureFlags.d.ts +1 -1
- package/build/utils/featureFlags.js +1 -1
- package/build/utils/frontendIntegration.d.ts +9 -0
- package/build/utils/frontendIntegration.js +237 -0
- package/build/utils/frontendIntegration.test.d.ts +1 -0
- package/build/utils/frontendIntegration.test.js +229 -0
- package/build/utils/pr-comment-parser.d.ts +3 -3
- package/build/utils/pr-comment-parser.js +6 -6
- package/build/utils/pr-comment-parser.test.js +3 -3
- package/build/utils/repoScanner.d.ts +7 -0
- package/build/utils/repoScanner.js +14 -6
- package/build/utils/repoScanner.test.js +9 -9
- package/build/utils/routeParsers.d.ts +32 -0
- package/build/utils/routeParsers.js +205 -2
- package/build/utils/routeParsers.test.js +279 -29
- package/build/utils/sourceRouteExtractor.js +174 -5
- package/build/utils/sourceRouteExtractor.test.js +173 -0
- package/build/utils/utils.d.ts +11 -0
- package/build/utils/utils.js +19 -0
- package/build/utils/utils.test.js +23 -1
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/build/workspace/workspace.d.ts +21 -21
- package/build/workspace/workspace.js +7 -4
- package/build/workspace/workspace.test.js +65 -6
- package/node_modules/playwright/lib/mcp/browser/tools/files.js +8 -3
- package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
- package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -1
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +33 -9
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +7 -1
- package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +44 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.B_7ywgmr.js → index.d4gkVSou.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/package.json +1 -1
- 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
|
});
|
package/build/utils/utils.d.ts
CHANGED
|
@@ -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
|
package/build/utils/utils.js
CHANGED
|
@@ -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.
|
|
2
|
-
export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.
|
|
3
|
-
export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.
|
|
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";
|
package/build/utils/versions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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" })
|
|
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 })
|
|
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({
|
|
45
|
-
|
|
46
|
-
|
|
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")
|
|
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
|
-
|
|
65
|
-
await
|
|
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
|
-
/**
|
|
1775
|
-
|
|
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
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
this._tempDir
|
|
1781
|
-
|
|
1782
|
-
|
|
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);
|