308aada5f9
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
404 lines
17 KiB
TypeScript
404 lines
17 KiB
TypeScript
import { type Page } from "@playwright/test";
|
|
import path from "node:path";
|
|
import { test, expect } from "../../fixtures/test-base";
|
|
import type { SeedData } from "../../fixtures/test-base";
|
|
import type { ApiClient } from "../../helpers/api-client";
|
|
import type { BackendContext } from "../../fixtures/backend";
|
|
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
|
|
import { GitHelper, makeGitEnv, createStandardProfile } from "../../helpers/git-helper";
|
|
import { routeMainWebSocketWithPreviewFeedbackCreateFailure } from "../../helpers/ws-drop";
|
|
import { SessionPage } from "../../pages/session-page";
|
|
|
|
const SVG_ASSET =
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><rect width="24" height="24" fill="#60a5fa"/></svg>';
|
|
|
|
function mobilePreviewHtml(assetDirectory: string): string {
|
|
return `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<link rel="stylesheet" href="./${assetDirectory}/preview.css">
|
|
</head>
|
|
<body>
|
|
<h1>Mobile native preview</h1>
|
|
<p id="css-status">CSS asset pending</p>
|
|
<img id="asset-image" src="./${assetDirectory}/logo.svg" alt="asset">
|
|
<button id="capture-button">Review order</button>
|
|
<section id="capture-card">Generated order summary</section>
|
|
<p id="native-status">Browser script pending</p>
|
|
<script src="./${assetDirectory}/preview.js"></script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function mobilePreviewScript(entryName: string): string {
|
|
return `(() => {
|
|
const image = document.querySelector("#asset-image");
|
|
const status = document.querySelector("#native-status");
|
|
const render = () => {
|
|
status.textContent = [
|
|
typeof ResizeObserver === "function" ? "api:available" : "api:missing",
|
|
image.complete && image.naturalWidth > 0 ? "image:loaded" : "image:pending",
|
|
location.pathname.endsWith("/${entryName}") ? "path:entry" : "path:wrong",
|
|
].join(" | ");
|
|
};
|
|
image.addEventListener("load", render);
|
|
render();
|
|
})();`;
|
|
}
|
|
|
|
async function setupMobileHtmlPreviewTest({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}: {
|
|
testPage: Page;
|
|
apiClient: ApiClient;
|
|
seedData: SeedData;
|
|
backend: BackendContext;
|
|
}): Promise<{ session: SessionPage; filePath: string; taskId: string; sessionId: string }> {
|
|
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const filePath = `mobile-preview-${suffix}.html`;
|
|
const assetDirectory = `mobile-preview-assets-${suffix}`;
|
|
const git = new GitHelper(
|
|
path.join(backend.tmpDir, "repos", "e2e-repo"),
|
|
makeGitEnv(backend.tmpDir),
|
|
);
|
|
git.createFile(filePath, mobilePreviewHtml(assetDirectory));
|
|
git.createFile(`${assetDirectory}/preview.css`, "#css-status { font-weight: 700; }");
|
|
git.createFile(`${assetDirectory}/preview.js`, mobilePreviewScript(filePath));
|
|
git.createFile(`${assetDirectory}/logo.svg`, SVG_ASSET);
|
|
git.stageAll();
|
|
git.commit(`add ${filePath}`);
|
|
|
|
const profile = await createStandardProfile(apiClient, `mobile-html-${Date.now()}`);
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Mobile HTML Preview",
|
|
profile.id,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await session.waitForChatIdle({ timeout: 45_000 });
|
|
if (!task.session_id) throw new Error("Mobile HTML preview task has no session");
|
|
return { session, filePath, taskId: task.id, sessionId: task.session_id };
|
|
}
|
|
|
|
test.describe("Mobile HTML preview", () => {
|
|
test.describe.configure({ retries: 1, timeout: 120_000 });
|
|
|
|
test("renders native scripts and relative assets in the focused viewer", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
prCapture,
|
|
}) => {
|
|
const { filePath } = await setupMobileHtmlPreviewTest({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
});
|
|
|
|
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
|
|
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
|
|
await expect(fileNode).toBeVisible({ timeout: 15_000 });
|
|
await fileNode.tap();
|
|
|
|
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
|
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
|
const previewToggle = viewer.getByTestId("html-preview-toggle");
|
|
await expect(previewToggle).toBeVisible();
|
|
const toggleBox = await previewToggle.boundingBox();
|
|
expect(toggleBox?.width).toBeGreaterThanOrEqual(44);
|
|
expect(toggleBox?.height).toBeGreaterThanOrEqual(44);
|
|
await expect(previewToggle).toHaveAttribute("title", /Previewing runs workspace code/);
|
|
|
|
await previewToggle.tap();
|
|
const preview = viewer.getByTestId("html-preview");
|
|
await expect(preview).toBeVisible();
|
|
await expect(preview.getByTestId("html-preview-trust-warning")).toBeVisible();
|
|
const frame = preview.frameLocator("iframe");
|
|
await expect(preview.locator("iframe")).toHaveAttribute("src", /\/port-proxy\/.*\/\d+\//);
|
|
await expect(frame.locator("h1")).toHaveText("Mobile native preview", { timeout: 15_000 });
|
|
await expect(frame.locator("#native-status")).toHaveText(
|
|
"api:available | image:loaded | path:entry",
|
|
);
|
|
await expect(frame.locator("#css-status")).toHaveCSS("font-weight", "700");
|
|
await expect(frame.locator("#asset-image")).toBeVisible();
|
|
await assertNoDocumentHorizontalOverflow(testPage, "mobile HTML preview");
|
|
|
|
await prCapture.screenshot("html-preview-mobile", {
|
|
caption: "Native HTML preview in the focused mobile viewer",
|
|
});
|
|
|
|
await preview.getByRole("button", { name: "Show code" }).tap();
|
|
await expect(viewer.locator(".cm-editor:visible")).toBeVisible();
|
|
await expect(viewer.locator(".cm-content")).toContainText("Mobile native preview");
|
|
await expect(preview).toBeHidden();
|
|
});
|
|
|
|
test("captures touch feedback in the native HTML preview drawer", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
const createFailure = await routeMainWebSocketWithPreviewFeedbackCreateFailure(testPage);
|
|
const { filePath, session, taskId, sessionId } = await setupMobileHtmlPreviewTest({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
});
|
|
|
|
await testPage.getByRole("button", { name: "Files" }).tap();
|
|
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
|
|
await expect(fileNode).toBeVisible({ timeout: 15_000 });
|
|
await fileNode.tap();
|
|
|
|
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
|
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
|
await viewer.getByTestId("html-preview-toggle").tap();
|
|
const preview = viewer.getByTestId("html-preview");
|
|
const frame = preview.frameLocator("iframe");
|
|
await expect(frame.locator("#capture-button")).toBeVisible({ timeout: 15_000 });
|
|
|
|
const trigger = preview.getByTestId("preview-feedback-trigger");
|
|
const triggerBox = await trigger.boundingBox();
|
|
expect(triggerBox?.width).toBeGreaterThanOrEqual(44);
|
|
expect(triggerBox?.height).toBeGreaterThanOrEqual(44);
|
|
await trigger.tap();
|
|
|
|
const drawer = testPage.getByTestId("preview-feedback-drawer");
|
|
await expect(drawer).toBeVisible();
|
|
const viewport = testPage.viewportSize();
|
|
expect(viewport).not.toBeNull();
|
|
await expect
|
|
.poll(async () => {
|
|
const box = await drawer.boundingBox();
|
|
return box ? box.y + box.height : Number.POSITIVE_INFINITY;
|
|
})
|
|
.toBeLessThanOrEqual(viewport!.height);
|
|
const drawerBox = await drawer.boundingBox();
|
|
expect(drawerBox).not.toBeNull();
|
|
expect(drawerBox!.x).toBeGreaterThanOrEqual(0);
|
|
expect(drawerBox!.x + drawerBox!.width).toBeLessThanOrEqual(viewport!.width);
|
|
expect(drawerBox!.y + drawerBox!.height).toBeLessThanOrEqual(viewport!.height);
|
|
|
|
const captureChoices = [
|
|
{ name: "Select text", label: "Text" },
|
|
{ name: "Select element", label: "Element" },
|
|
{ name: "Select screenshot region", label: "Screenshot" },
|
|
];
|
|
const captureChoiceBoxes = [];
|
|
for (const choice of captureChoices) {
|
|
const button = drawer.getByRole("button", { name: choice.name, exact: true });
|
|
await expect(button).toHaveText(choice.label);
|
|
captureChoiceBoxes.push(await button.boundingBox());
|
|
}
|
|
expect(new Set(captureChoiceBoxes.map((box) => Math.round(box?.y ?? -1))).size).toBe(1);
|
|
|
|
const elementChoice = drawer.getByRole("button", { name: "Select element", exact: true });
|
|
const elementChoiceBox = await elementChoice.boundingBox();
|
|
expect(elementChoiceBox?.height).toBeGreaterThanOrEqual(44);
|
|
await elementChoice.tap();
|
|
await frame.locator("#capture-button").tap();
|
|
|
|
const elementDraft = testPage.getByTestId("preview-feedback-draft");
|
|
await expect(elementDraft).toContainText("<button#capture-button>");
|
|
await elementDraft
|
|
.getByRole("textbox", { name: "Comment on selection" })
|
|
.fill("Increase this touch target contrast");
|
|
await elementDraft.getByRole("button", { name: "Save feedback" }).tap();
|
|
await expect(elementDraft).toBeHidden({ timeout: 15_000 });
|
|
|
|
await drawer.getByRole("button", { name: "Select screenshot region", exact: true }).tap();
|
|
const screenshotTarget = frame.locator("#capture-card");
|
|
const region = await screenshotTarget.evaluate((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
|
|
});
|
|
const pointer = { pointerId: 7, pointerType: "touch", isPrimary: true, buttons: 1 };
|
|
await screenshotTarget.dispatchEvent("pointerdown", {
|
|
...pointer,
|
|
clientX: region.left + 2,
|
|
clientY: region.top + 2,
|
|
});
|
|
await screenshotTarget.dispatchEvent("pointermove", {
|
|
...pointer,
|
|
clientX: region.right - 2,
|
|
clientY: region.bottom - 2,
|
|
});
|
|
await screenshotTarget.dispatchEvent("pointerup", {
|
|
...pointer,
|
|
buttons: 0,
|
|
clientX: region.right - 2,
|
|
clientY: region.bottom - 2,
|
|
});
|
|
|
|
const screenshotDraft = testPage.getByTestId("preview-feedback-draft");
|
|
await expect(screenshotDraft.getByRole("img", { name: "Screenshot preview" })).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
await screenshotDraft
|
|
.getByRole("textbox", { name: "Comment on selection" })
|
|
.fill("Reduce the vertical space in this card");
|
|
createFailure.failNextCreate();
|
|
await screenshotDraft.getByRole("button", { name: "Save feedback" }).tap();
|
|
await expect.poll(() => createFailure.failedCount()).toBe(1);
|
|
await expect(screenshotDraft).toBeVisible();
|
|
await expect(
|
|
screenshotDraft.getByRole("textbox", { name: "Comment on selection" }),
|
|
).toHaveValue("Reduce the vertical space in this card");
|
|
await expect(screenshotDraft.getByRole("img", { name: "Screenshot preview" })).toBeVisible();
|
|
await expect(testPage.getByTestId("preview-feedback-drawer").getByRole("alert")).toContainText(
|
|
"feedback could not be saved",
|
|
);
|
|
await screenshotDraft.getByRole("button", { name: "Save feedback" }).tap();
|
|
await expect(screenshotDraft).toBeHidden({ timeout: 15_000 });
|
|
await expect(preview.getByTestId("preview-feedback-trigger")).toContainText("2");
|
|
await assertNoDocumentHorizontalOverflow(testPage, "mobile preview feedback drawer");
|
|
|
|
await testPage.keyboard.press("Escape");
|
|
await expect(drawer).toBeHidden();
|
|
await preview.getByRole("button", { name: "Show code" }).tap();
|
|
await expect(viewer.locator(".cm-content")).toContainText("Generated order summary");
|
|
await expect(preview).toBeHidden();
|
|
|
|
await testPage.getByRole("navigation").getByRole("button", { name: "Chat", exact: true }).tap();
|
|
await apiClient.seedTaskSession(taskId, {
|
|
state: "COMPLETED",
|
|
sessionId,
|
|
completedAt: new Date().toISOString(),
|
|
});
|
|
await testPage.reload();
|
|
await session.waitForLoad();
|
|
await testPage.getByRole("navigation").getByRole("button", { name: "Chat", exact: true }).tap();
|
|
await expect(testPage.getByTestId("completed-session-banner")).toBeVisible({ timeout: 15_000 });
|
|
|
|
const fallbackTrigger = testPage.getByTestId("preview-feedback-collection-trigger");
|
|
await expect(fallbackTrigger).toBeVisible();
|
|
const fallbackTriggerBox = await fallbackTrigger.boundingBox();
|
|
expect(fallbackTriggerBox?.width).toBeGreaterThanOrEqual(44);
|
|
expect(fallbackTriggerBox?.height).toBeGreaterThanOrEqual(44);
|
|
const userMessageCount = await session.activeChat().getByTestId("user-message-bubble").count();
|
|
await fallbackTrigger.tap();
|
|
|
|
const collectionBody = testPage.getByTestId("preview-feedback-collection-body");
|
|
const collectionDrawer = testPage.getByRole("dialog").filter({ has: collectionBody });
|
|
await expect(collectionDrawer).toBeVisible();
|
|
const collectionViewport = testPage.viewportSize();
|
|
expect(collectionViewport).not.toBeNull();
|
|
await expect
|
|
.poll(async () => {
|
|
const box = await collectionDrawer.boundingBox();
|
|
return box ? box.y + box.height : Number.POSITIVE_INFINITY;
|
|
})
|
|
.toBeLessThanOrEqual(collectionViewport!.height);
|
|
const collectionBox = await collectionDrawer.boundingBox();
|
|
expect(collectionBox).not.toBeNull();
|
|
expect(collectionBox!.y + collectionBox!.height).toBeLessThanOrEqual(
|
|
collectionViewport!.height,
|
|
);
|
|
expect(
|
|
parseFloat(
|
|
await collectionBody.evaluate((element) => getComputedStyle(element).paddingBottom),
|
|
),
|
|
).toBeGreaterThanOrEqual(16);
|
|
const collectionItems = collectionBody.getByTestId("preview-feedback-item");
|
|
await expect(collectionItems).toHaveCount(2);
|
|
await expect(
|
|
collectionItems.nth(1).getByRole("img", { name: "Screenshot preview" }),
|
|
).toBeVisible();
|
|
|
|
const firstItem = collectionItems.nth(0);
|
|
await firstItem.getByRole("button", { name: "Edit feedback" }).tap();
|
|
await firstItem
|
|
.getByRole("textbox", { name: "Edit comment" })
|
|
.fill("Keep this touch action clear");
|
|
await firstItem.getByRole("button", { name: "Save changes" }).tap();
|
|
await expect(firstItem).toContainText("Keep this touch action clear");
|
|
|
|
await collectionItems.nth(1).getByRole("button", { name: "Delete feedback" }).tap();
|
|
await expect(collectionItems).toHaveCount(1);
|
|
expect(await session.activeChat().getByTestId("user-message-bubble").count()).toBe(
|
|
userMessageCount,
|
|
);
|
|
await assertNoDocumentHorizontalOverflow(testPage, "mobile task feedback collection");
|
|
|
|
await testPage.keyboard.press("Escape");
|
|
await expect(collectionDrawer).toBeHidden();
|
|
await expect
|
|
.poll(() =>
|
|
testPage.evaluate(() => document.activeElement?.getAttribute("data-testid") ?? ""),
|
|
)
|
|
.toBe("preview-feedback-collection-trigger");
|
|
|
|
await testPage.reload();
|
|
await expect(
|
|
testPage.getByRole("navigation").getByRole("button", { name: "Chat", exact: true }),
|
|
).toBeVisible();
|
|
await testPage.getByRole("navigation").getByRole("button", { name: "Chat", exact: true }).tap();
|
|
const restoredTrigger = testPage.getByTestId("preview-feedback-collection-trigger");
|
|
await expect(restoredTrigger).toContainText("1");
|
|
await restoredTrigger.tap();
|
|
await expect(testPage.getByTestId("preview-feedback-collection-body")).toContainText(
|
|
"Keep this touch action clear",
|
|
);
|
|
});
|
|
|
|
test("recovers from a failed publish without losing the source viewer", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
const { filePath } = await setupMobileHtmlPreviewTest({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
});
|
|
|
|
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
|
|
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
|
|
await expect(fileNode).toBeVisible({ timeout: 15_000 });
|
|
await fileNode.tap();
|
|
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
|
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
|
|
|
await testPage.route("**/api/v1/task-sessions/*/html-previews", async (route) => {
|
|
await route.fulfill({
|
|
status: 503,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "agentctl unavailable" }),
|
|
});
|
|
});
|
|
await viewer.getByTestId("html-preview-toggle").tap();
|
|
const preview = viewer.getByTestId("html-preview");
|
|
await expect(preview.getByTestId("html-preview-error")).toBeVisible({ timeout: 10_000 });
|
|
await expect(preview.getByTestId("html-preview-error")).toContainText(
|
|
"HTML preview session is not available",
|
|
);
|
|
await testPage.unroute("**/api/v1/task-sessions/*/html-previews");
|
|
|
|
await preview.getByRole("button", { name: "Retry HTML preview" }).tap();
|
|
await expect(preview.frameLocator("iframe").locator("h1")).toHaveText("Mobile native preview", {
|
|
timeout: 15_000,
|
|
});
|
|
await preview.getByRole("button", { name: "Show code" }).tap();
|
|
await expect(viewer.locator(".cm-content")).toContainText("Mobile native preview");
|
|
});
|
|
});
|