Files
Native-Router-Vue/apps/demo/e2e/navigation.spec.ts
2026-07-22 02:12:05 +00:00

543 lines
19 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
async function captureTransitions(page: Page) {
await page.evaluate(() => {
const state = window as typeof window & {
__nvrEvents?: Array<{
direction: string | null;
presentation: string | null;
}>;
__nvrObserver?: MutationObserver;
};
state.__nvrObserver?.disconnect();
state.__nvrEvents = [];
const view = document.querySelector(".nvr-router-view");
if (!view) throw new Error("Native router view did not render");
state.__nvrObserver = new MutationObserver(() => {
if (view.classList.contains("nvr-router-view--interactive")) {
state.__nvrEvents?.push({
direction: view.getAttribute("data-native-direction"),
presentation: view.getAttribute("data-native-presentation"),
});
}
});
state.__nvrObserver.observe(view, { attributes: true });
});
}
async function recordedTransitions(page: Page) {
return await page.evaluate(
() =>
(
window as typeof window & {
__nvrEvents?: Array<{
direction: string | null;
presentation: string | null;
}>;
}
).__nvrEvents ?? [],
);
}
async function waitForTransition(page: Page) {
await expect(page.locator(".nvr-router-view")).not.toHaveClass(
/nvr-router-view--interactive/,
);
}
async function flickToNextTab(page: Page, leaveSlowSpring = false) {
// Start on the route header, outside conversation-owned drag targets.
const surface = await page.locator(".nvr-router-view").boundingBox();
const header = await page
.locator(
'[data-native-role="active"] .app-header, [data-native-role="to"] .app-header',
)
.last()
.boundingBox();
if (!surface || !header)
throw new Error("Active route header did not render");
const y = header.y + header.height * 0.5;
await page.mouse.move(surface.x + surface.width * 0.72, y);
await page.mouse.down();
if (leaveSlowSpring) {
await page.mouse.move(surface.x + surface.width * 0.3, y, { steps: 10 });
await page.waitForTimeout(90);
}
await page.mouse.move(surface.x + surface.width * 0.27, y);
await page.mouse.up();
}
test("navigates a conversation and returns through the native runtime", async ({
page,
}) => {
await page.goto("/inbox");
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
await page.getByText("Maya Chen").last().click();
await expect(page).toHaveURL(/\/chat\/maya$/);
await expect(page.getByRole("heading", { name: "Maya Chen" })).toBeVisible();
await page.getByRole("button", { name: "Back" }).click();
await expect(page).toHaveURL(/\/inbox$/);
await waitForTransition(page);
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
});
test("switches sibling routes without growing the primary history flow", async ({
page,
}) => {
await page.goto("/inbox");
await page.getByRole("link", { name: /Stories/ }).click();
await expect(page).toHaveURL(/\/stories$/);
await waitForTransition(page);
await expect(page.getByRole("heading", { name: "Stories" })).toBeVisible();
await page.getByRole("link", { name: /You/ }).click();
await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page);
await expect(page.getByRole("heading", { name: "You" })).toBeVisible();
});
test("uses route order for tab direction and does not animate the active tab", async ({
page,
}) => {
await page.goto("/inbox");
await captureTransitions(page);
await page.getByRole("link", { name: /Stories/ }).click();
await expect(page).toHaveURL(/\/stories$/);
await waitForTransition(page);
expect(await recordedTransitions(page)).toContainEqual({
direction: "forward",
presentation: "slide",
});
await captureTransitions(page);
await page.getByRole("link", { name: /Inbox/ }).click();
await expect(page).toHaveURL(/\/inbox$/);
await waitForTransition(page);
expect(await recordedTransitions(page)).toContainEqual({
direction: "back",
presentation: "slide",
});
await captureTransitions(page);
await page.getByRole("link", { name: /Inbox/ }).click();
await page.waitForTimeout(100);
expect(await recordedTransitions(page)).toEqual([]);
await expect(page).toHaveURL(/\/inbox$/);
});
test("interrupts an active tab animation when another tab is tapped", async ({
page,
}) => {
await page.goto("/inbox");
const routerView = page.locator(".nvr-router-view");
await page.getByRole("link", { name: /Stories/ }).click();
await expect(routerView).toHaveClass(/nvr-router-view--interactive/);
const firstTransaction = await routerView.getAttribute(
"data-native-transaction",
);
expect(firstTransaction).not.toBeNull();
await page.getByRole("link", { name: /You/ }).click();
await expect(routerView).not.toHaveAttribute(
"data-native-transaction",
firstTransaction!,
{ timeout: 250 },
);
await expect(page.locator('[data-native-role="from"]')).toHaveAttribute(
"data-native-route",
"/stories",
);
await expect(page.locator('[data-native-role="to"]')).toHaveAttribute(
"data-native-route",
"/profile",
);
await expect(page).toHaveURL(/\/profile$/);
});
test("interrupts a settling push animation with an edge-back gesture", async ({
page,
}) => {
await page.goto("/inbox");
await page.getByText("Maya Chen").last().click();
await expect(page).toHaveURL(/\/chat\/maya$/);
const routerView = page.locator(".nvr-router-view");
await expect(routerView).toHaveClass(/nvr-router-view--interactive/);
const pushTransaction = await routerView.getAttribute(
"data-native-transaction",
);
const frame = await page.locator(".app-frame").boundingBox();
if (!frame) throw new Error("App frame did not render");
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5);
await page.mouse.down();
await page.mouse.move(
frame.x + frame.width * 0.34,
frame.y + frame.height * 0.5,
{ steps: 16 },
);
await expect(routerView).not.toHaveAttribute(
"data-native-transaction",
pushTransaction!,
{ timeout: 250 },
);
await expect(page.locator('[data-native-role="from"]')).toHaveAttribute(
"data-native-route",
"/chat/maya",
);
await expect(page.locator('[data-native-role="to"]')).toHaveAttribute(
"data-native-route",
"/inbox",
);
await page.mouse.up();
});
test("moves sibling screens edge-to-edge at one-to-one drag progress", async ({
page,
}) => {
await page.goto("/stories");
const routerView = page.locator(".nvr-router-view");
const frame = await routerView.boundingBox();
if (!frame) throw new Error("Native router view did not render");
await page.mouse.move(
frame.x + frame.width * 0.55,
frame.y + frame.height * 0.45,
);
await page.mouse.down();
await page.mouse.move(
frame.x + frame.width * 0.8,
frame.y + frame.height * 0.45,
{ steps: 18 },
);
await expect(routerView).toHaveAttribute("data-native-presentation", "slide");
await expect(routerView).toHaveAttribute("data-native-direction", "back");
const from = await page.locator('[data-native-role="from"]').boundingBox();
const to = await page.locator('[data-native-role="to"]').boundingBox();
if (!from || !to)
throw new Error("Both sibling pages must be live during a drag");
expect(Math.abs(from.x - (to.x + to.width))).toBeLessThan(3);
await page.mouse.up();
});
test("accepts a second fast tab flick while the first spring is still settling", async ({
page,
}) => {
await page.goto("/inbox");
const routerView = page.locator(".nvr-router-view");
// Commit by distance with a deliberately slow final sample, leaving enough
// baseline spring for the second fast gesture to interrupt deterministically.
await flickToNextTab(page, true);
await expect(page).toHaveURL(/\/stories$/);
await expect(routerView).toHaveClass(/nvr-router-view--interactive/);
await flickToNextTab(page);
await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page);
// A stale pointer-up cleanup used to leave an orphaned transaction here,
// permanently blocking both subsequent swipes and imperative tab links.
await page.getByRole("link", { name: /Inbox/ }).click();
await expect(page).toHaveURL(/\/inbox$/);
await waitForTransition(page);
await expect(routerView).not.toHaveAttribute("data-native-transaction");
});
test("renders a suspended pushed sibling and evicts it after backing out", async ({
page,
}) => {
await page.goto("/profile");
await page.getByRole("link", { name: /Runtime stress lab/ }).click();
await expect(page.getByTestId("async-data-loading")).toBeVisible();
await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
await expect(
page.getByRole("navigation", { name: "Primary navigation" }),
).toBeVisible();
await expect(page.getByRole("link", { name: /You/ })).toHaveAttribute(
"aria-current",
"page",
);
await expect(page.getByTestId("async-data-ready")).toBeVisible({
timeout: 2_000,
});
const lab = page.getByTestId("runtime-lab-view");
const firstMountId = await lab.getAttribute("data-mount-id");
await page.getByRole("button", { name: "Back" }).click();
await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page);
// Popping a pushed route removes it after the exit animation. Keeping the
// descriptor allows browser-forward navigation without retaining its DOM.
await expect(page.getByTestId("runtime-lab-view")).toHaveCount(0);
// browser forward exists only because this sibling opted into push history.
await page.evaluate(() => history.forward());
await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
await expect(page.getByTestId("async-data-loading")).toBeVisible();
await expect(page.getByTestId("async-data-ready")).toBeVisible({
timeout: 2_000,
});
await expect(page.getByTestId("runtime-lab-view")).not.toHaveAttribute(
"data-mount-id",
firstMountId!,
);
});
test("clicking the root tab from a pushed sibling collapses its back history", async ({
page,
}) => {
await page.goto("/profile");
await page.getByRole("link", { name: /Runtime stress lab/ }).click();
await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
await page.getByRole("link", { name: /You/ }).click();
await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page);
await expect(page.locator(".nvr-navigator")).toHaveAttribute(
"data-native-can-go-back",
"false",
);
await expect(page.getByTestId("runtime-lab-view")).toHaveCount(0);
});
test("lazily caches a visited sibling and pauses its active work while hidden", async ({
page,
}) => {
await page.goto("/stories");
const stories = page.getByTestId("stories-view");
const mountId = await stories.getAttribute("data-mount-id");
await expect
.poll(async () => Number(await stories.getAttribute("data-active-ticks")))
.toBeGreaterThan(1);
await page.getByRole("link", { name: /You/ }).click();
await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page);
await expect(stories).toHaveCount(1);
const hiddenTicks = Number(await stories.getAttribute("data-active-ticks"));
await page.waitForTimeout(600);
await expect(stories).toHaveAttribute(
"data-active-ticks",
String(hiddenTicks),
);
await page.getByRole("link", { name: /Stories/ }).click();
await expect(page).toHaveURL(/\/stories$/);
await waitForTransition(page);
await expect(stories).toHaveAttribute("data-mount-id", mountId!);
await expect
.poll(async () => Number(await stories.getAttribute("data-active-ticks")))
.toBeGreaterThan(hiddenTicks);
});
test("evicts a cached sibling when its dynamic entry guard rejects it", async ({
page,
}) => {
await page.goto("/stories");
const stories = page.getByTestId("stories-view");
const firstMountId = await stories.getAttribute("data-mount-id");
await page.getByRole("link", { name: /You/ }).click();
await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page);
await expect(stories).toHaveCount(1);
const guardToggle = page.getByRole("button", {
name: "Block Stories re-entry",
});
await guardToggle.click();
await expect(guardToggle).toHaveAttribute("aria-pressed", "true");
await page.getByRole("link", { name: /Stories/ }).click();
await expect(page).toHaveURL(/\/profile$/);
await expect(page.getByTestId("story-guard-status")).toHaveText("blocked");
await waitForTransition(page);
await expect(page.getByTestId("stories-view")).toHaveCount(0);
await guardToggle.click();
await page.getByRole("link", { name: /Stories/ }).click();
await expect(page).toHaveURL(/\/stories$/);
await waitForTransition(page);
await expect(page.getByTestId("stories-view")).not.toHaveAttribute(
"data-mount-id",
firstMountId!,
);
});
test("manually unloads an inactive route through the public API demo", async ({
page,
}) => {
await page.goto("/stories");
await page.getByRole("link", { name: /You/ }).click();
await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page);
await expect(page.getByTestId("stories-view")).toHaveCount(1);
await page.getByRole("link", { name: /Navigation lab/ }).click();
await expect(page).toHaveURL(/\/settings$/);
await waitForTransition(page);
await page.getByTestId("unload-stories").click();
await expect(page.getByTestId("unload-stories")).toHaveText(
"Unloaded 1 Stories view",
);
await expect(page.getByTestId("stories-view")).toHaveCount(0);
});
test("records a navigation frame profile across route changes", async ({
page,
}) => {
await page.goto("/settings");
await page.getByTestId("profile-start").click();
await expect(page.locator(".profiler-badge")).toBeVisible();
await page.getByRole("button", { name: "Back" }).click();
await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page);
await page.getByRole("link", { name: /Runtime stress lab/ }).click();
await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
await page.getByRole("link", { name: /You/ }).click();
await expect(page).toHaveURL(/\/profile$/);
await waitForTransition(page);
await page.getByRole("link", { name: /Navigation lab/ }).click();
await expect(page).toHaveURL(/\/settings$/);
await waitForTransition(page);
await page.getByTestId("profile-stop").click();
await expect(page.locator(".profiler-badge")).toHaveCount(0);
await expect(page.getByTestId("profile-status")).toContainText("navigations");
await expect(page.getByTestId("profile-export")).toBeEnabled();
});
test("opens and dismisses the compose sheet", async ({ page }) => {
await page.goto("/inbox");
await page.getByRole("button", { name: "Compose" }).click();
await expect(page).toHaveURL(/\/compose$/);
await expect(
page.getByRole("heading", { name: "New message" }),
).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
});
test("keeps the target live during a held component drag", async ({ page }) => {
await page.goto("/inbox");
const row = page.locator(".conversation-row").first();
const box = await row.boundingBox();
if (!box) throw new Error("Conversation row did not render");
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.35, box.y + box.height / 2, {
steps: 12,
});
await expect(page.locator('[data-native-role="to"]')).toBeVisible();
await page.mouse.up();
await expect(page.getByRole("heading", { name: "Maya Chen" })).toBeVisible();
});
test("holds an edge-back preview without committing the URL", async ({
page,
}) => {
await page.goto("/inbox");
await page.getByText("Maya Chen").last().click();
await expect(page).toHaveURL(/\/chat\/maya$/);
const frame = await page.locator(".app-frame").boundingBox();
if (!frame) throw new Error("App frame did not render");
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5);
await page.mouse.down();
await page.mouse.move(
frame.x + frame.width * 0.55,
frame.y + frame.height * 0.5,
{ steps: 14 },
);
await expect(page.locator('[data-native-role="to"]')).toBeVisible();
await expect(page).toHaveURL(/\/chat\/maya$/);
await page.mouse.up();
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
});
test("never previews a stale conversation after a cancelled back gesture", async ({
page,
}) => {
await page.goto("/inbox");
await page.getByText("Maya Chen").last().click();
await page.getByRole("button", { name: "Back" }).click();
await expect(page).toHaveURL(/\/inbox$/);
await waitForTransition(page);
await page.getByText("Noah Williams").last().click();
await expect(page).toHaveURL(/\/chat\/noah$/);
await waitForTransition(page);
const frame = await page.locator(".app-frame").boundingBox();
if (!frame) throw new Error("App frame did not render");
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5);
await page.mouse.down();
await page.mouse.move(
frame.x + frame.width * 0.055,
frame.y + frame.height * 0.5,
{ steps: 16 },
);
await page.mouse.up();
await waitForTransition(page);
await expect(page).toHaveURL(/\/chat\/noah$/);
await page.getByRole("button", { name: "Back" }).click();
const backTarget = page.locator('[data-native-role="to"]');
await expect(
backTarget.getByRole("heading", { name: "Messages" }),
).toBeVisible();
await expect(
backTarget.getByRole("heading", { name: "Maya Chen" }),
).toHaveCount(0);
await waitForTransition(page);
await expect(page).toHaveURL(/\/inbox$/);
});
test("always opens compose as a vertical sheet after prior navigation", async ({
page,
}) => {
await page.goto("/inbox");
await page.getByText("Maya Chen").last().click();
await page.getByRole("button", { name: "Back" }).click();
await expect(page).toHaveURL(/\/inbox$/);
await waitForTransition(page);
await page.getByRole("link", { name: /Stories/ }).click();
await expect(page).toHaveURL(/\/stories$/);
await waitForTransition(page);
await page.getByRole("link", { name: /Inbox/ }).click();
await expect(page).toHaveURL(/\/inbox$/);
await waitForTransition(page);
await page.getByRole("button", { name: "Compose" }).click();
const routerView = page.locator(".nvr-router-view");
await expect(routerView).toHaveAttribute("data-native-presentation", "sheet");
await expect(routerView).toHaveAttribute("data-native-direction", "up");
const frame = await routerView.boundingBox();
const sheet = await page.locator('[data-native-role="to"]').boundingBox();
if (!frame || !sheet) throw new Error("Sheet transition did not render");
expect(Math.abs(frame.x - sheet.x)).toBeLessThan(3);
await waitForTransition(page);
await page.getByRole("button", { name: "Cancel" }).click();
});
test("drags a sheet down to dismiss it", async ({ page }) => {
await page.goto("/inbox");
await page.getByRole("button", { name: "Compose" }).click();
const sheet = await page.locator(".sheet-screen").boundingBox();
if (!sheet) throw new Error("Sheet did not render");
await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + 12);
await page.mouse.down();
await page.mouse.move(
sheet.x + sheet.width / 2,
sheet.y + sheet.height * 0.55,
{ steps: 14 },
);
await page.mouse.up();
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
});