Files
webstudio/apps/builder/e2e/flows/dashboard.ts
Oleg Isonen 8718feb465 test: simplify browser testing infrastructure (#6271)
## 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
2026-08-31 17:17:28 +02:00

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 });
};