Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions e2e/app-locale-routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ test.describe("app/[lang] public routes (non-default locales)", () => {
);
});

test("ar home sets rtl document direction from proxy locale header", async ({
page,
}) => {
await page.goto("/ar");
await expect(page.locator("html")).toHaveAttribute("lang", "ar");
await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
});

test("de privacy is indexable with locale canonical", async ({ page }) => {
await page.goto("/de/privacy");
await expect(page.locator('meta[name="robots"]')).toHaveCount(0);
Expand Down
8 changes: 8 additions & 0 deletions e2e/helpers/dismissCookieBanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,11 @@ export async function dismissCookieBannerIfVisible(page: Page): Promise<void> {
.waitFor({ state: "hidden", timeout: 15_000 })
.catch(() => undefined);
}

/** Footer privacy link (avoids cookie-banner duplicate when banner is still open). */
export async function clickFooterPrivacyPolicyLink(page: Page): Promise<void> {
await page
.getByRole("contentinfo")
.getByRole("link", { name: /privacy policy/i })
.click();
}
103 changes: 103 additions & 0 deletions e2e/helpers/landingWebGLCanvases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { expect, type Page } from "@playwright/test";

export type WebGLCanvasState = {
width: number;
height: number;
contextLost: boolean;
};

/** Decorative landing canvases are R3F WebGL (not 2d/metrics). */
export async function readLandingWebGLCanvasStates(
page: Page,
): Promise<WebGLCanvasState[]> {
return page.evaluate(() => {
const states: WebGLCanvasState[] = [];

for (const canvas of document.querySelectorAll("canvas")) {
if (!(canvas instanceof HTMLCanvasElement)) {
continue;
}

const context = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
if (!context) {
continue;
}

states.push({
width: canvas.width,
height: canvas.height,
contextLost: context.isContextLost(),
});
}

return states;
});
}

const isActiveLandingCanvas = (state: WebGLCanvasState) =>
!state.contextLost && state.width >= 64 && state.height >= 64;

export async function countActiveLandingWebGLCanvases(
page: Page,
): Promise<number> {
const states = await readLandingWebGLCanvasStates(page);
return states.filter(isActiveLandingCanvas).length;
}

/** Home mounts two decorative models (hero tree + Python section). */
export async function waitForActiveLandingWebGLCanvases(
page: Page,
expectedMin = 2,
timeoutMs = 20_000,
): Promise<WebGLCanvasState[]> {
await expect
.poll(async () => countActiveLandingWebGLCanvases(page), {
timeout: timeoutMs,
})
.toBeGreaterThanOrEqual(expectedMin);

const states = await readLandingWebGLCanvasStates(page);
return states.filter(isActiveLandingCanvas);
}

export function collectWebGlContextLostMessages(page: Page): string[] {
const messages: string[] = [];
page.on("console", (message) => {
const text = message.text();
if (/webglrenderer:\s*context lost/i.test(text)) {
messages.push(text);
}
});
return messages;
}

const MIN_LANDING_CANVAS_DIMENSION_PX = 64;

/** Forces WEBGL_lose_context on active landing-sized WebGL canvases (recovery e2e). */
export async function forceLoseActiveLandingWebGLContexts(
page: Page,
): Promise<number> {
return page.evaluate((minDimension) => {
let lost = 0;
for (const canvas of document.querySelectorAll("canvas")) {
if (!(canvas instanceof HTMLCanvasElement)) {
continue;
}
if (canvas.width < minDimension || canvas.height < minDimension) {
continue;
}

const context = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
if (!context || context.isContextLost()) {
continue;
}

const extension = context.getExtension("WEBGL_lose_context");
if (extension) {
extension.loseContext();
lost += 1;
}
}
return lost;
}, MIN_LANDING_CANVAS_DIMENSION_PX);
}
79 changes: 79 additions & 0 deletions e2e/helpers/playgroundMonacoEditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { expect, type Page } from "@playwright/test";

/** Playground code panel uses Monaco (desktop split layout and mobile code view). */
export async function waitForPlaygroundMonacoEditor(
page: Page,
timeoutMs = 30_000,
): Promise<void> {
const editorLines = page.locator(".monaco-editor .view-lines").first();
await expect(editorLines).toBeVisible({ timeout: timeoutMs });
}

export function collectMonacoRuntimeErrors(page: Page): string[] {
const messages: string[] = [];

page.on("console", (message) => {
const type = message.type();
if (type !== "error" && type !== "warning") {
return;
}

const text = message.text();
if (
/instantiationservice has been disposed/i.test(text) ||
/reading 'domnode'/i.test(text) ||
/renderText/i.test(text)
) {
messages.push(text);
}
});

page.on("pageerror", (error) => {
const text = error.message;
if (
/instantiationservice has been disposed/i.test(text) ||
/reading 'domnode'/i.test(text) ||
/renderText/i.test(text)
) {
messages.push(text);
}
});

return messages;
}

export function collectPythonRunnerRuntimeErrors(page: Page): string[] {
const messages: string[] = [];

const maybeCollect = (text: string) => {
if (
/pythonrunner has been disposed/i.test(text) ||
/pythonrunner init superseded/i.test(text) ||
/pythonrunner released/i.test(text) ||
/worker crashed/i.test(text)
) {
messages.push(text);
}
};

page.on("console", (message) => {
if (message.type() === "error") {
maybeCollect(message.text());
}
});

page.on("pageerror", (error) => {
maybeCollect(error.message);
});

return messages;
}

/** Brand link back to marketing home. */
export async function clickAppBarHomeLink(page: Page): Promise<void> {
await page
.getByRole("banner")
.getByRole("link", { name: /dstruct/i })
.first()
.click();

Check failure on line 78 in e2e/helpers/playgroundMonacoEditor.ts

View workflow job for this annotation

GitHub Actions / e2e

[chromium] › e2e/playground-monaco-nav.spec.ts:17:3 › playground runtime navigation › remounts Monaco after home and playground navigations

2) [chromium] › e2e/playground-monaco-nav.spec.ts:17:3 › playground runtime navigation › remounts Monaco after home and playground navigations Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: locator.click: Test timeout of 120000ms exceeded. Call log: - waiting for getByRole('banner').getByRole('link', { name: /dstruct/i }).first() - locator resolved to <a href="/" class="MuiTypography-root MuiTypography-inherit MuiLink-root MuiLink-underlineAlways css-1ckufiw">…</a> - attempting click action - waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - <h6 class="MuiTypography-root MuiTypography-h6 MuiTypography-noWrap css-16q6e9j">dStruct</h6> from <div class="MuiBox-root css-16ly5um">…</div> subtree intercepts pointer events - retrying click action - waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 100ms 176 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 500ms at helpers/playgroundMonacoEditor.ts:78 76 | .getByRole("link", { name: /dstruct/i }) 77 | .first() > 78 | .click(); | ^ 79 | } 80 | at clickAppBarHomeLink (/home/runner/work/dStruct/dStruct/e2e/helpers/playgroundMonacoEditor.ts:78:6) at /home/runner/work/dStruct/dStruct/e2e/playground-monaco-nav.spec.ts:38:13

Check failure on line 78 in e2e/helpers/playgroundMonacoEditor.ts

View workflow job for this annotation

GitHub Actions / e2e

[chromium] › e2e/playground-monaco-nav.spec.ts:55:3 › playground runtime navigation › releases Pyodide worker after leaving playground with Python selected

1) [chromium] › e2e/playground-monaco-nav.spec.ts:55:3 › playground runtime navigation › releases Pyodide worker after leaving playground with Python selected Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: locator.click: Test timeout of 120000ms exceeded. Call log: - waiting for getByRole('banner').getByRole('link', { name: /dstruct/i }).first() - locator resolved to <a href="/" class="MuiTypography-root MuiTypography-inherit MuiLink-root MuiLink-underlineAlways css-1ckufiw">…</a> - attempting click action - waiting for element to be visible, enabled and stable - element is not stable - retrying click action - waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 100ms 224 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 500ms at helpers/playgroundMonacoEditor.ts:78 76 | .getByRole("link", { name: /dstruct/i }) 77 | .first() > 78 | .click(); | ^ 79 | } 80 | at clickAppBarHomeLink (/home/runner/work/dStruct/dStruct/e2e/helpers/playgroundMonacoEditor.ts:78:6) at /home/runner/work/dStruct/dStruct/e2e/playground-monaco-nav.spec.ts:69:11

