mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-24 20:17:07 +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
323 lines
9.4 KiB
TypeScript
323 lines
9.4 KiB
TypeScript
import { createElement } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { hydrateRoot, type Root } from "react-dom/client";
|
|
import { act } from "react-dom/test-utils";
|
|
import { afterEach, expect, test, vi } from "vitest";
|
|
import { Time, __testing__ } from "./time";
|
|
|
|
const { parseDate, formatDate, timeZoneOrDefault } = __testing__;
|
|
|
|
let root: Root | undefined;
|
|
|
|
const mockVisitorTimeZone = (timeZone: string) => {
|
|
vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({
|
|
locale: "de-DE",
|
|
calendar: "gregory",
|
|
numberingSystem: "latn",
|
|
timeZone,
|
|
});
|
|
};
|
|
|
|
afterEach(() => {
|
|
root?.unmount();
|
|
root = undefined;
|
|
vi.restoreAllMocks();
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
test("13-digit Unix timestamp", () => {
|
|
expect(parseDate("1724938577059")).toEqual(
|
|
new Date("2024-08-29T13:36:17.059Z")
|
|
);
|
|
});
|
|
|
|
test("10-digit Unix timestamp ", () => {
|
|
expect(parseDate("1724938577")).toEqual(new Date("2024-08-29T13:36:17.000Z"));
|
|
});
|
|
|
|
test("maximum 32-bit signed integer timestamp", () => {
|
|
expect(parseDate("2147483647")).toEqual(new Date("2038-01-19T03:14:07.000Z"));
|
|
});
|
|
|
|
test("far future date", () => {
|
|
expect(parseDate("9999999999999")).toEqual(
|
|
new Date("2286-11-20T17:46:39.999Z")
|
|
);
|
|
});
|
|
|
|
test("parse ISO date string", () => {
|
|
expect(parseDate("2024-08-29T13:36:17.000Z")).toEqual(
|
|
new Date("2024-08-29T13:36:17.000Z")
|
|
);
|
|
});
|
|
|
|
test("formats ISO date string in UTC to avoid hydration mismatches", () => {
|
|
expect(
|
|
renderToStaticMarkup(
|
|
createElement(Time, { datetime: "2026-05-06T22:00:00.000Z" })
|
|
)
|
|
).toBe('<time dateTime="2026-05-06T22:00:00.000Z">6 May 2026</time>');
|
|
});
|
|
|
|
test("hydrates ISO date string without timezone mismatch warnings", async () => {
|
|
const datetime = "2026-05-06T22:00:00.000Z";
|
|
const ui = createElement(Time, { datetime });
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
|
|
container.innerHTML = renderToStaticMarkup(ui);
|
|
expect(container.textContent).toBe("6 May 2026");
|
|
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
await act(async () => {
|
|
root = hydrateRoot(container, ui);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.textContent).toBe("6 May 2026");
|
|
expect(consoleError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("formats ISO date string in explicit timezone", () => {
|
|
expect(
|
|
renderToStaticMarkup(
|
|
createElement(Time, {
|
|
datetime: "2026-06-08T17:45:00.000+00:00",
|
|
language: "de",
|
|
country: "DE",
|
|
timeStyle: "short",
|
|
dateStyle: "none",
|
|
timeZone: "Europe/Berlin",
|
|
})
|
|
)
|
|
).toBe('<time dateTime="2026-06-08T17:45:00.000+00:00">19:45</time>');
|
|
});
|
|
|
|
test("formats custom template in explicit timezone", () => {
|
|
expect(
|
|
formatDate(
|
|
new Date("2026-06-08T17:45:00.000+00:00"),
|
|
"YYYY-MM-DD HH:mm",
|
|
"de-DE",
|
|
"Europe/Berlin"
|
|
)
|
|
).toBe("2026-06-08 19:45");
|
|
});
|
|
|
|
test("formats custom template with date rollover in explicit timezone", () => {
|
|
expect(
|
|
formatDate(
|
|
new Date("2026-06-08T23:45:00.000+00:00"),
|
|
"YYYY-MM-DD HH:mm",
|
|
"de-DE",
|
|
"Europe/Berlin"
|
|
)
|
|
).toBe("2026-06-09 01:45");
|
|
});
|
|
|
|
test("falls back to UTC for invalid timezone", () => {
|
|
expect(timeZoneOrDefault("Not/A_Timezone")).toBe("UTC");
|
|
});
|
|
|
|
test("trims timezone before validation", () => {
|
|
expect(timeZoneOrDefault(" Europe/Berlin ")).toBe("Europe/Berlin");
|
|
});
|
|
|
|
test("renders UTC fallback for invalid timezone", () => {
|
|
expect(
|
|
renderToStaticMarkup(
|
|
createElement(Time, {
|
|
datetime: "2026-06-08T17:45:00.000+00:00",
|
|
language: "de",
|
|
country: "DE",
|
|
timeStyle: "short",
|
|
dateStyle: "none",
|
|
timeZone: "Not/A_Timezone",
|
|
})
|
|
)
|
|
).toBe('<time dateTime="2026-06-08T17:45:00.000+00:00">17:45</time>');
|
|
});
|
|
|
|
test("hydrates visitor timezone without mismatch and updates after hydration", async () => {
|
|
const datetime = "2026-06-08T17:45:00.000+00:00";
|
|
const ui = createElement(Time, {
|
|
datetime,
|
|
language: "de",
|
|
country: "DE",
|
|
timeStyle: "short",
|
|
dateStyle: "none",
|
|
timeZone: "visitor",
|
|
});
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
|
|
container.innerHTML = renderToStaticMarkup(ui);
|
|
expect(container.textContent).toBe("17:45");
|
|
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
mockVisitorTimeZone("Europe/Berlin");
|
|
await act(async () => {
|
|
root = hydrateRoot(container, ui);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.textContent).toBe("19:45");
|
|
expect(
|
|
consoleError.mock.calls.some((call) =>
|
|
call.some(
|
|
(message) =>
|
|
typeof message === "string" &&
|
|
(message.includes("Text content did not match") ||
|
|
message.includes("Hydration failed"))
|
|
)
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
test("trims visitor timezone mode before hydration update", async () => {
|
|
const datetime = "2026-06-08T17:45:00.000+00:00";
|
|
const ui = createElement(Time, {
|
|
datetime,
|
|
language: "de",
|
|
country: "DE",
|
|
timeStyle: "short",
|
|
dateStyle: "none",
|
|
timeZone: " visitor ",
|
|
});
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
|
|
container.innerHTML = renderToStaticMarkup(ui);
|
|
expect(container.textContent).toBe("17:45");
|
|
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
mockVisitorTimeZone("Europe/Berlin");
|
|
await act(async () => {
|
|
root = hydrateRoot(container, ui);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.textContent).toBe("19:45");
|
|
expect(
|
|
consoleError.mock.calls.some((call) =>
|
|
call.some(
|
|
(message) =>
|
|
typeof message === "string" &&
|
|
(message.includes("Text content did not match") ||
|
|
message.includes("Hydration failed"))
|
|
)
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
test.skip("parse short date", () => {
|
|
expect(parseDate("2024.10")).toEqual(new Date("2024-10-01T00:00:00.000Z"));
|
|
expect(parseDate("2024/10")).toEqual(new Date("2024-10-01T00:00:00.000Z"));
|
|
expect(parseDate("2024-10")).toEqual(new Date("2024-10-01T00:00:00.000Z"));
|
|
expect(parseDate("2024")).toEqual(new Date("2024-01-01T00:00:00.000Z"));
|
|
});
|
|
|
|
test("empty string", () => {
|
|
expect(parseDate("")).toEqual(undefined);
|
|
});
|
|
|
|
test("invalid date string", () => {
|
|
expect(parseDate("whatever that is")).toEqual(undefined);
|
|
});
|
|
|
|
test("formatDate with full template", () => {
|
|
const date = new Date("2025-10-31T14:05:09Z");
|
|
expect(formatDate(date, "YYYY-MM-DD HH:mm:ss")).toBe("2025-10-31 14:05:09");
|
|
});
|
|
|
|
test("formatDate with short date", () => {
|
|
const date = new Date("2025-10-31T14:05:09Z");
|
|
expect(formatDate(date, "DD/MM/YY")).toBe("31/10/25");
|
|
});
|
|
|
|
test("formatDate with time only", () => {
|
|
const date = new Date("2025-10-31T14:05:09Z");
|
|
expect(formatDate(date, "H:m:s")).toBe("14:5:9");
|
|
});
|
|
|
|
test("formatDate with padded values", () => {
|
|
const date = new Date("2025-01-05T08:03:07Z");
|
|
expect(formatDate(date, "YYYY-MM-DD HH:mm:ss")).toBe("2025-01-05 08:03:07");
|
|
});
|
|
|
|
test("formatDate with unpadded values", () => {
|
|
const date = new Date("2025-01-05T08:03:07Z");
|
|
expect(formatDate(date, "YYYY-M-D H:m:s")).toBe("2025-1-5 8:3:7");
|
|
});
|
|
|
|
test("formatDate with year only", () => {
|
|
const date = new Date("2025-10-31T14:05:09Z");
|
|
expect(formatDate(date, "YYYY")).toBe("2025");
|
|
});
|
|
|
|
test("formatDate with custom separator", () => {
|
|
const date = new Date("2025-10-31T14:05:09Z");
|
|
expect(formatDate(date, "DD.MM.YYYY")).toBe("31.10.2025");
|
|
});
|
|
|
|
test("formatDate with full month name in English", () => {
|
|
const date = new Date("2025-10-31T14:05:09Z");
|
|
expect(formatDate(date, "MMMM D, YYYY", "en-US")).toBe("October 31, 2025");
|
|
});
|
|
|
|
test("formatDate with short month name in English", () => {
|
|
const date = new Date("2025-10-31T14:05:09Z");
|
|
expect(formatDate(date, "MMM D, YYYY", "en-US")).toBe("Oct 31, 2025");
|
|
});
|
|
|
|
test("formatDate with full day name in English", () => {
|
|
const date = new Date("2025-10-31T14:05:09Z");
|
|
expect(formatDate(date, "DDDD, MMMM D, YYYY", "en-US")).toBe(
|
|
"Friday, October 31, 2025"
|
|
);
|
|
});
|
|
|
|
test("formatDate with short day name in English", () => {
|
|
const date = new Date("2025-10-31T14:05:09Z");
|
|
expect(formatDate(date, "DDD, MMM D", "en-US")).toBe("Fri, Oct 31");
|
|
});
|
|
|
|
test("formatDate with full month name in German", () => {
|
|
const date = new Date("2025-10-31T14:05:09Z");
|
|
expect(formatDate(date, "D. MMMM YYYY", "de-DE")).toBe("31. Oktober 2025");
|
|
});
|
|
|
|
test("formatDate with full day name in German", () => {
|
|
const date = new Date("2025-10-31T14:05:09Z");
|
|
expect(formatDate(date, "DDDD, D. MMMM YYYY", "de-DE")).toBe(
|
|
"Freitag, 31. Oktober 2025"
|
|
);
|
|
});
|
|
|
|
test("formatDate with short day and month in Spanish", () => {
|
|
const date = new Date("2025-10-31T14:05:09Z");
|
|
expect(formatDate(date, "DDD, D MMM YYYY", "es-ES")).toBe("vie, 31 oct 2025");
|
|
});
|
|
|
|
test("formatDate with full day and month in French", () => {
|
|
const date = new Date("2025-10-31T14:05:09Z");
|
|
expect(formatDate(date, "DDDD D MMMM YYYY", "fr-FR")).toBe(
|
|
"vendredi 31 octobre 2025"
|
|
);
|
|
});
|
|
|
|
test("formatDate with mixed tokens", () => {
|
|
const date = new Date("2025-01-15T09:30:45Z");
|
|
expect(formatDate(date, "DDDD, MMMM D, YYYY at HH:mm", "en-US")).toBe(
|
|
"Wednesday, January 15, 2025 at 09:30"
|
|
);
|
|
});
|
|
|
|
test("formatDate preserves non-token text", () => {
|
|
const date = new Date("2025-10-31T14:05:09Z");
|
|
expect(formatDate(date, "Today is DDDD", "en-US")).toBe("Today is Friday");
|
|
});
|