@skyramp/mcp 0.2.0-rc.1 → 0.2.0-rc.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/index.js +4 -2
- package/build/prompts/code-reuse.js +106 -7
- package/build/prompts/pom-aware-code-reuse.js +106 -7
- package/build/prompts/startTraceCollectionPrompts.js +37 -15
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +26 -31
- package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +40 -1
- package/build/prompts/test-maintenance/driftAnalysisSections.js +90 -86
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +286 -163
- package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +154 -45
- package/build/prompts/test-recommendation/diffExecutionPlan.js +215 -117
- package/build/prompts/test-recommendation/promptPlan.js +290 -0
- package/build/prompts/test-recommendation/promptPlan.test.js +336 -0
- package/build/prompts/test-recommendation/recommendationSections.js +3 -1
- package/build/prompts/test-recommendation/recommendationShared.js +23 -1
- package/build/prompts/test-recommendation/scopeAssessment.js +65 -14
- package/build/prompts/test-recommendation/scopeAssessment.test.js +93 -2
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +36 -12
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +222 -1
- package/build/prompts/testbot/testbot-prompts.js +18 -62
- package/build/prompts/testbot/testbot-prompts.test.js +65 -31
- package/build/services/ScenarioGenerationService.js +11 -1
- package/build/services/TestExecutionService.js +73 -15
- package/build/services/TestExecutionService.test.js +105 -0
- package/build/services/TestGenerationService.js +11 -1
- package/build/tools/executeSkyrampTestTool.js +1 -10
- package/build/tools/test-management/actionsTool.js +152 -63
- package/build/tools/test-management/analyzeChangesTool.js +171 -63
- package/build/tools/test-management/analyzeChangesTool.test.js +103 -16
- package/build/tools/test-management/analyzeTestHealthTool.js +30 -81
- package/build/tools/test-management/index.js +1 -0
- package/build/tools/test-management/uiAnalyzeChangesTool.js +149 -0
- package/build/tools/test-management/uiAnalyzeChangesTool.test.js +100 -0
- package/build/tools/trace/resolveSaveStoragePath.js +16 -0
- package/build/tools/trace/resolveSaveStoragePath.test.js +17 -0
- package/build/tools/trace/resolveSessionPaths.js +39 -0
- package/build/tools/trace/resolveSessionPaths.test.js +103 -0
- package/build/tools/trace/sessionState.js +14 -0
- package/build/tools/trace/sessionState.test.js +17 -0
- package/build/tools/trace/startTraceCollectionTool.js +84 -14
- package/build/tools/trace/stopTraceCollectionTool.js +9 -2
- package/build/types/TestAnalysis.js +50 -0
- package/build/types/TestRecommendation.js +6 -58
- package/build/types/TestTypes.js +1 -1
- package/build/utils/AnalysisStateManager.js +22 -11
- package/build/utils/branchDiff.js +11 -2
- package/build/utils/docker.test.js +1 -1
- package/build/utils/gitStaging.js +52 -3
- package/build/utils/gitStaging.test.js +19 -1
- package/build/utils/repoScanner.js +18 -10
- package/build/utils/repoScanner.test.js +92 -0
- package/build/utils/routeParsers.js +168 -25
- package/build/utils/routeParsers.test.js +180 -1
- package/build/utils/scenarioDrafting.js +220 -17
- package/build/utils/scenarioDrafting.test.js +182 -9
- package/build/utils/sourceRouteExtractor.js +806 -0
- package/build/utils/sourceRouteExtractor.test.js +565 -0
- package/build/utils/uiPageEnumerator.js +319 -0
- package/build/utils/uiPageEnumerator.test.js +422 -0
- package/build/utils/utils.js +27 -0
- package/build/utils/versions.js +1 -1
- package/build/utils/workspaceAuth.js +33 -4
- package/node_modules/playwright/lib/dom-analyzer/blueprint.js +54 -5
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +4 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +6 -0
- package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.js +150 -0
- package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.test.js +470 -0
- package/node_modules/playwright/lib/mcp/browser/tab.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +21 -4
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +3 -0
- package/node_modules/playwright/package.json +1 -1
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.4.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.5.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz +0 -0
- package/package.json +3 -3
- package/build/services/TestHealthService.js +0 -694
- package/build/services/TestHealthService.test.js +0 -241
- package/build/types/TestDriftAnalysis.js +0 -1
- package/build/types/TestHealth.js +0 -4
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// Mock @skyramp/skyramp so workspaceAuth's import resolves; readWorkspaceConfigRaw
|
|
2
|
+
// itself is mocked per-test below.
|
|
3
|
+
jest.mock("@skyramp/skyramp", () => ({
|
|
4
|
+
WorkspaceConfigManager: { create: jest.fn() },
|
|
5
|
+
}));
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as os from "os";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import { frameworkFileToUrlPath, findCandidatePagesByFrameworkRoute, findCandidatePagesBySourceRoute, findRootFallbackPage, enumerateCandidateUiPages, pickFrontendBaseUrl, detectsFilesystemRouting, } from "./uiPageEnumerator.js";
|
|
10
|
+
import * as workspaceAuth from "./workspaceAuth.js";
|
|
11
|
+
// Helper to create a tmp repo dir with optional config files; returns the path.
|
|
12
|
+
function makeTmpRepo(configFiles = []) {
|
|
13
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-test-repo-"));
|
|
14
|
+
for (const cfg of configFiles) {
|
|
15
|
+
const fullPath = path.join(dir, cfg);
|
|
16
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
17
|
+
fs.writeFileSync(fullPath, "");
|
|
18
|
+
}
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// frameworkFileToUrlPath
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
describe("frameworkFileToUrlPath", () => {
|
|
25
|
+
it("maps Next.js App Router page.tsx to /path", () => {
|
|
26
|
+
expect(frameworkFileToUrlPath("app/orders/page.tsx")).toBe("/orders");
|
|
27
|
+
});
|
|
28
|
+
it("maps Next.js App Router root page.tsx to /", () => {
|
|
29
|
+
expect(frameworkFileToUrlPath("app/page.tsx")).toBe("/");
|
|
30
|
+
});
|
|
31
|
+
it("maps Next.js App Router dynamic [id] segment to :id", () => {
|
|
32
|
+
expect(frameworkFileToUrlPath("app/orders/[id]/page.tsx")).toBe("/orders/:id");
|
|
33
|
+
});
|
|
34
|
+
it("strips Next.js App Router route groups (parentheses)", () => {
|
|
35
|
+
expect(frameworkFileToUrlPath("app/(marketing)/about/page.tsx")).toBe("/about");
|
|
36
|
+
});
|
|
37
|
+
it("maps Next.js Pages Router /pages/foo.tsx to /foo", () => {
|
|
38
|
+
expect(frameworkFileToUrlPath("pages/orders.tsx")).toBe("/orders");
|
|
39
|
+
});
|
|
40
|
+
it("maps Next.js Pages Router /pages/index.tsx to /", () => {
|
|
41
|
+
expect(frameworkFileToUrlPath("pages/index.tsx")).toBe("/");
|
|
42
|
+
});
|
|
43
|
+
it("maps Next.js Pages Router /pages/foo/index.tsx to /foo", () => {
|
|
44
|
+
expect(frameworkFileToUrlPath("pages/orders/index.tsx")).toBe("/orders");
|
|
45
|
+
});
|
|
46
|
+
it("excludes Next.js Pages Router API handlers (/pages/api/*)", () => {
|
|
47
|
+
expect(frameworkFileToUrlPath("pages/api/orders.ts")).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
it("maps Vue / Nuxt /pages/foo.vue to /foo", () => {
|
|
50
|
+
expect(frameworkFileToUrlPath("pages/orders.vue")).toBe("/orders");
|
|
51
|
+
});
|
|
52
|
+
it("maps Vue / Nuxt 2 /pages/_id.vue to :id", () => {
|
|
53
|
+
expect(frameworkFileToUrlPath("pages/_id.vue")).toBe("/:id");
|
|
54
|
+
});
|
|
55
|
+
it("maps SvelteKit routes/+page.svelte to /", () => {
|
|
56
|
+
expect(frameworkFileToUrlPath("src/routes/+page.svelte")).toBe("/");
|
|
57
|
+
});
|
|
58
|
+
it("maps SvelteKit routes/foo/+page.svelte to /foo", () => {
|
|
59
|
+
expect(frameworkFileToUrlPath("src/routes/orders/+page.svelte")).toBe("/orders");
|
|
60
|
+
});
|
|
61
|
+
it("returns null for a generic component file (not a route)", () => {
|
|
62
|
+
expect(frameworkFileToUrlPath("src/components/Button.tsx")).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
it("returns null for a layout file in app/", () => {
|
|
65
|
+
expect(frameworkFileToUrlPath("app/orders/layout.tsx")).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
it("returns null for a styles file under frontend dir", () => {
|
|
68
|
+
expect(frameworkFileToUrlPath("src/styles/global.css")).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// findCandidatePagesByFrameworkRoute
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
describe("findCandidatePagesByFrameworkRoute", () => {
|
|
75
|
+
it("returns empty for empty input", () => {
|
|
76
|
+
expect(findCandidatePagesByFrameworkRoute([], "http://localhost:3000")).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
it("returns empty when no files map to routes", () => {
|
|
79
|
+
expect(findCandidatePagesByFrameworkRoute(["src/components/Button.tsx", "src/utils/format.ts"], "http://localhost:3000")).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
it("emits a single page with full URL for a Next.js App Router route", () => {
|
|
82
|
+
const pages = findCandidatePagesByFrameworkRoute(["app/orders/page.tsx"], "http://localhost:3000");
|
|
83
|
+
expect(pages).toEqual([
|
|
84
|
+
{
|
|
85
|
+
url: "http://localhost:3000/orders",
|
|
86
|
+
sourcedFrom: ["app/orders/page.tsx"],
|
|
87
|
+
strategy: "framework-route-grep",
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
it("collapses two files emitting the same URL into one entry with both sources", () => {
|
|
92
|
+
const pages = findCandidatePagesByFrameworkRoute(["app/orders/page.tsx", "app/orders/page.tsx"], "http://localhost:3000");
|
|
93
|
+
expect(pages).toHaveLength(1);
|
|
94
|
+
expect(pages[0].sourcedFrom).toEqual(["app/orders/page.tsx"]);
|
|
95
|
+
});
|
|
96
|
+
it("strips trailing slash from baseUrl before joining", () => {
|
|
97
|
+
const pages = findCandidatePagesByFrameworkRoute(["app/orders/page.tsx"], "http://localhost:3000/");
|
|
98
|
+
expect(pages[0].url).toBe("http://localhost:3000/orders");
|
|
99
|
+
});
|
|
100
|
+
it("ignores files that don't map to routes (mixed input)", () => {
|
|
101
|
+
const pages = findCandidatePagesByFrameworkRoute([
|
|
102
|
+
"src/components/Button.tsx", // not a route
|
|
103
|
+
"app/orders/page.tsx", // route
|
|
104
|
+
"src/styles/main.css", // not a route
|
|
105
|
+
], "http://localhost:3000");
|
|
106
|
+
expect(pages).toHaveLength(1);
|
|
107
|
+
expect(pages[0].url).toBe("http://localhost:3000/orders");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// findCandidatePagesBySourceRoute (strategy 2)
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
describe("findCandidatePagesBySourceRoute", () => {
|
|
114
|
+
function makeRouterRepo() {
|
|
115
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-strat2-"));
|
|
116
|
+
fs.mkdirSync(path.join(dir, "src", "pages"), { recursive: true });
|
|
117
|
+
fs.writeFileSync(path.join(dir, "src", "App.tsx"), `
|
|
118
|
+
import { Routes, Route } from 'react-router-dom';
|
|
119
|
+
import Cart from './pages/Cart';
|
|
120
|
+
import Orders from './pages/Orders';
|
|
121
|
+
|
|
122
|
+
export default function App() {
|
|
123
|
+
return (
|
|
124
|
+
<Routes>
|
|
125
|
+
<Route path="/cart" element={<Cart />} />
|
|
126
|
+
<Route path="/orders" element={<Orders />} />
|
|
127
|
+
</Routes>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
`);
|
|
131
|
+
fs.writeFileSync(path.join(dir, "src", "pages", "Cart.tsx"), "export default function Cart() { return <div/>; }");
|
|
132
|
+
fs.writeFileSync(path.join(dir, "src", "pages", "Orders.tsx"), "export default function Orders() { return <div/>; }");
|
|
133
|
+
return dir;
|
|
134
|
+
}
|
|
135
|
+
it("emits candidate pages for changed files that match a route's component", () => {
|
|
136
|
+
const repo = makeRouterRepo();
|
|
137
|
+
const pages = findCandidatePagesBySourceRoute(["src/pages/Cart.tsx"], "http://localhost:3000", repo);
|
|
138
|
+
expect(pages).toHaveLength(1);
|
|
139
|
+
expect(pages[0]).toMatchObject({
|
|
140
|
+
url: "http://localhost:3000/cart",
|
|
141
|
+
strategy: "source-grounded-routes",
|
|
142
|
+
});
|
|
143
|
+
expect(pages[0].sourcedFrom).toEqual(["src/pages/Cart.tsx"]);
|
|
144
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
145
|
+
});
|
|
146
|
+
it("emits multiple pages when multiple changed files match routes", () => {
|
|
147
|
+
const repo = makeRouterRepo();
|
|
148
|
+
const pages = findCandidatePagesBySourceRoute(["src/pages/Cart.tsx", "src/pages/Orders.tsx"], "http://localhost:3000", repo);
|
|
149
|
+
expect(pages.map((p) => p.url).sort()).toEqual([
|
|
150
|
+
"http://localhost:3000/cart",
|
|
151
|
+
"http://localhost:3000/orders",
|
|
152
|
+
]);
|
|
153
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
154
|
+
});
|
|
155
|
+
it("returns empty when no changed files match a route's component", () => {
|
|
156
|
+
const repo = makeRouterRepo();
|
|
157
|
+
const pages = findCandidatePagesBySourceRoute(["src/components/Button.tsx"], "http://localhost:3000", repo);
|
|
158
|
+
expect(pages).toEqual([]);
|
|
159
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
160
|
+
});
|
|
161
|
+
it("returns empty when the repo has no router files", () => {
|
|
162
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-empty-"));
|
|
163
|
+
const pages = findCandidatePagesBySourceRoute(["src/pages/Cart.tsx"], "http://localhost:3000", repo);
|
|
164
|
+
expect(pages).toEqual([]);
|
|
165
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
166
|
+
});
|
|
167
|
+
it("strips trailing slash from baseUrl", () => {
|
|
168
|
+
const repo = makeRouterRepo();
|
|
169
|
+
const pages = findCandidatePagesBySourceRoute(["src/pages/Cart.tsx"], "http://localhost:3000/", repo);
|
|
170
|
+
expect(pages[0].url).toBe("http://localhost:3000/cart");
|
|
171
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
172
|
+
});
|
|
173
|
+
it("emits candidate pages when the changed file is the route DECLARATION (router.tsx, not the component)", () => {
|
|
174
|
+
// Regression: previously we only matched route.componentFile. A PR that
|
|
175
|
+
// renames a route in App.tsx but doesn't touch the component itself would
|
|
176
|
+
// surface zero source-grounded candidates. Real example: directus P06
|
|
177
|
+
// renamed /settings/data-model in modules/settings/index.ts; the component
|
|
178
|
+
// was unchanged, so the agent fell through to root-fallback and missed
|
|
179
|
+
// the changed routes entirely.
|
|
180
|
+
const repo = makeRouterRepo();
|
|
181
|
+
const pages = findCandidatePagesBySourceRoute(["src/App.tsx"], "http://localhost:3000", repo);
|
|
182
|
+
expect(pages.map((p) => p.url).sort()).toEqual([
|
|
183
|
+
"http://localhost:3000/cart",
|
|
184
|
+
"http://localhost:3000/orders",
|
|
185
|
+
]);
|
|
186
|
+
expect(pages[0].sourcedFrom).toEqual(["src/App.tsx"]);
|
|
187
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// pickFrontendBaseUrl
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
describe("pickFrontendBaseUrl", () => {
|
|
194
|
+
let readSpy;
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
readSpy = jest.spyOn(workspaceAuth, "readWorkspaceConfigRaw");
|
|
197
|
+
});
|
|
198
|
+
afterEach(() => {
|
|
199
|
+
readSpy.mockRestore();
|
|
200
|
+
});
|
|
201
|
+
it("returns null when workspace config is missing", async () => {
|
|
202
|
+
readSpy.mockResolvedValue(null);
|
|
203
|
+
expect(await pickFrontendBaseUrl("/repo")).toBeNull();
|
|
204
|
+
});
|
|
205
|
+
it("returns null when no services have baseUrl", async () => {
|
|
206
|
+
readSpy.mockResolvedValue({
|
|
207
|
+
services: [{ serviceName: "backend", language: "python" }],
|
|
208
|
+
});
|
|
209
|
+
expect(await pickFrontendBaseUrl("/repo")).toBeNull();
|
|
210
|
+
});
|
|
211
|
+
it("picks the frontend service by name suffix", async () => {
|
|
212
|
+
readSpy.mockResolvedValue({
|
|
213
|
+
services: [
|
|
214
|
+
{ serviceName: "demoshop-backend", api: { baseUrl: "http://localhost:8000" } },
|
|
215
|
+
{ serviceName: "demoshop-frontend", api: { baseUrl: "http://localhost:5173" } },
|
|
216
|
+
],
|
|
217
|
+
});
|
|
218
|
+
expect(await pickFrontendBaseUrl("/repo")).toBe("http://localhost:5173");
|
|
219
|
+
});
|
|
220
|
+
it("picks the frontend service by framework when name doesn't signal it", async () => {
|
|
221
|
+
readSpy.mockResolvedValue({
|
|
222
|
+
services: [
|
|
223
|
+
{ serviceName: "api", language: "python", api: { baseUrl: "http://localhost:8000" } },
|
|
224
|
+
{
|
|
225
|
+
serviceName: "client",
|
|
226
|
+
language: "typescript",
|
|
227
|
+
framework: "next",
|
|
228
|
+
api: { baseUrl: "http://localhost:3000" },
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
});
|
|
232
|
+
expect(await pickFrontendBaseUrl("/repo")).toBe("http://localhost:3000");
|
|
233
|
+
});
|
|
234
|
+
it("falls back to first service with baseUrl when no frontend signal", async () => {
|
|
235
|
+
readSpy.mockResolvedValue({
|
|
236
|
+
services: [
|
|
237
|
+
{ serviceName: "svc-a", language: "go", api: { baseUrl: "http://localhost:8000" } },
|
|
238
|
+
{ serviceName: "svc-b", language: "go", api: { baseUrl: "http://localhost:8001" } },
|
|
239
|
+
],
|
|
240
|
+
});
|
|
241
|
+
expect(await pickFrontendBaseUrl("/repo")).toBe("http://localhost:8000");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// findRootFallbackPage
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
describe("findRootFallbackPage", () => {
|
|
248
|
+
let readSpy;
|
|
249
|
+
beforeEach(() => {
|
|
250
|
+
readSpy = jest.spyOn(workspaceAuth, "readWorkspaceConfigRaw");
|
|
251
|
+
});
|
|
252
|
+
afterEach(() => {
|
|
253
|
+
readSpy.mockRestore();
|
|
254
|
+
});
|
|
255
|
+
it("returns null when no frontend baseUrl resolvable", async () => {
|
|
256
|
+
readSpy.mockResolvedValue(null);
|
|
257
|
+
expect(await findRootFallbackPage("/repo")).toBeNull();
|
|
258
|
+
});
|
|
259
|
+
it("returns root-fallback page when frontend baseUrl resolves", async () => {
|
|
260
|
+
readSpy.mockResolvedValue({
|
|
261
|
+
services: [{ serviceName: "frontend", api: { baseUrl: "http://localhost:3000" } }],
|
|
262
|
+
});
|
|
263
|
+
const page = await findRootFallbackPage("/repo");
|
|
264
|
+
expect(page).toEqual({
|
|
265
|
+
url: "http://localhost:3000",
|
|
266
|
+
sourcedFrom: [],
|
|
267
|
+
strategy: "root-fallback",
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// enumerateCandidateUiPages
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
describe("enumerateCandidateUiPages", () => {
|
|
275
|
+
let readSpy;
|
|
276
|
+
beforeEach(() => {
|
|
277
|
+
readSpy = jest.spyOn(workspaceAuth, "readWorkspaceConfigRaw");
|
|
278
|
+
});
|
|
279
|
+
afterEach(() => {
|
|
280
|
+
readSpy.mockRestore();
|
|
281
|
+
});
|
|
282
|
+
it("returns empty for empty file list", async () => {
|
|
283
|
+
expect(await enumerateCandidateUiPages("/repo", [])).toEqual([]);
|
|
284
|
+
});
|
|
285
|
+
it("returns empty when workspace has no resolvable baseUrl", async () => {
|
|
286
|
+
readSpy.mockResolvedValue(null);
|
|
287
|
+
expect(await enumerateCandidateUiPages("/repo", ["app/orders/page.tsx"])).toEqual([]);
|
|
288
|
+
});
|
|
289
|
+
it("uses strategy 1 (framework-route-grep) when route files match", async () => {
|
|
290
|
+
readSpy.mockResolvedValue({
|
|
291
|
+
services: [{ serviceName: "frontend", api: { baseUrl: "http://localhost:3000" } }],
|
|
292
|
+
});
|
|
293
|
+
const repo = makeTmpRepo(["next.config.js"]);
|
|
294
|
+
const pages = await enumerateCandidateUiPages(repo, [
|
|
295
|
+
"app/orders/page.tsx",
|
|
296
|
+
"src/components/Button.tsx", // ignored — not a route
|
|
297
|
+
]);
|
|
298
|
+
expect(pages).toHaveLength(1);
|
|
299
|
+
expect(pages[0]).toMatchObject({
|
|
300
|
+
url: "http://localhost:3000/orders",
|
|
301
|
+
strategy: "framework-route-grep",
|
|
302
|
+
});
|
|
303
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
304
|
+
});
|
|
305
|
+
it("falls back to strategy 3 (root-fallback) when no route files match", async () => {
|
|
306
|
+
readSpy.mockResolvedValue({
|
|
307
|
+
services: [{ serviceName: "frontend", api: { baseUrl: "http://localhost:3000" } }],
|
|
308
|
+
});
|
|
309
|
+
const pages = await enumerateCandidateUiPages("/repo", [
|
|
310
|
+
"src/components/Button.tsx", // not a route
|
|
311
|
+
"src/state/store.ts", // not a route
|
|
312
|
+
]);
|
|
313
|
+
expect(pages).toHaveLength(1);
|
|
314
|
+
expect(pages[0]).toMatchObject({
|
|
315
|
+
url: "http://localhost:3000",
|
|
316
|
+
strategy: "root-fallback",
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
it("emits multiple route pages when several match strategy 1", async () => {
|
|
320
|
+
readSpy.mockResolvedValue({
|
|
321
|
+
services: [{ serviceName: "frontend", api: { baseUrl: "http://localhost:3000" } }],
|
|
322
|
+
});
|
|
323
|
+
// Need a real repoPath with framework config so detectsFilesystemRouting passes.
|
|
324
|
+
const repo = makeTmpRepo(["next.config.js"]);
|
|
325
|
+
const pages = await enumerateCandidateUiPages(repo, [
|
|
326
|
+
"app/orders/page.tsx",
|
|
327
|
+
"app/products/[id]/page.tsx",
|
|
328
|
+
]);
|
|
329
|
+
expect(pages).toHaveLength(2);
|
|
330
|
+
const urls = pages.map((p) => p.url).sort();
|
|
331
|
+
expect(urls).toEqual([
|
|
332
|
+
"http://localhost:3000/orders",
|
|
333
|
+
"http://localhost:3000/products/:id",
|
|
334
|
+
]);
|
|
335
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
336
|
+
});
|
|
337
|
+
it("uses strategy 2 (source-grounded-routes) when strategy 1 doesn't match but routes are declared in source", async () => {
|
|
338
|
+
readSpy.mockResolvedValue({
|
|
339
|
+
services: [{ serviceName: "frontend", api: { baseUrl: "http://localhost:5173" } }],
|
|
340
|
+
});
|
|
341
|
+
// Repo with no framework config (so strategy 1 won't fire) but with a
|
|
342
|
+
// React Router App.tsx declaring routes.
|
|
343
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-ladder-"));
|
|
344
|
+
fs.mkdirSync(path.join(repo, "src", "pages"), { recursive: true });
|
|
345
|
+
fs.writeFileSync(path.join(repo, "src", "App.tsx"), `
|
|
346
|
+
import { Routes, Route } from 'react-router-dom';
|
|
347
|
+
import Cart from './pages/Cart';
|
|
348
|
+
export default function App() { return <Routes><Route path="/cart" element={<Cart />} /></Routes>; }
|
|
349
|
+
`);
|
|
350
|
+
fs.writeFileSync(path.join(repo, "src", "pages", "Cart.tsx"), "export default function Cart() { return <div/>; }");
|
|
351
|
+
const pages = await enumerateCandidateUiPages(repo, [
|
|
352
|
+
"src/pages/Cart.tsx",
|
|
353
|
+
]);
|
|
354
|
+
expect(pages).toHaveLength(1);
|
|
355
|
+
expect(pages[0]).toMatchObject({
|
|
356
|
+
url: "http://localhost:5173/cart",
|
|
357
|
+
strategy: "source-grounded-routes",
|
|
358
|
+
});
|
|
359
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
360
|
+
});
|
|
361
|
+
it("falls back to root when repo lacks a framework config (no false positives)", async () => {
|
|
362
|
+
readSpy.mockResolvedValue({
|
|
363
|
+
services: [{ serviceName: "frontend", api: { baseUrl: "http://localhost:3000" } }],
|
|
364
|
+
});
|
|
365
|
+
// Repo has files matching the Pages Router pattern but no next.config —
|
|
366
|
+
// this is the demoshop case (React with conventional layout, no Next.js).
|
|
367
|
+
const repo = makeTmpRepo([]);
|
|
368
|
+
const pages = await enumerateCandidateUiPages(repo, [
|
|
369
|
+
"frontend/src/pages/Cart.tsx",
|
|
370
|
+
]);
|
|
371
|
+
expect(pages).toHaveLength(1);
|
|
372
|
+
expect(pages[0]).toMatchObject({
|
|
373
|
+
url: "http://localhost:3000",
|
|
374
|
+
strategy: "root-fallback",
|
|
375
|
+
});
|
|
376
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// detectsFilesystemRouting
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
describe("detectsFilesystemRouting", () => {
|
|
383
|
+
let repo;
|
|
384
|
+
afterEach(() => {
|
|
385
|
+
if (repo) {
|
|
386
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
387
|
+
repo = "";
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
it("returns false when no framework config exists", () => {
|
|
391
|
+
repo = makeTmpRepo([]);
|
|
392
|
+
expect(detectsFilesystemRouting(repo)).toBe(false);
|
|
393
|
+
});
|
|
394
|
+
it("detects Next.js via next.config.js at repo root", () => {
|
|
395
|
+
repo = makeTmpRepo(["next.config.js"]);
|
|
396
|
+
expect(detectsFilesystemRouting(repo)).toBe(true);
|
|
397
|
+
});
|
|
398
|
+
it("detects Next.js via next.config.ts", () => {
|
|
399
|
+
repo = makeTmpRepo(["next.config.ts"]);
|
|
400
|
+
expect(detectsFilesystemRouting(repo)).toBe(true);
|
|
401
|
+
});
|
|
402
|
+
it("detects Nuxt via nuxt.config.ts", () => {
|
|
403
|
+
repo = makeTmpRepo(["nuxt.config.ts"]);
|
|
404
|
+
expect(detectsFilesystemRouting(repo)).toBe(true);
|
|
405
|
+
});
|
|
406
|
+
it("detects SvelteKit via svelte.config.js", () => {
|
|
407
|
+
repo = makeTmpRepo(["svelte.config.js"]);
|
|
408
|
+
expect(detectsFilesystemRouting(repo)).toBe(true);
|
|
409
|
+
});
|
|
410
|
+
it("detects framework config one level deep (frontend/)", () => {
|
|
411
|
+
repo = makeTmpRepo(["frontend/next.config.js"]);
|
|
412
|
+
expect(detectsFilesystemRouting(repo)).toBe(true);
|
|
413
|
+
});
|
|
414
|
+
it("detects framework config in apps/web/", () => {
|
|
415
|
+
repo = makeTmpRepo(["apps/web/next.config.mjs"]);
|
|
416
|
+
expect(detectsFilesystemRouting(repo)).toBe(true);
|
|
417
|
+
});
|
|
418
|
+
it("returns false when only a pages/ directory exists (no config)", () => {
|
|
419
|
+
repo = makeTmpRepo(["pages/Cart.tsx"]);
|
|
420
|
+
expect(detectsFilesystemRouting(repo)).toBe(false);
|
|
421
|
+
});
|
|
422
|
+
});
|
package/build/utils/utils.js
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
import path from "path";
|
|
2
|
+
import * as fs from "fs";
|
|
2
3
|
import { isTestbotEnabled } from "./featureFlags.js";
|
|
3
4
|
import { logger } from "./logger.js";
|
|
5
|
+
export function readDiffFile(diffFilePath) {
|
|
6
|
+
if (!diffFilePath)
|
|
7
|
+
return undefined;
|
|
8
|
+
try {
|
|
9
|
+
return fs.readFileSync(diffFilePath, "utf-8");
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function toolError(message) {
|
|
16
|
+
return {
|
|
17
|
+
content: [{ type: "text", text: message }],
|
|
18
|
+
isError: true,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Happy-path counterpart to `toolError`. Wraps a single text payload in the
|
|
23
|
+
* MCP tool-result shape so handlers don't have to construct
|
|
24
|
+
* `{ content: [{ type: "text", text }] }` boilerplate themselves.
|
|
25
|
+
*/
|
|
26
|
+
export function toolText(text) {
|
|
27
|
+
return {
|
|
28
|
+
content: [{ type: "text", text }],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
4
31
|
export const OUTPUT_DIR_FIELD_NAME = "outputDir";
|
|
5
32
|
export const TRACE_OUTPUT_FILE_FIELD_NAME = "traceOutputFile";
|
|
6
33
|
export const PLAYWRIGHT_OUTPUT_FILE_FIELD_NAME = "playwrightOutput";
|
package/build/utils/versions.js
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
1
2
|
import path from "path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
2
4
|
import { WorkspaceConfigManager } from "@skyramp/skyramp";
|
|
3
5
|
import { logger } from "./logger.js";
|
|
4
6
|
/**
|
|
5
|
-
* Reads workspace.yml
|
|
6
|
-
*
|
|
7
|
+
* Reads `.skyramp/workspace.yml`.
|
|
8
|
+
*
|
|
9
|
+
* Tries the library's Zod-validated `WorkspaceConfigManager.read()` first so
|
|
10
|
+
* well-formed workspaces produce a normalized object. When validation fails
|
|
11
|
+
* (e.g. a hand-rolled fixture that omits `metadata.createdAt` /`updatedAt`,
|
|
12
|
+
* or any new field added by a future schema), falls back to a raw YAML parse.
|
|
13
|
+
*
|
|
14
|
+
* Callers are expected to read only the fields they need (`services`,
|
|
15
|
+
* `services[].api.baseUrl`, etc.) — they should not depend on the strict
|
|
16
|
+
* shape across both paths.
|
|
17
|
+
*
|
|
18
|
+
* Returns null if the file does not exist OR cannot be parsed as YAML at all.
|
|
7
19
|
*/
|
|
8
20
|
export async function readWorkspaceConfigRaw(workspacePath) {
|
|
9
21
|
const wsMgr = new WorkspaceConfigManager(workspacePath);
|
|
@@ -12,8 +24,25 @@ export async function readWorkspaceConfigRaw(workspacePath) {
|
|
|
12
24
|
try {
|
|
13
25
|
return await wsMgr.read();
|
|
14
26
|
}
|
|
15
|
-
catch {
|
|
16
|
-
|
|
27
|
+
catch (err) {
|
|
28
|
+
// Validation failed — try a raw YAML parse so callers that only need
|
|
29
|
+
// unvalidated fields (services / baseUrl) still work on partial fixtures.
|
|
30
|
+
const configPath = path.join(workspacePath, ".skyramp", "workspace.yml");
|
|
31
|
+
try {
|
|
32
|
+
const raw = await fs.promises.readFile(configPath, "utf-8");
|
|
33
|
+
const parsed = yaml.load(raw);
|
|
34
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
35
|
+
logger.debug("workspace.yml failed strict validation; using raw parse", {
|
|
36
|
+
workspacePath,
|
|
37
|
+
error: err instanceof Error ? err.message : String(err),
|
|
38
|
+
});
|
|
39
|
+
return parsed;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
17
46
|
}
|
|
18
47
|
}
|
|
19
48
|
/**
|
|
@@ -260,8 +260,44 @@ async function domEvaluationScript() {
|
|
|
260
260
|
const text = el.textContent?.trim().replace(/\s+/g, " ").slice(0, 60);
|
|
261
261
|
if (text)
|
|
262
262
|
return text;
|
|
263
|
+
if (NAMELESS_INTERACTIVE_ICON_ROLES.has(role)) {
|
|
264
|
+
const iconName = findDescendantIconName(el);
|
|
265
|
+
if (iconName)
|
|
266
|
+
return iconName;
|
|
267
|
+
}
|
|
263
268
|
return "";
|
|
264
269
|
}
|
|
270
|
+
const NAMELESS_INTERACTIVE_ICON_ROLES = /* @__PURE__ */ new Set([
|
|
271
|
+
"button",
|
|
272
|
+
"link",
|
|
273
|
+
"menuitem",
|
|
274
|
+
"tab",
|
|
275
|
+
"switch",
|
|
276
|
+
"checkbox",
|
|
277
|
+
"radio",
|
|
278
|
+
"option"
|
|
279
|
+
]);
|
|
280
|
+
function findDescendantIconName(el) {
|
|
281
|
+
const descendants = el.querySelectorAll('[data-icon], svg title, [href*="#icon-"]');
|
|
282
|
+
let count = 0;
|
|
283
|
+
for (const d of Array.from(descendants)) {
|
|
284
|
+
if (++count > 6)
|
|
285
|
+
break;
|
|
286
|
+
const dataIcon = d.getAttribute("data-icon");
|
|
287
|
+
if (dataIcon && dataIcon.trim())
|
|
288
|
+
return dataIcon.trim();
|
|
289
|
+
if (d.tagName.toLowerCase() === "title") {
|
|
290
|
+
const txt = d.textContent?.trim();
|
|
291
|
+
if (txt) return txt;
|
|
292
|
+
}
|
|
293
|
+
const href = d.getAttribute("href") || d.getAttribute("xlink:href");
|
|
294
|
+
if (href) {
|
|
295
|
+
const match = href.match(/#icon-(.+)$/i);
|
|
296
|
+
if (match) return match[1];
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
265
301
|
const NATIVE_TAG_TO_ROLE = {
|
|
266
302
|
button: "button",
|
|
267
303
|
a: "link",
|
|
@@ -350,6 +386,8 @@ async function domEvaluationScript() {
|
|
|
350
386
|
return "immutable";
|
|
351
387
|
if (role === "link")
|
|
352
388
|
return "immutable";
|
|
389
|
+
if (role === "heading")
|
|
390
|
+
return "immutable";
|
|
353
391
|
if (role === "searchbox" || role === "tab" || role === "tabpanel")
|
|
354
392
|
return "immutable";
|
|
355
393
|
if (role === "menuitem") {
|
|
@@ -374,11 +412,11 @@ async function domEvaluationScript() {
|
|
|
374
412
|
return "unknown";
|
|
375
413
|
}
|
|
376
414
|
const INTERACTIVE_SELECTOR = [
|
|
377
|
-
"button
|
|
415
|
+
"button",
|
|
378
416
|
"a[href]",
|
|
379
|
-
'input:not([type="hidden"])
|
|
380
|
-
"select
|
|
381
|
-
"textarea
|
|
417
|
+
'input:not([type="hidden"])',
|
|
418
|
+
"select",
|
|
419
|
+
"textarea",
|
|
382
420
|
'[role="button"]',
|
|
383
421
|
'[role="link"]',
|
|
384
422
|
'[role="checkbox"]',
|
|
@@ -395,7 +433,18 @@ async function domEvaluationScript() {
|
|
|
395
433
|
'[role="alert"]',
|
|
396
434
|
'[role="log"]',
|
|
397
435
|
'[role="marquee"]',
|
|
398
|
-
"iframe"
|
|
436
|
+
"iframe",
|
|
437
|
+
// Headings — captured for grounding test assertions like
|
|
438
|
+
// `expect(getByRole('heading', { name: 'X' })).toBeVisible()`. Headings
|
|
439
|
+
// remain non-interactive but are routinely cited in UI tests; the
|
|
440
|
+
// blueprint must include them for the citation invariant to ground.
|
|
441
|
+
"h1",
|
|
442
|
+
"h2",
|
|
443
|
+
"h3",
|
|
444
|
+
"h4",
|
|
445
|
+
"h5",
|
|
446
|
+
"h6",
|
|
447
|
+
'[role="heading"]'
|
|
399
448
|
].join(", ");
|
|
400
449
|
const LANDMARKS = [
|
|
401
450
|
{ selector: 'nav, [role="navigation"]', landmark: "navigation", nameHint: "navbar" },
|
|
@@ -169,6 +169,8 @@ function diffBlueprints(before, after) {
|
|
|
169
169
|
delta.textChanges.push({
|
|
170
170
|
logicalName: elBefore.logicalName,
|
|
171
171
|
sectionLogicalName: sBefore.name,
|
|
172
|
+
role: elAfter.role,
|
|
173
|
+
accessibleName: elAfter.accessibleName,
|
|
172
174
|
before: truncate(tA, 200),
|
|
173
175
|
after: truncate(tB, 200)
|
|
174
176
|
});
|
|
@@ -209,6 +211,8 @@ function diffBlueprints(before, after) {
|
|
|
209
211
|
delta.repeatingCountChanges.push({
|
|
210
212
|
logicalName: repB.logicalName,
|
|
211
213
|
sectionLogicalName: section.name,
|
|
214
|
+
role: repB.role,
|
|
215
|
+
accessibleNameTemplate: repB.accessibleNameTemplate,
|
|
212
216
|
before: repA.items.length,
|
|
213
217
|
after: repB.items.length,
|
|
214
218
|
delta: repB.items.length - repA.items.length
|
|
@@ -155,6 +155,8 @@ test("diffBlueprints: repeatingCountChanges 12 -> 13", () => {
|
|
|
155
155
|
assertEqual(d.repeatingCountChanges[0].before, 12);
|
|
156
156
|
assertEqual(d.repeatingCountChanges[0].after, 13);
|
|
157
157
|
assertEqual(d.repeatingCountChanges[0].delta, 1);
|
|
158
|
+
assertEqual(d.repeatingCountChanges[0].role, "button");
|
|
159
|
+
assertEqual(d.repeatingCountChanges[0].accessibleNameTemplate, "View details for order {orderId}");
|
|
158
160
|
});
|
|
159
161
|
test("diffBlueprints: repeatingItemsChanged detects same-count composition shift", () => {
|
|
160
162
|
const items1 = [{ parameters: { orderId: "1" } }, { parameters: { orderId: "2" } }];
|
|
@@ -173,6 +175,8 @@ test("diffBlueprints: textChanges on matched element", () => {
|
|
|
173
175
|
assertEqual(d.textChanges.length, 1);
|
|
174
176
|
assertEqual(d.textChanges[0].before, "Pending");
|
|
175
177
|
assertEqual(d.textChanges[0].after, "Cancelled");
|
|
178
|
+
assertEqual(d.textChanges[0].role, "button");
|
|
179
|
+
assertEqual(d.textChanges[0].accessibleName, "Cancelled");
|
|
176
180
|
});
|
|
177
181
|
test("diffBlueprints: whitespace-only text changes ignored", () => {
|
|
178
182
|
const a = makeBlueprint("http://x", [makeSection("main", [makeElement({ logicalName: "s", accessibleName: "Saved" })])]);
|
|
@@ -223,6 +227,8 @@ test("diffBlueprints: element matched via stableId when logicalName drifts", ()
|
|
|
223
227
|
assertEqual(d.textChanges.length, 1);
|
|
224
228
|
assertEqual(d.textChanges[0].before, "");
|
|
225
229
|
assertEqual(d.textChanges[0].after, "Order placed");
|
|
230
|
+
assertEqual(d.textChanges[0].role, "button");
|
|
231
|
+
assertEqual(d.textChanges[0].accessibleName, "Order placed");
|
|
226
232
|
assertEqual(d.hasStructuralChange, false);
|
|
227
233
|
});
|
|
228
234
|
test("diffBlueprints: element matched via testId when logicalName drifts", () => {
|