Check failure on line 78 in e2e/helpers/playgroundMonacoEditor.ts

View workflow job for this annotation

GitHub Actions / e2e

[chromium] › e2e/playground-monaco-nav.spec.ts:55:3 › playground runtime navigation › releases Pyodide worker after leaving playground with Python selected

1) [chromium] › e2e/playground-monaco-nav.spec.ts:55:3 › playground runtime navigation › releases Pyodide worker after leaving playground with Python selected Error: locator.click: Test timeout of 120000ms exceeded. Call log: - waiting for getByRole('banner').getByRole('link', { name: /dstruct/i }).first() - locator resolved to <a href="/" class="MuiTypography-root MuiTypography-inherit MuiLink-root MuiLink-underlineAlways css-1ckufiw">…</a> - attempting click action - waiting for element to be visible, enabled and stable - element is not stable - retrying click action - waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 100ms 223 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 500ms at helpers/playgroundMonacoEditor.ts:78 76 | .getByRole("link", { name: /dstruct/i }) 77 | .first() > 78 | .click(); | ^ 79 | } 80 | at clickAppBarHomeLink (/home/runner/work/dStruct/dStruct/e2e/helpers/playgroundMonacoEditor.ts:78:6) at /home/runner/work/dStruct/dStruct/e2e/playground-monaco-nav.spec.ts:69:11
}
42 changes: 42 additions & 0 deletions e2e/home-hero-preview-nav.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { instant } from "@next/playwright";
import { expect, test } from "@playwright/test";

