812 lines
29 KiB
TypeScript
812 lines
29 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("exercises Vue built-ins, lifecycle hooks, injection, and scoped route state", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/profile");
|
|
await page.getByRole("link", { name: /Vue compatibility lab/ }).click();
|
|
await expect(page).toHaveURL(
|
|
/\/profile\/vue-lab\/alpha\?mode=manual#route-state$/,
|
|
);
|
|
|
|
const lab = page.getByTestId("vue-compatibility-view");
|
|
const firstInstance = await lab.getAttribute("data-instance-id");
|
|
await expect(page.getByTestId("compat-param")).toHaveText("alpha");
|
|
await expect(page.getByTestId("compat-query")).toContainText("manual");
|
|
await expect(page.getByTestId("compat-hash")).toHaveText("#route-state");
|
|
await expect(page.getByTestId("options-lifecycle-probe")).toContainText(
|
|
"/profile/vue-lab/alpha?mode=manual#route-state",
|
|
);
|
|
await expect(page.getByTestId("inject-probe-route-tree")).toContainText(
|
|
"Injected from the demo application root",
|
|
);
|
|
|
|
await page.getByRole("button", { name: "Increment A" }).click();
|
|
await expect(page.getByTestId("keep-alive-A")).toContainText("Counter: 1");
|
|
await page.getByTestId("compat-switch-keepalive").click();
|
|
await expect(page.getByTestId("keep-alive-B")).toBeVisible();
|
|
await page.getByTestId("compat-switch-keepalive").click();
|
|
await expect(page.getByTestId("keep-alive-A")).toContainText("Counter: 1");
|
|
|
|
await page.getByTestId("compat-update-probes").click();
|
|
await expect(page.getByTestId("compat-event-log")).toContainText("onUpdated");
|
|
await page.getByTestId("compat-toggle-transition").click();
|
|
await expect(page.getByTestId("compat-transition-card")).toHaveCount(0);
|
|
await expect(page.getByTestId("compat-event-log")).toContainText(
|
|
"after-leave",
|
|
);
|
|
|
|
await page.getByTestId("compat-open-teleport").click();
|
|
const teleport = page.getByTestId("compat-teleport-overlay");
|
|
await expect(teleport).toBeVisible();
|
|
await expect(page.getByTestId("inject-probe-teleport")).toContainText(
|
|
"Injected from the demo application root",
|
|
);
|
|
await page.getByRole("button", { name: "Close teleported overlay" }).click();
|
|
|
|
await page.getByTestId("compat-reload-suspense").click();
|
|
await expect(page.getByTestId("compat-suspense-fallback")).toBeVisible();
|
|
await expect(page.getByTestId("compat-suspense-ready")).toBeVisible({
|
|
timeout: 2_000,
|
|
});
|
|
|
|
await page.getByTestId("compat-open-away").click();
|
|
await expect(page.getByTestId("vue-compatibility-away")).toBeVisible();
|
|
await page.getByTestId("compat-unload-return").click();
|
|
await expect(page.getByTestId("vue-compatibility-view")).toBeVisible();
|
|
await expect(page.getByTestId("vue-compatibility-view")).not.toHaveAttribute(
|
|
"data-instance-id",
|
|
firstInstance!,
|
|
);
|
|
await expect(page.getByTestId("compat-event-log")).toContainText(
|
|
"onUnmounted",
|
|
);
|
|
});
|
|
|
|
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 waitForTransition(page);
|
|
await expect(page.locator("[data-native-sheet]")).toHaveAttribute(
|
|
"data-native-sheet-breakpoint",
|
|
"0.62",
|
|
);
|
|
await expect(page.locator('[data-native-role="underlay"]')).toHaveAttribute(
|
|
"aria-hidden",
|
|
"true",
|
|
);
|
|
await page.getByRole("button", { name: "Cancel" }).click();
|
|
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
|
|
});
|
|
|
|
test("contains a sheet below the safe top and supports snap and content sizes", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/inbox");
|
|
await page.getByRole("button", { name: "Compose" }).click();
|
|
await expect(page).toHaveURL(/\/compose$/);
|
|
await waitForTransition(page);
|
|
|
|
const routerView = page.locator(".nvr-router-view");
|
|
const activeLayer = page.locator('[data-native-role="active"]');
|
|
const frame = await routerView.boundingBox();
|
|
const layer = await activeLayer.boundingBox();
|
|
if (!frame || !layer) throw new Error("Sheet route did not render");
|
|
expect(layer.y).toBeGreaterThanOrEqual(frame.y + 7);
|
|
expect(layer.y + layer.height).toBeLessThanOrEqual(
|
|
frame.y + frame.height + 1,
|
|
);
|
|
|
|
const handle = page.getByRole("slider", {
|
|
name: "Resize or dismiss sheet",
|
|
});
|
|
await handle.press("ArrowUp");
|
|
await expect(page.getByTestId("sheet-size")).toHaveText("100%");
|
|
await expect(page.locator("[data-native-sheet]")).toHaveAttribute(
|
|
"data-native-sheet-breakpoint",
|
|
"1",
|
|
);
|
|
|
|
await page.getByRole("button", { name: "Fit content" }).click();
|
|
await expect(page.getByTestId("sheet-size")).toHaveText("Auto");
|
|
await expect(page.locator("[data-native-sheet]")).toHaveAttribute(
|
|
"data-native-sheet-mode",
|
|
"content",
|
|
);
|
|
});
|
|
|
|
test("animates the partial surface immediately and preserves underlay geometry", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/inbox");
|
|
await page.getByRole("button", { name: "Compose" }).dispatchEvent("click");
|
|
|
|
const opening = await page.evaluate(async () => {
|
|
for (let frame = 0; frame < 60; frame += 1) {
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
const routerView = document.querySelector<HTMLElement>(
|
|
".nvr-router-view--interactive",
|
|
);
|
|
const surface = document.querySelector<HTMLElement>(
|
|
'[data-native-role="to"] [data-native-sheet]',
|
|
);
|
|
const progress = Number(
|
|
routerView?.style.getPropertyValue("--native-progress") ?? 0,
|
|
);
|
|
if (routerView && surface && progress >= 0.03) {
|
|
const frameRect = routerView.getBoundingClientRect();
|
|
const surfaceRect = surface.getBoundingClientRect();
|
|
return {
|
|
progress,
|
|
frameBottom: frameRect.bottom,
|
|
surfaceTop: surfaceRect.top,
|
|
surfaceHeight: surfaceRect.height,
|
|
};
|
|
}
|
|
}
|
|
return undefined;
|
|
});
|
|
expect(opening).toBeDefined();
|
|
expect(opening!.surfaceTop).toBeLessThan(opening!.frameBottom);
|
|
|
|
await waitForTransition(page);
|
|
const settledSurfaceHeight = await page
|
|
.locator("[data-native-sheet]")
|
|
.evaluate((element) => element.getBoundingClientRect().height);
|
|
expect(opening!.surfaceHeight).toBeCloseTo(settledSurfaceHeight, 0);
|
|
const openUnderlay = await page
|
|
.locator('[data-native-role="underlay"]')
|
|
.evaluate((element) => {
|
|
const matrix = new DOMMatrix(getComputedStyle(element).transform);
|
|
const rect = element.getBoundingClientRect();
|
|
return { scale: matrix.a, top: rect.top };
|
|
});
|
|
expect(openUnderlay.scale).toBeCloseTo(0.96, 2);
|
|
expect(openUnderlay.top).toBeGreaterThan(0);
|
|
|
|
await page.getByRole("button", { name: "Cancel" }).dispatchEvent("click");
|
|
const closing = await page.evaluate(async () => {
|
|
for (let frame = 0; frame < 60; frame += 1) {
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
const routerView = document.querySelector<HTMLElement>(
|
|
'.nvr-router-view--interactive[data-native-direction="back"]',
|
|
);
|
|
const destination = document.querySelector<HTMLElement>(
|
|
'[data-native-role="to"]',
|
|
);
|
|
const progress = Number(
|
|
routerView?.style.getPropertyValue("--native-progress") ?? 0,
|
|
);
|
|
if (routerView && destination && progress >= 0.03) {
|
|
const matrix = new DOMMatrix(getComputedStyle(destination).transform);
|
|
return { progress, scale: matrix.a };
|
|
}
|
|
}
|
|
return undefined;
|
|
});
|
|
expect(closing).toBeDefined();
|
|
expect(closing!.scale).toBeGreaterThanOrEqual(0.96);
|
|
expect(closing!.scale).toBeLessThan(1);
|
|
await waitForTransition(page);
|
|
await expect(page).toHaveURL(/\/inbox$/);
|
|
const restoredScale = await page
|
|
.locator('[data-native-role="active"]')
|
|
.evaluate(
|
|
(element) => new DOMMatrix(getComputedStyle(element).transform).a,
|
|
);
|
|
expect(restoredScale).toBeCloseTo(1, 3);
|
|
|
|
await page.getByRole("button", { name: "Compose" }).dispatchEvent("click");
|
|
await page.waitForFunction(() => {
|
|
const routerView = document.querySelector<HTMLElement>(
|
|
'.nvr-router-view--interactive[data-native-presentation="sheet"]',
|
|
);
|
|
const surface = document.querySelector<HTMLElement>(
|
|
'[data-native-role="to"] [data-native-sheet]',
|
|
);
|
|
return Boolean(
|
|
routerView &&
|
|
surface &&
|
|
surface.getBoundingClientRect().top <
|
|
routerView.getBoundingClientRect().bottom,
|
|
);
|
|
});
|
|
await waitForTransition(page);
|
|
const repeatedUnderlayScale = await page
|
|
.locator('[data-native-role="underlay"]')
|
|
.evaluate(
|
|
(element) => new DOMMatrix(getComputedStyle(element).transform).a,
|
|
);
|
|
expect(repeatedUnderlayScale).toBeCloseTo(0.96, 2);
|
|
});
|
|
|
|
test("hands content overscroll to adjacent sheet breakpoints", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/inbox");
|
|
await page.getByRole("button", { name: "Compose" }).click();
|
|
await expect(page).toHaveURL(/\/compose$/);
|
|
await waitForTransition(page);
|
|
|
|
const surface = page.locator("[data-native-sheet]");
|
|
const body = page.locator(".nvr-sheet__body");
|
|
const box = await body.boundingBox();
|
|
if (!box) throw new Error("Sheet scroll body did not render");
|
|
|
|
await body.evaluate((element) => {
|
|
element.scrollTop = Math.min(
|
|
40,
|
|
Math.max(0, element.scrollHeight - element.clientHeight - 10),
|
|
);
|
|
});
|
|
await page.mouse.move(box.x + 100, box.y + box.height * 0.5);
|
|
await page.mouse.wheel(0, 1_000);
|
|
await page.mouse.wheel(0, 80);
|
|
await expect(surface).toHaveAttribute("data-native-sheet-breakpoint", "0.62");
|
|
await page.waitForTimeout(280);
|
|
|
|
await body.evaluate((element) => {
|
|
element.scrollTop = element.scrollHeight;
|
|
});
|
|
await page.mouse.move(box.x + 100, box.y + box.height * 0.7);
|
|
await page.mouse.down();
|
|
await page.mouse.move(box.x + 100, box.y + box.height * 0.35, { steps: 10 });
|
|
await page.mouse.up();
|
|
await expect(surface).toHaveAttribute("data-native-sheet-breakpoint", "1");
|
|
|
|
await body.evaluate((element) => {
|
|
element.scrollTop = 0;
|
|
});
|
|
const expandedBox = await body.boundingBox();
|
|
if (!expandedBox) throw new Error("Expanded sheet body did not render");
|
|
await page.mouse.move(
|
|
expandedBox.x + 100,
|
|
expandedBox.y + expandedBox.height * 0.35,
|
|
);
|
|
await page.mouse.down();
|
|
await page.mouse.move(
|
|
expandedBox.x + 100,
|
|
expandedBox.y + expandedBox.height * 0.55,
|
|
{ steps: 10 },
|
|
);
|
|
await page.mouse.up();
|
|
await expect(surface).toHaveAttribute("data-native-sheet-breakpoint", "0.62");
|
|
});
|
|
|
|
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();
|
|
await expect(page).toHaveURL(/\/compose$/);
|
|
await waitForTransition(page);
|
|
const sheet = await page.locator("[data-native-sheet]").boundingBox();
|
|
const handle = await page.locator(".nvr-sheet__handle").boundingBox();
|
|
if (!sheet || !handle) throw new Error("Sheet did not render");
|
|
await page.mouse.move(handle.x + handle.width / 2, handle.y + 12);
|
|
await page.mouse.down();
|
|
await page.mouse.move(
|
|
handle.x + handle.width / 2,
|
|
handle.y + sheet.height * 0.75,
|
|
{ steps: 14 },
|
|
);
|
|
await page.mouse.up();
|
|
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
|
|
});
|