mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-30 08:37:56 +09:00
## Summary Unifies frontend testing around three intentional boundaries: - Vitest Node projects for pure and server tests - Vitest Browser Mode with Playwright/Chromium for DOM and component tests - Playwright Test for Builder end-to-end workflows The Builder no longer uses jsdom. Ordinary E2E workflows use an automatically managed isolated `page` fixture; explicit contexts remain only for simultaneous identities and suite-scoped setup. ## Technical choices - Split Builder tests into one Node project and independent browser projects so each Chromium/Vite process has a bounded workload. - Added shared Browser Mode configuration for the affected SDK React packages. - Preserve the Builder's production top-window bridge ownership; Browser Mode uses explicit test-only installation seams. - Centralized Playwright context options and kept action/navigation timeouts bounded. - Validate Playwright filters before Docker, migrations, browser installation, or Builder builds start. - Preserve the first failed E2E attempt's report, traces, screenshots, and videos when CI retries a shard. - Split static checks, typechecks, CLI tests, core package tests, browser package tests, and Builder projects into parallel CI jobs with five-minute hard ceilings. - Distribute Builder E2E tests across ten isolated shards with two workers; parallel suites create unique per-worker setup data. - Reuse color contrast DOM/canvas samples so the exhaustive theme sweep takes about four seconds instead of timing out at 30 seconds. - The removed cost is jsdom behavior drift, oversized serial check jobs, and unnecessary E2E startup for empty selections. Real browser startup and E2E environment setup remain. ## Todo - [x] Split Builder pure/server tests from browser/component tests. - [x] Migrate Builder DOM tests from jsdom to Vitest Browser Mode. - [x] Migrate affected SDK React DOM tests to the shared Browser Mode setup. - [x] Remove jsdom annotations, dependencies, and compatibility setup. - [x] Replace ordinary manual E2E contexts with the isolated `page` fixture. - [x] Keep explicit contexts only for concurrent identities and `beforeAll` setup. - [x] Preserve setup authentication isolation from workflow pages. - [x] Add bounded Playwright action and navigation timeouts. - [x] Preserve first-attempt E2E failure evidence across CI retry. - [x] Reject empty E2E selections before expensive environment setup. - [x] Parallelize repository checks into bounded CI shards. - [x] Isolate parallel E2E setup data across workers. - [x] Remove the color contrast sweep bottleneck. - [x] Update E2E contributor documentation. - [x] Review product documentation impact; no user-facing product docs are affected. - [x] Run the full repository tests and typechecks. - [x] Run formatting, lint, package-boundary, generated-file, and fixture checks. - [x] Complete final correctness and simplification review. ## Verification - `pnpm --filter @webstudio-is/builder test` - `pnpm -r typecheck` - `pnpm lint` - `pnpm check:package-boundaries` - `pnpm check:generated-api` - `pnpm check:generated-docs` - `pnpm fixtures` - Design-system: 222 tests pass locally. - Builder: all seven Vitest projects pass locally. - The formerly slow E2E shard passes locally in 1m55s. - Full database-backed Builder E2E execution passes in CI. - All CI jobs pass; the slowest completed in 4m58s. - No fixture inputs/outputs or generated files changed. Ref #6270
129 lines
3.6 KiB
TypeScript
129 lines
3.6 KiB
TypeScript
import { dashboardUrl, getProjectIdFromBuilderUrl } from "../test";
|
|
import type { Page } from "@playwright/test";
|
|
import env from "../../app/env/env.server";
|
|
|
|
const loginReadyTimeout = 30_000;
|
|
|
|
export const getWorkerEmail = (
|
|
email: string,
|
|
parallelIndex = process.env.TEST_PARALLEL_INDEX
|
|
) => {
|
|
if (parallelIndex === undefined) {
|
|
return email;
|
|
}
|
|
const separatorIndex = email.lastIndexOf("@");
|
|
return `${email.slice(0, separatorIndex)}+worker-${parallelIndex}${email.slice(separatorIndex)}`;
|
|
};
|
|
|
|
export const loginWithSecret = async ({
|
|
page,
|
|
email,
|
|
devPlan,
|
|
}: {
|
|
page: Page;
|
|
email: string;
|
|
devPlan?: string;
|
|
}) => {
|
|
if (devPlan !== undefined) {
|
|
await page.context().clearCookies();
|
|
}
|
|
|
|
console.info("e2e: opening login");
|
|
await page.goto(`${dashboardUrl}/login`);
|
|
const loginWithSecretButton = page.getByRole("button", {
|
|
name: "Login with Secret",
|
|
});
|
|
let loginState: "login" | "dashboard";
|
|
try {
|
|
loginState = await Promise.any([
|
|
loginWithSecretButton
|
|
.waitFor({ state: "visible", timeout: loginReadyTimeout })
|
|
.then(() => "login" as const),
|
|
page
|
|
.waitForURL(`${dashboardUrl}/dashboard`, {
|
|
timeout: loginReadyTimeout,
|
|
})
|
|
.then(() => "dashboard" as const),
|
|
]);
|
|
} catch (error) {
|
|
const bodyText = await page
|
|
.locator("body")
|
|
.innerText()
|
|
.catch(() => "");
|
|
throw new Error(
|
|
`Expected login page or dashboard at ${page.url()}. Body: ${bodyText}`,
|
|
{ cause: error }
|
|
);
|
|
}
|
|
if (loginState === "dashboard") {
|
|
console.info("e2e: dashboard loaded");
|
|
return;
|
|
}
|
|
await loginWithSecretButton.click();
|
|
if (env.AUTH_SECRET === undefined) {
|
|
throw new Error("AUTH_SECRET must be set for e2e dev login");
|
|
}
|
|
await page.getByPlaceholder("Auth secret").fill(env.AUTH_SECRET);
|
|
await page.getByPlaceholder("Email (optional)").fill(getWorkerEmail(email));
|
|
if (devPlan !== undefined) {
|
|
await page.locator('select[name="devPlan"]').selectOption(devPlan);
|
|
}
|
|
console.info("e2e: submitting dev login");
|
|
await page.getByRole("button", { name: "Login" }).click();
|
|
await page.waitForURL(`${dashboardUrl}/dashboard`);
|
|
console.info("e2e: dashboard loaded");
|
|
};
|
|
|
|
export const createBlankProject = async ({
|
|
page,
|
|
title,
|
|
}: {
|
|
page: Page;
|
|
title: string;
|
|
}) => {
|
|
const createProjectButton = page.getByRole("button", {
|
|
name: /Create a blank project|New project/,
|
|
});
|
|
try {
|
|
await createProjectButton.waitFor({ state: "visible", timeout: 10_000 });
|
|
} catch (error) {
|
|
const bodyText = await page
|
|
.locator("body")
|
|
.innerText()
|
|
.catch(() => "");
|
|
throw new Error(
|
|
`Expected dashboard create-project button at ${page.url()}. Body: ${bodyText}`,
|
|
{ cause: error }
|
|
);
|
|
}
|
|
|
|
console.info("e2e: opening create project dialog");
|
|
await createProjectButton.click();
|
|
await page.getByPlaceholder("My Project").fill(title);
|
|
console.info("e2e: submitting create project");
|
|
await page.getByRole("button", { name: "Create Project" }).click();
|
|
await page.waitForURL(/https:\/\/p-[^.]+\.wstd\.dev:\d+\//);
|
|
console.info(`e2e: created project at ${page.url()}`);
|
|
|
|
const projectId = getProjectIdFromBuilderUrl(page.url());
|
|
if (projectId === undefined) {
|
|
throw new Error(`Expected builder URL, received ${page.url()}`);
|
|
}
|
|
return projectId;
|
|
};
|
|
|
|
export const loginAndCreateBlankProject = async ({
|
|
page,
|
|
email,
|
|
title,
|
|
devPlan,
|
|
}: {
|
|
page: Page;
|
|
email: string;
|
|
title: string;
|
|
devPlan?: string;
|
|
}) => {
|
|
await loginWithSecret({ page, email, devPlan });
|
|
return await createBlankProject({ page, title });
|
|
};
|