mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
fix: Retry responsive preview routes and report linked project IDs (#6458)
Responsive verification could reject an interior route while the refreshed preview was still settling. Managed responsive captures now use the same bounded retry as single screenshots. If the route keeps returning HTTP 4xx/5xx, the tool reports the route failure instead of calling it the wrong site. Issue reports now use the resolved linked project ID, including when the CLI was started without --project. This makes future project-specific reports traceable without adding URLs or customer content. The immediate false rejection is removed for transient failures. A batch may be recaptured up to twice with a one-second wait between attempts. Persistent route or server errors still fail and need investigation. Ref #6456 Ref #6452 Ref #6457 Ref #6396 ## Verification - [x] New responsive retry and HTTP-classification tests failed before the changes and pass afterward. - [x] Full CLI suite: 1,153 tests passed. CLI typecheck, formatting and targeted lint passed. - [x] Focused sitemap, bundle-loading, and API-compatibility tests passed. - [x] Reviewed authoritative docs: no user workflow or documented input changed, so no docs update is needed. - [x] Fixture inputs and generated outputs are unaffected; no fixture regeneration is needed. - [ ] Full Luna/low agent evaluation suite: approval requested, not yet received. - [ ] Verify the affected interior route on a released CLI. The original report lacks the project and route identity, so this PR does not claim that specific site is fixed.
This commit is contained in:
1 parent
9b1100f25b
commit
7fd91301d6
3 files changed
+160
-38
No files matched your search
@@ -1708,3 +1708,96 @@ test("rejects resized generated routes that rendered Builder chrome", async () =
|
||||
])
|
||||
).rejects.toMatchObject({ code: "SCREENSHOT_NOT_GENERATED_SITE" });
|
||||
});
|
||||
|
||||
test("retries a resized generated route while the refreshed preview settles", async () => {
|
||||
const capturePage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
navigation: { generatedSiteRootPresent: false },
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
navigation: { generatedSiteRootPresent: true },
|
||||
},
|
||||
]);
|
||||
const sleep = vi.fn(async () => undefined);
|
||||
const handlers = createMcpPreviewHandlers({
|
||||
preview: {
|
||||
status: vi.fn(() => ({
|
||||
url: "http://127.0.0.1:5173/",
|
||||
running: true,
|
||||
mode: "iterative" as const,
|
||||
})),
|
||||
startAndWait: vi.fn(),
|
||||
resolveUrl: vi.fn(() => "http://127.0.0.1:5173/account"),
|
||||
},
|
||||
isStale: () => false,
|
||||
createCaptureSession: vi.fn(() => ({
|
||||
capture: vi.fn(),
|
||||
capturePage,
|
||||
close: vi.fn(),
|
||||
})) as never,
|
||||
sleep,
|
||||
});
|
||||
|
||||
await expect(
|
||||
handlers.capturePageScreenshots([
|
||||
{
|
||||
path: "/account",
|
||||
source: "session",
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
])
|
||||
).resolves.toMatchObject([
|
||||
{ navigation: { generatedSiteRootPresent: true } },
|
||||
]);
|
||||
expect(capturePage).toHaveBeenCalledTimes(2);
|
||||
expect(sleep).toHaveBeenCalledWith(1000);
|
||||
});
|
||||
|
||||
test("reports an interior route HTTP failure instead of a wrong-site error", async () => {
|
||||
const handlers = createMcpPreviewHandlers({
|
||||
preview: {
|
||||
status: vi.fn(() => ({
|
||||
url: "http://127.0.0.1:5173/",
|
||||
running: true,
|
||||
mode: "iterative" as const,
|
||||
})),
|
||||
startAndWait: vi.fn(),
|
||||
resolveUrl: vi.fn(() => "http://127.0.0.1:5173/account"),
|
||||
},
|
||||
isStale: () => false,
|
||||
createCaptureSession: vi.fn(() => ({
|
||||
capture: vi.fn(),
|
||||
capturePage: vi.fn(async () => [
|
||||
{
|
||||
navigation: {
|
||||
generatedSiteRootPresent: false,
|
||||
status: 503,
|
||||
documentReadyState: "complete",
|
||||
redirects: [],
|
||||
},
|
||||
},
|
||||
]),
|
||||
close: vi.fn(),
|
||||
})) as never,
|
||||
sleep: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
await expect(
|
||||
handlers.capturePageScreenshots([
|
||||
{
|
||||
path: "/account",
|
||||
source: "session",
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
])
|
||||
).rejects.toMatchObject({
|
||||
code: "SCREENSHOT_ROUTE_HTTP_ERROR",
|
||||
issues: [
|
||||
{ code: "screenshot_route_http_error", constraint: "http_status:503" },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -493,6 +493,19 @@ export const createMcpPreviewHandlers = ({
|
||||
quality: input.quality,
|
||||
scale: input.scale,
|
||||
});
|
||||
const captureWhileRouteSettles = async <Result>(
|
||||
capture: () => Promise<Result>,
|
||||
isGeneratedSite: (result: Result) => boolean,
|
||||
progress?: McpToolProgress
|
||||
) => {
|
||||
let result = await capture();
|
||||
for (let retry = 0; retry < 2 && !isGeneratedSite(result); retry += 1) {
|
||||
progress?.report("tool screenshot waiting for refreshed generated route");
|
||||
await sleep(1000);
|
||||
result = await capture();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const assertGeneratedSiteCapture = (
|
||||
input: McpScreenshotInput,
|
||||
result: Awaited<ReturnType<typeof captureScreenshot>>
|
||||
@@ -502,12 +515,29 @@ export const createMcpPreviewHandlers = ({
|
||||
input.baseUrl === undefined &&
|
||||
result.navigation?.generatedSiteRootPresent === false
|
||||
) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
'Screenshot did not render the generated Webstudio site. Capture the route with screenshot { path: "/" }; do not capture the Builder UI.'
|
||||
),
|
||||
{ code: "SCREENSHOT_NOT_GENERATED_SITE" }
|
||||
);
|
||||
const status = result.navigation.status;
|
||||
const routeFailed = status !== undefined && status >= 400;
|
||||
const code = routeFailed
|
||||
? "SCREENSHOT_ROUTE_HTTP_ERROR"
|
||||
: "SCREENSHOT_NOT_GENERATED_SITE";
|
||||
const message = routeFailed
|
||||
? `The generated route returned HTTP ${status} before a Webstudio page could render. Check the preview server output for the route error.`
|
||||
: "The requested route did not render a generated Webstudio page. Check the route and preview server output; a screenshot of the home route does not verify this page.";
|
||||
throw Object.assign(new Error(message), {
|
||||
code,
|
||||
issues: [
|
||||
{
|
||||
code: routeFailed
|
||||
? "screenshot_route_http_error"
|
||||
: "generated_site_marker_missing",
|
||||
path: [],
|
||||
message,
|
||||
constraint: routeFailed
|
||||
? `http_status:${status}`
|
||||
: "generated_site_identity_present",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -589,25 +619,13 @@ export const createMcpPreviewHandlers = ({
|
||||
return await captureWithSessionReconnect(
|
||||
input,
|
||||
progress,
|
||||
async (captureSession) => {
|
||||
let captureResult =
|
||||
await captureSession.capture(captureOptions);
|
||||
for (
|
||||
let retry = 0;
|
||||
retry < 2 &&
|
||||
captureResult.navigation?.generatedSiteRootPresent ===
|
||||
false;
|
||||
retry += 1
|
||||
) {
|
||||
progress?.report(
|
||||
"tool screenshot waiting for refreshed generated route"
|
||||
);
|
||||
await sleep(1000);
|
||||
captureResult =
|
||||
await captureSession.capture(captureOptions);
|
||||
}
|
||||
return captureResult;
|
||||
}
|
||||
async (captureSession) =>
|
||||
await captureWhileRouteSettles(
|
||||
() => captureSession.capture(captureOptions),
|
||||
(result) =>
|
||||
result.navigation?.generatedSiteRootPresent !== false,
|
||||
progress
|
||||
)
|
||||
);
|
||||
}
|
||||
return await captureScreenshot({
|
||||
@@ -675,18 +693,29 @@ export const createMcpPreviewHandlers = ({
|
||||
return await captureWithSessionReconnect(
|
||||
firstInput,
|
||||
progress,
|
||||
async (captureSession) =>
|
||||
await captureSession.capturePage(
|
||||
inputs.map((input, index) => {
|
||||
const url = urls[index];
|
||||
if (url === undefined) {
|
||||
throw new Error(
|
||||
"Screenshot URL resolution was incomplete."
|
||||
);
|
||||
}
|
||||
return getCaptureOptions(input, url);
|
||||
})
|
||||
)
|
||||
async (captureSession) => {
|
||||
const captureOptions = inputs.map((input, index) => {
|
||||
const url = urls[index];
|
||||
if (url === undefined) {
|
||||
throw new Error(
|
||||
"Screenshot URL resolution was incomplete."
|
||||
);
|
||||
}
|
||||
return getCaptureOptions(input, url);
|
||||
});
|
||||
if (isManagedSessionPreviewCapture(firstInput) === false) {
|
||||
return await captureSession.capturePage(captureOptions);
|
||||
}
|
||||
return await captureWhileRouteSettles(
|
||||
() => captureSession.capturePage(captureOptions),
|
||||
(results) =>
|
||||
results.every(
|
||||
(result) =>
|
||||
result.navigation?.generatedSiteRootPresent !== false
|
||||
),
|
||||
progress
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
timeout,
|
||||
|
||||
@@ -1873,7 +1873,7 @@ const createCliMcpHost = async ({
|
||||
: (status: BuilderStateNamespaceStatus) =>
|
||||
getBuilderStateNamespacesByStatus(snapshot.freshness, status);
|
||||
return createIssueReportRuntime(failureTracker.get(), {
|
||||
projectId,
|
||||
projectId: connection.projectId,
|
||||
...(namespacesByStatus === undefined
|
||||
? {}
|
||||
: {
|
||||
|
||||
Reference in new issue
Block a user