Files
Native-Router-Vue/apps/demo/e2e/navigation.spec.ts

327 lines
16 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 deep sibling and keeps its pushed history entry', 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 counter = page.locator('[data-native-route="/profile/runtime-lab"] [data-testid="mounted-seconds"]')
const before = Number(await counter.getAttribute('data-seconds'))
await page.getByRole('button', { name: 'Back' }).click()
await expect(page).toHaveURL(/\/profile$/)
await waitForTransition(page)
// The route is out of view but still mounted, so its local interval advances.
await expect.poll(async () => Number(await counter.getAttribute('data-seconds')), { timeout: 2_500 }).toBeGreaterThan(before)
// 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-ready')).toBeVisible()
})
test('a dynamic guard rejects an already-cached route without destroying it', async ({ page }) => {
await page.goto('/profile')
await page.getByRole('link', { name: /Runtime stress lab/ }).click()
await expect(page).toHaveURL(/\/profile\/runtime-lab$/)
await expect(page.getByTestId('async-data-ready')).toBeVisible({ timeout: 2_000 })
await page.getByRole('button', { name: 'Back' }).click()
await expect(page).toHaveURL(/\/profile$/)
await waitForTransition(page)
const cachedLab = page.locator('[data-native-route="/profile/runtime-lab"]')
const counter = cachedLab.getByTestId('mounted-seconds')
const beforeBlockedAttempt = Number(await counter.getAttribute('data-seconds'))
await page.getByRole('button', { name: 'Block Runtime Lab re-entry' }).click()
await expect(page.getByRole('button', { name: 'Block Runtime Lab re-entry' })).toHaveAttribute('aria-pressed', 'true')
await page.getByRole('link', { name: /Runtime stress lab/ }).click()
await expect(cachedLab.getByTestId('lab-guard-status')).toHaveText('blocked')
await waitForTransition(page)
await expect(page).toHaveURL(/\/profile$/)
await expect(page.getByRole('heading', { name: 'You' })).toBeVisible()
await expect(cachedLab).toHaveCount(1)
await expect.poll(async () => Number(await counter.getAttribute('data-seconds')), { timeout: 2_500 }).toBeGreaterThan(beforeBlockedAttempt)
await page.getByRole('button', { name: 'Block Runtime Lab re-entry' }).click()
await page.getByRole('link', { name: /Runtime stress lab/ }).click()
await expect(page).toHaveURL(/\/profile\/runtime-lab$/)
await expect(page.getByTestId('async-data-ready')).toBeVisible()
await expect(page.getByTestId('mounted-seconds')).toHaveAttribute('data-seconds', /[2-9]|[1-9]\d+/)
})
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()
})