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:
Oleg Isonen authored and GitHub committed 2026-09-28 11:16:34 +02:00
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" },
],
});
});
+66 -37
View File
@@ -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,
+1 -1
View File
@@ -1873,7 +1873,7 @@ const createCliMcpHost = async ({
: (status: BuilderStateNamespaceStatus) =>
getBuilderStateNamespacesByStatus(snapshot.freshness, status);
return createIssueReportRuntime(failureTracker.get(), {
projectId,
projectId: connection.projectId,
...(namespacesByStatus === undefined
? {}
: {