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