Add docs and further stress tests to the demo
This commit is contained in:
@@ -32,14 +32,19 @@ async function waitForTransition(page: Page) {
|
||||
await expect(page.locator('.nvr-router-view')).not.toHaveClass(/nvr-router-view--interactive/)
|
||||
}
|
||||
|
||||
async function flickToNextTab(page: Page) {
|
||||
async function flickToNextTab(page: Page, leaveSlowSpring = false) {
|
||||
// Start on the route header, outside conversation-owned drag targets.
|
||||
const frame = await page.locator('[data-native-role="active"] .app-header, [data-native-role="to"] .app-header').last().boundingBox()
|
||||
if (!frame) throw new Error('Active route header did not render')
|
||||
const y = frame.y + frame.height * 0.5
|
||||
await page.mouse.move(frame.x + frame.width * 0.68, y)
|
||||
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()
|
||||
await page.mouse.move(frame.x + frame.width * 0.48, y)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -146,7 +151,9 @@ test('accepts a second fast tab flick while the first spring is still settling',
|
||||
await page.goto('/inbox')
|
||||
const routerView = page.locator('.nvr-router-view')
|
||||
|
||||
await flickToNextTab(page)
|
||||
// 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/)
|
||||
|
||||
@@ -162,6 +169,61 @@ test('accepts a second fast tab flick while the first spring is still settling',
|
||||
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()
|
||||
|
||||
@@ -88,4 +88,5 @@ test('precaches lazily split routes for offline navigation', async ({ page }) =>
|
||||
})
|
||||
expect(cachedUrls.some((url) => /StoriesView-.*\.js$/.test(url))).toBe(true)
|
||||
expect(cachedUrls.some((url) => /ProfileView-.*\.js$/.test(url))).toBe(true)
|
||||
expect(cachedUrls.some((url) => /RuntimeLabView-.*\.js$/.test(url))).toBe(true)
|
||||
})
|
||||
|
||||
@@ -6,12 +6,12 @@ import { useRoute } from 'vue-router'
|
||||
import PwaUpdate from './components/PwaUpdate.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const siblingRoutes = ['/inbox', '/stories', '/profile']
|
||||
const siblingRoutes = ['/inbox', '/stories', '/profile', '/profile/runtime-lab']
|
||||
const showTabs = computed(() => Boolean(route.meta.tab))
|
||||
const tabs: NativeTabItem[] = [
|
||||
{ label: 'Inbox', to: '/inbox', icon: '◉' },
|
||||
{ label: 'Stories', to: '/stories', icon: '◎' },
|
||||
{ label: 'You', to: '/profile', icon: '◇' },
|
||||
{ label: 'You', to: '/profile', icon: '◇', activeWhen: (current) => current.path.startsWith('/profile') },
|
||||
]
|
||||
</script>
|
||||
|
||||
|
||||
16
apps/demo/src/components/AsyncLabData.vue
Normal file
16
apps/demo/src/components/AsyncLabData.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
const requestedAt = Date.now()
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 1_000))
|
||||
const resolutionTime = Date.now() - requestedAt
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="lab-probe lab-probe--ready" data-testid="async-data-ready">
|
||||
<span class="lab-probe__icon" aria-hidden="true">✓</span>
|
||||
<div>
|
||||
<strong>Async payload available</strong>
|
||||
<p>Resolved {{ resolutionTime }} ms after this component mounted.</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
25
apps/demo/src/lab-state.ts
Normal file
25
apps/demo/src/lab-state.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { readonly, reactive } from 'vue'
|
||||
|
||||
const state = reactive({
|
||||
blockEntry: false,
|
||||
checks: 0,
|
||||
status: 'idle' as 'idle' | 'checking' | 'allowed' | 'blocked',
|
||||
})
|
||||
|
||||
export const runtimeLabGuard = readonly(state)
|
||||
|
||||
export function setRuntimeLabBlocked(blocked: boolean) {
|
||||
state.blockEntry = blocked
|
||||
if (state.status !== 'checking') state.status = 'idle'
|
||||
}
|
||||
|
||||
/** An intentionally asynchronous, stateful guard used by the stress demo. */
|
||||
export async function evaluateRuntimeLabEntry() {
|
||||
state.checks += 1
|
||||
state.status = 'checking'
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 320))
|
||||
const allowed = !state.blockEntry
|
||||
state.status = allowed ? 'allowed' : 'blocked'
|
||||
return allowed
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRouter, createWebHashHistory, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { evaluateRuntimeLabEntry } from './lab-state'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: '/', redirect: '/inbox' },
|
||||
@@ -14,6 +15,11 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/profile', name: 'profile', component: () => import('./views/ProfileView.vue'),
|
||||
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 2, siblingHistory: 'replace', gesture: 'full' } },
|
||||
},
|
||||
{
|
||||
path: '/profile/runtime-lab', name: 'runtime-lab', component: () => import('./views/RuntimeLabView.vue'),
|
||||
beforeEnter: evaluateRuntimeLabEntry,
|
||||
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 3, siblingHistory: 'push', presentation: 'slide', parent: '/profile', gesture: 'full' } },
|
||||
},
|
||||
{
|
||||
path: '/chat/:id', name: 'chat', component: () => import('./views/ChatView.vue'),
|
||||
meta: { native: { presentation: 'push', parent: '/inbox', gesture: 'edge' } },
|
||||
|
||||
@@ -241,6 +241,15 @@ html[data-pwa-edge-guard="active"] body {
|
||||
.lab-intro { display: flex; align-items: center; gap: 16px; margin: 20px 16px; padding: 18px; border: 1px solid rgba(124,92,255,.2); border-radius: 20px; background: rgba(124,92,255,.1); }
|
||||
.lab-intro > span { display: grid; width: 54px; height: 54px; place-items: center; border-radius: 17px; background: #7c5cff; font-size: 20px; font-weight: 800; }
|
||||
.lab-intro strong { font-size: 14px; }.lab-intro p { margin: 4px 0 0; color: var(--muted); font-size: 11px; }
|
||||
.lab-intro--timer > span { font-variant-numeric: tabular-nums; }
|
||||
.runtime-lab-screen .settings-group { padding-bottom: 0; }
|
||||
.lab-probe { display: flex; min-height: 86px; align-items: center; gap: 14px; padding: 15px; border-top: 1px solid var(--line); }
|
||||
.lab-probe__icon, .lab-spinner { display: grid; flex: 0 0 auto; width: 42px; height: 42px; place-items: center; border-radius: 14px; }
|
||||
.lab-probe__icon { color: #07130f; background: #3dd9aa; font-size: 20px; font-weight: 900; }
|
||||
.lab-probe strong { font-size: 13px; }
|
||||
.lab-probe p { margin: 4px 0 0; color: var(--muted); font-size: 11px; line-height: 1.45; }
|
||||
.lab-spinner { border: 3px solid rgba(155,136,255,.2); border-top-color: #9b88ff; animation: lab-spin .7s linear infinite; }
|
||||
@keyframes lab-spin { to { transform: rotate(360deg); } }
|
||||
.settings-group { padding: 8px 0; }
|
||||
.settings-group h2 { margin: 8px 15px; color: #737986; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.settings-group > label, .settings-group > div { display: flex; min-height: 62px; align-items: center; justify-content: space-between; gap: 15px; padding: 10px 15px; border-top: 1px solid var(--line); }
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { NativeLink } from '@native-vue-router/core'
|
||||
import { NativeLink, useNativeRouter } from '@native-vue-router/core'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import { runtimeLabGuard, setRuntimeLabBlocked } from '../lab-state'
|
||||
|
||||
const native = useNativeRouter()
|
||||
|
||||
function toggleLabGuard() {
|
||||
setRuntimeLabBlocked(!runtimeLabGuard.blockEntry)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -17,6 +24,10 @@ import AppHeader from '../components/AppHeader.vue'
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-list">
|
||||
<a href="/profile/runtime-lab" @click.prevent="native.sibling('/profile/runtime-lab')"><span>⌁</span><strong>Runtime stress lab</strong><i>›</i></a>
|
||||
<button type="button" aria-label="Block Runtime Lab re-entry" :aria-pressed="runtimeLabGuard.blockEntry" @click="toggleLabGuard">
|
||||
<span>⌽</span><strong>Block cached lab re-entry</strong><i>{{ runtimeLabGuard.blockEntry ? 'On' : 'Off' }}</i>
|
||||
</button>
|
||||
<NativeLink to="/settings"><span>⚙︎</span><strong>Navigation lab</strong><i>›</i></NativeLink>
|
||||
<a href="https://github.com" target="_blank" rel="noreferrer"><span>⌘</span><strong>Project source</strong><i>↗</i></a>
|
||||
<button type="button"><span>◐</span><strong>Appearance</strong><i>System</i></button>
|
||||
|
||||
67
apps/demo/src/views/RuntimeLabView.vue
Normal file
67
apps/demo/src/views/RuntimeLabView.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import AsyncLabData from '../components/AsyncLabData.vue'
|
||||
import { runtimeLabGuard } from '../lab-state'
|
||||
|
||||
const mountedSeconds = ref(0)
|
||||
let mountedAt = 0
|
||||
let timer: number | undefined
|
||||
|
||||
onMounted(() => {
|
||||
mountedAt = Date.now()
|
||||
timer = window.setInterval(() => {
|
||||
mountedSeconds.value = Math.floor((Date.now() - mountedAt) / 1_000)
|
||||
}, 200)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer !== undefined) window.clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="screen screen--tabs runtime-lab-screen">
|
||||
<AppHeader title="Runtime stress lab" subtitle="A cached push-history sibling" back />
|
||||
|
||||
<section class="lab-intro lab-intro--timer">
|
||||
<span>{{ mountedSeconds }}</span>
|
||||
<div>
|
||||
<strong>Seconds mounted</strong>
|
||||
<p
|
||||
data-testid="mounted-seconds"
|
||||
:data-seconds="mountedSeconds"
|
||||
>This timer continues while the route is cached and out of view.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-group">
|
||||
<h2>Suspense boundary</h2>
|
||||
<Suspense :timeout="0">
|
||||
<AsyncLabData />
|
||||
<template #fallback>
|
||||
<article class="lab-probe lab-probe--loading" data-testid="async-data-loading" aria-live="polite">
|
||||
<span class="lab-spinner" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Waiting for async payload</strong>
|
||||
<p>Data becomes available one second after the child mounts.</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</Suspense>
|
||||
</section>
|
||||
|
||||
<section class="settings-group">
|
||||
<h2>Guard and history state</h2>
|
||||
<div>
|
||||
<span><strong>Entry guard</strong><small>Asynchronous check #{{ runtimeLabGuard.checks }}</small></span>
|
||||
<b data-testid="lab-guard-status" :class="{ offline: runtimeLabGuard.status === 'blocked' }">{{ runtimeLabGuard.status }}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span><strong>Sibling history</strong><small>This route pushes instead of replacing Profile</small></span>
|
||||
<b>push</b>
|
||||
</div>
|
||||
<p>Use Back to return to Profile. The route remains mounted in the native view cache, so its timer and resolved async component keep their state.</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
Reference in New Issue
Block a user