@skyramp/mcp 0.1.8 → 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/playwright/registerPlaywrightTools.js +12 -0
- package/build/playwright/traceRecordingPrompt.js +15 -0
- 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 +246 -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 +4 -3
- 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 +316 -1
- package/build/prompts/testbot/testbot-prompts.js +73 -13
- package/build/prompts/testbot/testbot-prompts.test.js +114 -1
- package/build/resources/testbotResource.js +1 -1
- package/build/services/ScenarioGenerationService.integration.test.js +158 -0
- package/build/services/ScenarioGenerationService.js +47 -4
- package/build/services/ScenarioGenerationService.test.js +158 -22
- 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/generate-tests/generateBatchScenarioRestTool.js +16 -4
- package/build/tools/generate-tests/generateIntegrationRestTool.js +2 -0
- package/build/tools/generate-tests/generateUIRestTool.js +2 -0
- package/build/tools/test-management/actionsTool.js +152 -63
- package/build/tools/test-management/analyzeChangesTool.js +178 -64
- 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 +180 -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/ThirdPartyNotices.txt +6 -6
- package/node_modules/playwright/lib/dom-analyzer/analyze.js +111 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1210 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.test.js +396 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.test.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +254 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +304 -0
- package/node_modules/playwright/lib/dom-analyzer/crawler.js +384 -0
- package/node_modules/playwright/lib/dom-analyzer/curatedWidgets.js +73 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +85 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.js +90 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.test.js +231 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.fixtures.js +145 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.test.js +41 -0
- package/node_modules/playwright/lib/dom-analyzer/graph.js +36 -0
- package/node_modules/playwright/lib/dom-analyzer/liveFingerprints.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.js +72 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.test.js +182 -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/dom-analyzer/sectionGrouper.js +169 -0
- package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.test.js +269 -0
- package/node_modules/playwright/lib/dom-analyzer/serialization.js +75 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.js +30 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.test.js +84 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.js +127 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.test.js +212 -0
- package/node_modules/playwright/lib/mcp/browser/browserContextFactory.js +3 -1
- package/node_modules/playwright/lib/mcp/browser/config.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/context.js +17 -1
- package/node_modules/playwright/lib/mcp/browser/tab.js +38 -0
- package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +261 -0
- package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -3
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +146 -0
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +140 -0
- package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +226 -0
- package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +2 -2
- package/node_modules/playwright/lib/mcp/browser/tools/widgetContract.js +168 -0
- package/node_modules/playwright/lib/mcp/browser/tools.js +6 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +52 -12
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +64 -13
- package/node_modules/playwright/package.json +1 -1
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
- 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
|
/**
|
|
@@ -153,8 +153,8 @@ This project incorporates components from the projects listed below. The origina
|
|
|
153
153
|
- node-releases@2.0.19 (https://github.com/chicoxyzzy/node-releases)
|
|
154
154
|
- normalize-path@3.0.0 (https://github.com/jonschlinkert/normalize-path)
|
|
155
155
|
- picocolors@1.1.1 (https://github.com/alexeyraspopov/picocolors)
|
|
156
|
-
- picomatch@2.3.
|
|
157
|
-
- picomatch@4.0.
|
|
156
|
+
- picomatch@2.3.2 (https://github.com/micromatch/picomatch)
|
|
157
|
+
- picomatch@4.0.4 (https://github.com/micromatch/picomatch)
|
|
158
158
|
- pretty-format@30.2.0 (https://github.com/jestjs/jest)
|
|
159
159
|
- react-is@18.3.1 (https://github.com/facebook/react)
|
|
160
160
|
- readdirp@3.6.0 (https://github.com/paulmillr/readdirp)
|
|
@@ -4491,7 +4491,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
|
4491
4491
|
=========================================
|
|
4492
4492
|
END OF picocolors@1.1.1 AND INFORMATION
|
|
4493
4493
|
|
|
4494
|
-
%% picomatch@2.3.
|
|
4494
|
+
%% picomatch@2.3.2 NOTICES AND INFORMATION BEGIN HERE
|
|
4495
4495
|
=========================================
|
|
4496
4496
|
The MIT License (MIT)
|
|
4497
4497
|
|
|
@@ -4515,9 +4515,9 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
|
4515
4515
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
4516
4516
|
THE SOFTWARE.
|
|
4517
4517
|
=========================================
|
|
4518
|
-
END OF picomatch@2.3.
|
|
4518
|
+
END OF picomatch@2.3.2 AND INFORMATION
|
|
4519
4519
|
|
|
4520
|
-
%% picomatch@4.0.
|
|
4520
|
+
%% picomatch@4.0.4 NOTICES AND INFORMATION BEGIN HERE
|
|
4521
4521
|
=========================================
|
|
4522
4522
|
The MIT License (MIT)
|
|
4523
4523
|
|
|
@@ -4541,7 +4541,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
|
4541
4541
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
4542
4542
|
THE SOFTWARE.
|
|
4543
4543
|
=========================================
|
|
4544
|
-
END OF picomatch@4.0.
|
|
4544
|
+
END OF picomatch@4.0.4 AND INFORMATION
|
|
4545
4545
|
|
|
4546
4546
|
%% pretty-format@30.2.0 NOTICES AND INFORMATION BEGIN HERE
|
|
4547
4547
|
=========================================
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
var analyze_exports = {};
|
|
30
|
+
__export(analyze_exports, {
|
|
31
|
+
writeSitemap: () => writeSitemap
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(analyze_exports);
|
|
34
|
+
var fs = __toESM(require("fs"));
|
|
35
|
+
var path = __toESM(require("path"));
|
|
36
|
+
var import_yaml = require("yaml");
|
|
37
|
+
var import_utils = require("playwright-core/lib/utils");
|
|
38
|
+
var import_crawler = require("./crawler");
|
|
39
|
+
var import_blueprint = require("./blueprint");
|
|
40
|
+
var import_graph = require("./graph");
|
|
41
|
+
function writeSitemap(sitemap, outputDir, format) {
|
|
42
|
+
fs.mkdirSync(path.join(outputDir, "pages"), { recursive: true });
|
|
43
|
+
const sitemapFile = path.join(outputDir, `sitemap.${format}`);
|
|
44
|
+
fs.writeFileSync(sitemapFile, format === "json" ? JSON.stringify(sitemap, null, 2) : (0, import_yaml.stringify)(sitemap), "utf-8");
|
|
45
|
+
for (const [url, blueprint] of Object.entries(sitemap.pages)) {
|
|
46
|
+
const hash = (0, import_graph.urlToFilename)(url);
|
|
47
|
+
const canonicalFile = path.join(outputDir, "pages", `${hash}.${format}`);
|
|
48
|
+
fs.writeFileSync(
|
|
49
|
+
canonicalFile,
|
|
50
|
+
format === "json" ? JSON.stringify(blueprint, null, 2) : (0, import_yaml.stringify)(blueprint),
|
|
51
|
+
"utf-8"
|
|
52
|
+
);
|
|
53
|
+
const mapJsonFile = path.join(outputDir, "pages", `${hash}.mapJson.json`);
|
|
54
|
+
fs.writeFileSync(mapJsonFile, JSON.stringify((0, import_blueprint.buildMap)(blueprint), null, 2), "utf-8");
|
|
55
|
+
const outlineFile = path.join(outputDir, "pages", `${hash}.outline.txt`);
|
|
56
|
+
fs.writeFileSync(outlineFile, (0, import_blueprint.buildOutline)(blueprint), "utf-8");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (require.main === module) {
|
|
60
|
+
const args = process.argv.slice(2);
|
|
61
|
+
const urlArg = args.find((a) => !a.startsWith("--"));
|
|
62
|
+
const outputDirIdx = args.indexOf("--output-dir");
|
|
63
|
+
const outputDir = outputDirIdx !== -1 ? args[outputDirIdx + 1] : "dom-analysis";
|
|
64
|
+
const depthIdx = args.indexOf("--depth");
|
|
65
|
+
const depth = depthIdx !== -1 ? parseInt(args[depthIdx + 1], 10) : 5;
|
|
66
|
+
const maxPagesIdx = args.indexOf("--max-pages");
|
|
67
|
+
const maxPages = maxPagesIdx !== -1 ? parseInt(args[maxPagesIdx + 1], 10) : 50;
|
|
68
|
+
const sameOriginOnly = !args.includes("--no-same-origin");
|
|
69
|
+
const probeButtonsIdx = args.indexOf("--probe-buttons");
|
|
70
|
+
const probeButtons = probeButtonsIdx !== -1 ? args[probeButtonsIdx + 1] : "immutable-only";
|
|
71
|
+
const storageStateIdx = args.indexOf("--storage-state");
|
|
72
|
+
const playwrightStoragePath = storageStateIdx !== -1 ? args[storageStateIdx + 1] : void 0;
|
|
73
|
+
const formatIdx = args.indexOf("--format");
|
|
74
|
+
const format = formatIdx !== -1 ? args[formatIdx + 1] : "json";
|
|
75
|
+
if (!urlArg) {
|
|
76
|
+
process.stderr.write("Usage: analyze.js <url> [--output-dir <dir>] [--depth <n>] [--max-pages <n>] [--no-same-origin] [--probe-buttons <mode>] [--storage-state <path>] [--format <json|yaml>]\n");
|
|
77
|
+
(0, import_utils.gracefullyProcessExitDoNotHang)(1);
|
|
78
|
+
}
|
|
79
|
+
if (!["immutable-only", "all", "none"].includes(probeButtons)) {
|
|
80
|
+
process.stderr.write(`Invalid --probe-buttons: ${probeButtons}. Must be one of: immutable-only, all, none
|
|
81
|
+
`);
|
|
82
|
+
(0, import_utils.gracefullyProcessExitDoNotHang)(1);
|
|
83
|
+
}
|
|
84
|
+
if (format !== "json" && format !== "yaml") {
|
|
85
|
+
process.stderr.write(`Invalid --format: ${format}. Must be json or yaml.
|
|
86
|
+
`);
|
|
87
|
+
(0, import_utils.gracefullyProcessExitDoNotHang)(1);
|
|
88
|
+
}
|
|
89
|
+
const run = depth > 0 ? (0, import_crawler.crawl)(urlArg, { depth, maxPages, sameOriginOnly, probeButtons, playwrightStoragePath }) : (0, import_crawler.crawlSinglePage)(urlArg, { playwrightStoragePath });
|
|
90
|
+
run.then((sitemap) => {
|
|
91
|
+
writeSitemap(sitemap, outputDir, format);
|
|
92
|
+
const absPath = path.resolve(outputDir, `sitemap.${format}`);
|
|
93
|
+
const pageCount = Object.keys(sitemap.pages).length;
|
|
94
|
+
process.stdout.write(`Sitemap written: ${absPath}
|
|
95
|
+
`);
|
|
96
|
+
process.stdout.write(` pages: ${pageCount}
|
|
97
|
+
`);
|
|
98
|
+
process.stdout.write(` edges: ${sitemap.edges.length}
|
|
99
|
+
`);
|
|
100
|
+
process.stdout.write(` per-page: ${path.resolve(outputDir, "pages")}/
|
|
101
|
+
`);
|
|
102
|
+
}).catch((err) => {
|
|
103
|
+
process.stderr.write(`Error: ${err.message}
|
|
104
|
+
`);
|
|
105
|
+
(0, import_utils.gracefullyProcessExitDoNotHang)(1);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
109
|
+
0 && (module.exports = {
|
|
110
|
+
writeSitemap
|
|
111
|
+
});
|