import {
clickFooterPrivacyPolicyLink,
dismissCookieBannerIfVisible,
} from "./helpers/dismissCookieBanner";
import { clickAppBarHomeLink } from "./helpers/playgroundMonacoEditor";

test.describe("home hero preview instant navigation", () => {
test.describe.configure({ mode: "serial" });

test("keeps playback controls after instant nav away and back", async ({
page,
}) => {
test.setTimeout(90_000);

await page.goto("/");
await dismissCookieBannerIfVisible(page);

const previewHeading = page.getByRole("heading", {
name: "Time-travel playback",
exact: true,
});
await expect(previewHeading).toBeVisible();
const playButton = page.getByRole("button", { name: "Play", exact: true });
await expect(playButton).toBeEnabled({ timeout: 30_000 });

await instant(page, async () => {
await clickFooterPrivacyPolicyLink(page);
await page.waitForURL((url) => url.pathname === "/privacy");
});

await instant(page, async () => {
await clickAppBarHomeLink(page);
await page.waitForURL((url) => url.pathname === "/");
});

await expect(previewHeading).toBeVisible();
await expect(playButton).toBeEnabled({ timeout: 30_000 });
});
});
56 changes: 56 additions & 0 deletions e2e/home-webgl-nav.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { expect, test } from "@playwright/test";

import {
clickFooterPrivacyPolicyLink,
dismissCookieBannerIfVisible,
} from "./helpers/dismissCookieBanner";
import {
collectWebGlContextLostMessages,
forceLoseActiveLandingWebGLContexts,
waitForActiveLandingWebGLCanvases,
} from "./helpers/landingWebGLCanvases";

test.describe("home landing WebGL canvases", () => {
test.describe.configure({ mode: "serial" });

test("keep active WebGL contexts after client navigation away and back", async ({
page,
}) => {
const contextLostMessages = collectWebGlContextLostMessages(page);

await page.goto("/");
await dismissCookieBannerIfVisible(page);
await waitForActiveLandingWebGLCanvases(page);

await clickFooterPrivacyPolicyLink(page);
await page.waitForURL((url) => url.pathname === "/privacy");

await page
.getByRole("link", { name: /dstruct/i })
.first()
.click();
await page.waitForURL((url) => url.pathname === "/");

const activeCanvases = await waitForActiveLandingWebGLCanvases(page);
expect(activeCanvases.length).toBeGreaterThanOrEqual(2);
expect(contextLostMessages).toEqual([]);
});

test("remounts landing canvases after forced WEBGL_lose_context", async ({
page,
}) => {
await page.goto("/");
await dismissCookieBannerIfVisible(page);
// Python decor lives below the fold; scroll so both models mount WebGL canvases.
await page.evaluate(() => {
window.scrollTo(0, document.body.scrollHeight);
});
await waitForActiveLandingWebGLCanvases(page, 2, 30_000);

const lostCount = await forceLoseActiveLandingWebGLContexts(page);
expect(lostCount).toBeGreaterThanOrEqual(2);

const recovered = await waitForActiveLandingWebGLCanvases(page, 2, 30_000);
expect(recovered.length).toBeGreaterThanOrEqual(2);
});
});
64 changes: 64 additions & 0 deletions e2e/instant-marketing-nav.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { instant } from "@next/playwright";
import { expect, test } from "@playwright/test";

import {
clickFooterPrivacyPolicyLink,
dismissCookieBannerIfVisible,
} from "./helpers/dismissCookieBanner";
import { waitForActiveLandingWebGLCanvases } from "./helpers/landingWebGLCanvases";

/**
* L5: marketing pages use `instant = true`; client navigations between them are validated.
* Serial mode avoids `next-instant-navigation-testing` cookie races on localhost.
*/
test.describe("instant marketing navigations (L5)", () => {
test.describe.configure({ mode: "serial" });

test.beforeEach(async ({ page }) => {
await page.goto("/");
await dismissCookieBannerIfVisible(page);
});

test("privacy is instant on client navigation from home", async ({
page,
}) => {
await instant(page, async () => {
await clickFooterPrivacyPolicyLink(page);
await page.waitForURL((url) => url.pathname === "/privacy");
await expect(page.getByRole("heading", { level: 1 })).toHaveText(
"Privacy Policy",
);
});
});

test("daily shell is instant on client navigation from home", async ({
page,
}) => {
await instant(page, async () => {
await page.getByRole("link", { name: /daily problem/i }).click();
await page.waitForURL((url) => url.pathname === "/daily");
await expect(page.getByRole("heading", { level: 4 })).toContainText(
"Not sure what to solve?",
);
});
});

test("home hero is instant on client navigation from privacy", async ({
page,
}) => {
await page.goto("/privacy");
await dismissCookieBannerIfVisible(page);

await instant(page, async () => {
await page
.getByRole("link", { name: /dstruct/i })
.first()
.click();
await page.waitForURL((url) => url.pathname === "/");
await expect(page.getByRole("heading", { level: 1 })).toContainText(
"Your code, frame by frame.",
);
});
await waitForActiveLandingWebGLCanvases(page);
});
});
Loading
Loading