mirror of
https://github.com/vernu/textbee.git
synced 2026-08-01 09:59:02 -04:00
Merge pull request #246 from vernu/perf/dashboard-nav-speed
perf(web): make dashboard navigation fast
This commit is contained in:
@@ -2,11 +2,28 @@
|
||||
|
||||
import { Routes } from '@/config/routes'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { CredentialResponse, GoogleLogin } from '@react-oauth/google'
|
||||
import {
|
||||
CredentialResponse,
|
||||
GoogleLogin,
|
||||
GoogleOAuthProvider,
|
||||
} from '@react-oauth/google'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
// The provider lives here rather than in the app-wide tree so the Google
|
||||
// Identity SDK is only loaded on the two pages that render this button
|
||||
// (login and register) instead of on every dashboard page.
|
||||
export default function LoginWithGoogle() {
|
||||
return (
|
||||
<GoogleOAuthProvider
|
||||
clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? ''}
|
||||
>
|
||||
<GoogleLoginButton />
|
||||
</GoogleOAuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function GoogleLoginButton() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirect = searchParams.get('redirect')
|
||||
|
||||
@@ -117,7 +117,9 @@ export default function CheckoutPage({
|
||||
planName,
|
||||
billingInterval: urlInterval ?? selected,
|
||||
})
|
||||
window.location.href = '/dashboard/account?plan-change-success=1'
|
||||
// Straight to the billing tab: the /dashboard/account redirect stub
|
||||
// drops query params, which silently ate the success toast.
|
||||
window.location.href = '/dashboard/account/billing?plan-change-success=1'
|
||||
} catch (error) {
|
||||
// no auto-retry here: the request may have charged the card
|
||||
setPlanChange(null)
|
||||
@@ -175,7 +177,7 @@ export default function CheckoutPage({
|
||||
Try again
|
||||
</Button>
|
||||
<Button variant='ghost' asChild>
|
||||
<Link href='/dashboard/account'>Back to your account</Link>
|
||||
<Link href='/dashboard/account/billing'>Back to your account</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,7 +235,7 @@ export default function CheckoutPage({
|
||||
disabled={isConfirming}
|
||||
asChild
|
||||
>
|
||||
<Link href='/dashboard/account'>Cancel</Link>
|
||||
<Link href='/dashboard/account/billing'>Cancel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CheckoutShell>
|
||||
|
||||
@@ -216,6 +216,21 @@ describe('SubscriptionInfo', () => {
|
||||
).toHaveAttribute('href', 'https://textbee.dev/pricing')
|
||||
})
|
||||
|
||||
// The loading state used to be a 16px spinner alone in the content column,
|
||||
// which read as a blank tab while the subscription loaded.
|
||||
it('shows a visible loading skeleton while the subscription loads', () => {
|
||||
useSubscription.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
})
|
||||
render(<SubscriptionInfo />)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Loading subscription'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders an error state rather than an empty card', () => {
|
||||
useSubscription.mockReturnValue({
|
||||
data: undefined,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useCurrentUser, useSubscription } from '@/lib/api'
|
||||
import type { SubscriptionStatus } from '@/lib/api/types'
|
||||
import {
|
||||
@@ -235,8 +235,12 @@ export default function SubscriptionInfo() {
|
||||
|
||||
if (isLoadingSubscription)
|
||||
return (
|
||||
<div className='flex h-full min-h-[200px] items-center justify-center'>
|
||||
<Spinner size='sm' />
|
||||
// Mirrors the two cards below. The old 16px spinner in an empty column
|
||||
// read as a blank page while the subscription loaded.
|
||||
<div role='status' className='space-y-4'>
|
||||
<span className='sr-only'>Loading subscription</span>
|
||||
<Skeleton className='h-44 w-full rounded-lg' />
|
||||
<Skeleton className='h-56 w-full rounded-lg' />
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
37
web/app/(app)/dashboard/(components)/nav-items.test.ts
Normal file
37
web/app/(app)/dashboard/(components)/nav-items.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isNavItemActive, navItems } from './nav-items'
|
||||
|
||||
describe('isNavItemActive', () => {
|
||||
it('matches the dashboard home exactly', () => {
|
||||
expect(isNavItemActive({ href: '/dashboard' }, '/dashboard')).toBe(true)
|
||||
expect(isNavItemActive({ href: '/dashboard' }, '/dashboard/messaging')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('matches section subroutes by prefix', () => {
|
||||
const messaging = { href: '/dashboard/messaging' }
|
||||
expect(isNavItemActive(messaging, '/dashboard/messaging')).toBe(true)
|
||||
expect(isNavItemActive(messaging, '/dashboard/messaging/bulk')).toBe(true)
|
||||
expect(isNavItemActive(messaging, '/dashboard/messaging/api-guide')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('does not match sibling routes that share a name prefix', () => {
|
||||
expect(
|
||||
isNavItemActive({ href: '/dashboard/message' }, '/dashboard/messaging')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps Account active across the section while linking to billing', () => {
|
||||
const account = navItems.find((item) => item.label === 'Account')
|
||||
// Direct link skips the /dashboard/account redirect stub.
|
||||
expect(account?.href).toBe('/dashboard/account/billing')
|
||||
expect(isNavItemActive(account!, '/dashboard/account/billing')).toBe(true)
|
||||
expect(isNavItemActive(account!, '/dashboard/account/profile')).toBe(true)
|
||||
expect(isNavItemActive(account!, '/dashboard/account/security')).toBe(true)
|
||||
expect(isNavItemActive(account!, '/dashboard/account')).toBe(true)
|
||||
expect(isNavItemActive(account!, '/dashboard/community')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,10 @@ export type NavItem = {
|
||||
href: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
// Active-state prefix when it differs from href (Account links straight to
|
||||
// billing so the click skips the /dashboard/account redirect stub, but must
|
||||
// stay highlighted on every account tab).
|
||||
match?: string
|
||||
// The mobile tab bar caps at 4 items (375px width); items marked
|
||||
// mobileHidden appear only in the desktop sidebar and the command palette.
|
||||
mobileHidden?: boolean
|
||||
@@ -23,15 +27,24 @@ export const navItems: NavItem[] = [
|
||||
{ href: '/dashboard/messaging', label: 'Messaging', icon: MessageSquareText },
|
||||
{ href: '/dashboard/webhooks', label: 'Webhooks', icon: Webhook, mobileHidden: true },
|
||||
{ href: '/dashboard/community', label: 'Community', icon: Users },
|
||||
{ href: '/dashboard/account', label: 'Account', icon: UserCircle },
|
||||
{
|
||||
href: '/dashboard/account/billing',
|
||||
label: 'Account',
|
||||
icon: UserCircle,
|
||||
match: '/dashboard/account',
|
||||
},
|
||||
]
|
||||
|
||||
export const mobileNavItems = navItems.filter((item) => !item.mobileHidden)
|
||||
|
||||
// /dashboard must match exactly; deeper routes match by prefix so nested pages
|
||||
// keep their parent highlighted.
|
||||
export function isNavItemActive(href: string, pathname: string): boolean {
|
||||
return href === '/dashboard'
|
||||
? pathname === href
|
||||
: pathname === href || pathname.startsWith(`${href}/`) || pathname.startsWith(href)
|
||||
export function isNavItemActive(
|
||||
item: Pick<NavItem, 'href' | 'match'>,
|
||||
pathname: string
|
||||
): boolean {
|
||||
const prefix = item.match ?? item.href
|
||||
return prefix === '/dashboard'
|
||||
? pathname === prefix
|
||||
: pathname === prefix || pathname.startsWith(`${prefix}/`)
|
||||
}
|
||||
|
||||
15
web/app/(app)/dashboard/account/loading.tsx
Normal file
15
web/app/(app)/dashboard/account/loading.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
// Shown in the content slot while a tab's page segment streams in; the
|
||||
// section header and tabs from the layout stay mounted above it. max-w-2xl
|
||||
// matches the account pages (billing, profile, security, support).
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div role='status' className='max-w-2xl space-y-4'>
|
||||
<span className='sr-only'>Loading</span>
|
||||
<Skeleton className='h-8 w-48' />
|
||||
<Skeleton className='h-28 w-full rounded-lg' />
|
||||
<Skeleton className='h-28 w-full rounded-lg' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export default function DashboardLayout({
|
||||
<SidebarLink
|
||||
key={item.href}
|
||||
item={item}
|
||||
isActive={isNavItemActive(item.href, pathname)}
|
||||
isActive={isNavItemActive(item, pathname)}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
@@ -114,7 +114,7 @@ export default function DashboardLayout({
|
||||
<MobileNavLink
|
||||
key={item.href}
|
||||
item={item}
|
||||
isActive={isNavItemActive(item.href, pathname)}
|
||||
isActive={isNavItemActive(item, pathname)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
20
web/app/(app)/dashboard/loading.tsx
Normal file
20
web/app/(app)/dashboard/loading.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
// Section-level fallback for dashboard routes without a nested loading file
|
||||
// (home, community). Padding mirrors those pages so nothing shifts when the
|
||||
// real content lands. Sections with their own layout (messaging, webhooks,
|
||||
// account) use their nested loading files instead, which keep the tabs
|
||||
// mounted during tab switches.
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div role='status' className='flex-1 space-y-6 p-4 sm:p-6 md:p-8'>
|
||||
<span className='sr-only'>Loading</span>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-8 w-56' />
|
||||
<Skeleton className='h-4 w-72' />
|
||||
</div>
|
||||
<Skeleton className='h-32 w-full rounded-xl' />
|
||||
<Skeleton className='h-32 w-full rounded-xl' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,11 +3,33 @@
|
||||
import { useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter'
|
||||
import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash'
|
||||
import go from 'react-syntax-highlighter/dist/esm/languages/prism/go'
|
||||
import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript'
|
||||
import json from 'react-syntax-highlighter/dist/esm/languages/prism/json'
|
||||
import php from 'react-syntax-highlighter/dist/esm/languages/prism/php'
|
||||
import python from 'react-syntax-highlighter/dist/esm/languages/prism/python'
|
||||
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Prism, registering only the languages the guide uses. The bare
|
||||
// react-syntax-highlighter entry is the highlight.js build with every language
|
||||
// compiled in, and it emits hljs-* class names that the Prism themes below do
|
||||
// not style, so this both shrinks the chunk and makes the theme apply.
|
||||
// The languages come from LANGUAGES in snippets.ts plus json for responses.
|
||||
for (const [name, definition] of [
|
||||
['bash', bash],
|
||||
['go', go],
|
||||
['javascript', javascript],
|
||||
['json', json],
|
||||
['php', php],
|
||||
['python', python],
|
||||
] as const) {
|
||||
SyntaxHighlighter.registerLanguage(name, definition)
|
||||
}
|
||||
|
||||
// The previous guide hardcoded a dark slate background regardless of theme, so
|
||||
// code blocks were the only dark thing on a light page.
|
||||
export default function CodeBlock({
|
||||
|
||||
14
web/app/(app)/dashboard/messaging/loading.tsx
Normal file
14
web/app/(app)/dashboard/messaging/loading.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
// Shown in the content slot while a tab's page segment streams in; the
|
||||
// section header and tabs from the layout stay mounted above it.
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div role='status' className='max-w-3xl space-y-4'>
|
||||
<span className='sr-only'>Loading</span>
|
||||
<Skeleton className='h-8 w-48' />
|
||||
<Skeleton className='h-28 w-full rounded-lg' />
|
||||
<Skeleton className='h-28 w-full rounded-lg' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
web/app/(app)/dashboard/webhooks/loading.tsx
Normal file
14
web/app/(app)/dashboard/webhooks/loading.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
// Shown in the content slot while a tab's page segment streams in; the
|
||||
// section header and tabs from the layout stay mounted above it.
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div role='status' className='max-w-3xl space-y-4'>
|
||||
<span className='sr-only'>Loading</span>
|
||||
<Skeleton className='h-8 w-48' />
|
||||
<Skeleton className='h-28 w-full rounded-lg' />
|
||||
<Skeleton className='h-28 w-full rounded-lg' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
web/app/(app)/providers.test.tsx
Normal file
55
web/app/(app)/providers.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import Providers from './providers'
|
||||
import httpBrowserClient from '@/lib/httpBrowserClient'
|
||||
import { server } from '@/test/msw/server'
|
||||
import { API_BASE_URL, TEST_ACCESS_TOKEN } from '@/test/fixtures'
|
||||
import { mockSession } from '@/test/render'
|
||||
|
||||
// Own getSession mock (overriding the global one in test/setup.ts) so the
|
||||
// no-session-fetch guarantee below is a real call-count assertion.
|
||||
const getSessionSpy = vi.hoisted(() => vi.fn())
|
||||
vi.mock('next-auth/react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('next-auth/react')>()
|
||||
return { ...actual, getSession: getSessionSpy }
|
||||
})
|
||||
|
||||
function DefaultsProbe() {
|
||||
const queries = useQueryClient().getDefaultOptions().queries
|
||||
return (
|
||||
<div data-testid='defaults'>
|
||||
{`${queries?.staleTime}|${String(queries?.refetchOnWindowFocus)}|${String(
|
||||
queries?.retry
|
||||
)}`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Providers', () => {
|
||||
it('sets query defaults: 60s staleTime, no focus refetch, one retry', () => {
|
||||
render(
|
||||
<Providers session={mockSession}>
|
||||
<DefaultsProbe />
|
||||
</Providers>
|
||||
)
|
||||
expect(screen.getByTestId('defaults')).toHaveTextContent('60000|false|1')
|
||||
})
|
||||
|
||||
it('seeds the API token from the server session with zero session fetches', async () => {
|
||||
let authHeader: string | null = null
|
||||
server.use(
|
||||
http.get(`${API_BASE_URL}/ping`, ({ request }) => {
|
||||
authHeader = request.headers.get('authorization')
|
||||
return HttpResponse.json({ ok: true })
|
||||
})
|
||||
)
|
||||
|
||||
render(<Providers session={mockSession}>{null}</Providers>)
|
||||
await httpBrowserClient.get('/ping')
|
||||
|
||||
expect(authHeader).toBe(`Bearer ${TEST_ACCESS_TOKEN}`)
|
||||
expect(getSessionSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,33 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import { GoogleOAuthProvider } from '@react-oauth/google'
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
import { SessionProvider, useSession } from 'next-auth/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState, type PropsWithChildren } from 'react'
|
||||
import { useEffect, useState, type PropsWithChildren } from 'react'
|
||||
import type { Session } from 'next-auth'
|
||||
import { setSessionToken } from '@/lib/httpBrowserClient'
|
||||
|
||||
// Keeps the API client's token in sync with the session (login, logout, tab
|
||||
// broadcast). Reads from context only: the provider is seeded with the server
|
||||
// session, so this never hits /api/auth/session.
|
||||
function SessionTokenBridge() {
|
||||
const { data: session } = useSession()
|
||||
|
||||
useEffect(() => {
|
||||
setSessionToken(session?.user?.accessToken ?? null)
|
||||
}, [session])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Client-side provider tree for the app. The QueryClient is created once via
|
||||
// useState so it is stable across re-renders (previously a new client was
|
||||
// constructed on every render, throwing away the cache). Session expiry is
|
||||
// handled globally by a 401 response interceptor in httpBrowserClient, so there
|
||||
// is no longer a per-navigation whoAmI check here.
|
||||
// handled globally by a 401 response interceptor in httpBrowserClient, so
|
||||
// SessionProvider does not refetch on window focus and there is no
|
||||
// per-navigation whoAmI check here.
|
||||
export default function Providers({
|
||||
session,
|
||||
children,
|
||||
}: PropsWithChildren<{ session: Session | null }>) {
|
||||
const [queryClient] = useState(() => new QueryClient())
|
||||
const [queryClient] = useState(() => {
|
||||
// Seeded during the first render, before any child mounts and queries.
|
||||
setSessionToken(session?.user?.accessToken ?? null)
|
||||
|
||||
// staleTime 60s: mutations invalidate their keys, so the user's own
|
||||
// changes are always instant; only out-of-tab changes can lag a minute.
|
||||
// The old defaults (staleTime 0, refetch on focus) refetched every query
|
||||
// on every mount and window focus.
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ThemeProvider lives in the root layout (app/theme-provider.tsx) so its
|
||||
// pre-paint script is not re-rendered on client navigation.
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GoogleOAuthProvider
|
||||
clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? ''}
|
||||
>
|
||||
{children}
|
||||
</GoogleOAuthProvider>
|
||||
</QueryClientProvider>
|
||||
<SessionProvider session={session} refetchOnWindowFocus={false}>
|
||||
<SessionTokenBridge />
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
45
web/components/shared/route-tabs.test.tsx
Normal file
45
web/components/shared/route-tabs.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import RouteTabs from './route-tabs'
|
||||
|
||||
// Capture Link props: prefetch is router behavior, invisible in the DOM.
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, prefetch, ...rest }: any) => (
|
||||
<a href={href} data-prefetch={String(prefetch)} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/dashboard/messaging',
|
||||
}))
|
||||
|
||||
const tabs = [
|
||||
{ href: '/dashboard/messaging', label: 'Send', exact: true },
|
||||
{ href: '/dashboard/messaging/bulk', label: 'Bulk Send' },
|
||||
{ href: '/dashboard/messaging/history', label: 'History' },
|
||||
]
|
||||
|
||||
describe('RouteTabs', () => {
|
||||
it('opts every tab link into full prefetch', () => {
|
||||
render(<RouteTabs tabs={tabs} />)
|
||||
// These routes are dynamic (session cookie in the app layout); without an
|
||||
// explicit prefetch every tab click pays a full server round trip.
|
||||
for (const link of screen.getAllByRole('link')) {
|
||||
expect(link).toHaveAttribute('data-prefetch', 'true')
|
||||
}
|
||||
expect(screen.getAllByRole('link')).toHaveLength(tabs.length)
|
||||
})
|
||||
|
||||
it('marks only the active tab with aria-current', () => {
|
||||
render(<RouteTabs tabs={tabs} />)
|
||||
expect(screen.getByRole('link', { name: 'Send' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page'
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'Bulk Send' })
|
||||
).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
})
|
||||
@@ -20,7 +20,9 @@ function isTabActive(tab: RouteTab, pathname: string): boolean {
|
||||
|
||||
// Link-based segmented control: tabs are real routes, so the active tab
|
||||
// survives refresh and deep links are shareable. Mobile: horizontally
|
||||
// scrollable pills; the active pill scrolls into view on load.
|
||||
// scrollable pills; the active pill scrolls into view on load. Links opt into
|
||||
// full prefetch because these routes are dynamic (session cookie in the app
|
||||
// layout), which the router's default prefetch skips.
|
||||
export default function RouteTabs({
|
||||
tabs,
|
||||
className,
|
||||
@@ -54,6 +56,7 @@ export default function RouteTabs({
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
prefetch
|
||||
ref={active ? activeRef : undefined}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cn(
|
||||
|
||||
129
web/components/shared/support-hq-widget.test.tsx
Normal file
129
web/components/shared/support-hq-widget.test.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import SupportHQWidget from './support-hq-widget'
|
||||
|
||||
const useSessionMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('next-auth/react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('next-auth/react')>()
|
||||
return { ...actual, useSession: useSessionMock }
|
||||
})
|
||||
|
||||
const init = vi.fn()
|
||||
const destroy = vi.fn()
|
||||
|
||||
// A fresh object every call, the way SessionProvider hands one back after a
|
||||
// refetch even when the underlying user has not changed.
|
||||
const sessionWith = (overrides: Record<string, unknown> = {}) => ({
|
||||
data: {
|
||||
user: {
|
||||
id: 'user_1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phone: '',
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const scripts = () =>
|
||||
document.querySelectorAll('script[src*="supporthq-widget.js"]')
|
||||
|
||||
// jsdom does not fetch external scripts, so loading is simulated.
|
||||
const fireLoad = () =>
|
||||
scripts().forEach((s) => (s as HTMLScriptElement).onload?.(new Event('load')))
|
||||
|
||||
beforeEach(() => {
|
||||
init.mockClear()
|
||||
destroy.mockClear()
|
||||
// @ts-ignore
|
||||
window.SupportHQWidget = { init, destroy }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document
|
||||
.querySelectorAll('script[src*="supporthq-widget.js"]')
|
||||
.forEach((s) => s.remove())
|
||||
})
|
||||
|
||||
describe('SupportHQWidget', () => {
|
||||
it('injects the widget script once and initialises with the user metadata', () => {
|
||||
useSessionMock.mockReturnValue(sessionWith())
|
||||
render(<SupportHQWidget />)
|
||||
|
||||
expect(scripts()).toHaveLength(1)
|
||||
|
||||
fireLoad()
|
||||
expect(init).toHaveBeenCalledTimes(1)
|
||||
expect(init.mock.calls[0][0].metadata).toEqual({
|
||||
userId: 'user_1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phone: '',
|
||||
})
|
||||
})
|
||||
|
||||
// The reported defect: the effect depended on the session object, so a
|
||||
// refetch that returned identical data still destroyed and re-injected the
|
||||
// widget, closing any chat the user had open.
|
||||
it('does not reinitialise when a refetch returns identical data', () => {
|
||||
useSessionMock.mockReturnValue(sessionWith())
|
||||
const { rerender } = render(<SupportHQWidget />)
|
||||
fireLoad()
|
||||
|
||||
useSessionMock.mockReturnValue(sessionWith())
|
||||
rerender(<SupportHQWidget />)
|
||||
|
||||
expect(scripts()).toHaveLength(1)
|
||||
expect(destroy).not.toHaveBeenCalled()
|
||||
expect(init).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reinitialises when the user metadata actually changes', () => {
|
||||
useSessionMock.mockReturnValue(sessionWith())
|
||||
const { rerender } = render(<SupportHQWidget />)
|
||||
fireLoad()
|
||||
|
||||
useSessionMock.mockReturnValue(sessionWith({ email: 'new@example.com' }))
|
||||
rerender(<SupportHQWidget />)
|
||||
fireLoad()
|
||||
|
||||
expect(destroy).toHaveBeenCalledTimes(1)
|
||||
expect(init).toHaveBeenCalledTimes(2)
|
||||
expect(init.mock.calls[1][0].metadata.email).toBe('new@example.com')
|
||||
})
|
||||
|
||||
// destroy() tears down the widget but leaves the tag, so without an explicit
|
||||
// removal every re-run left another one behind.
|
||||
it('leaves no script tags behind when it unmounts', () => {
|
||||
useSessionMock.mockReturnValue(sessionWith())
|
||||
const { unmount } = render(<SupportHQWidget />)
|
||||
fireLoad()
|
||||
|
||||
unmount()
|
||||
|
||||
expect(scripts()).toHaveLength(0)
|
||||
expect(destroy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not initialise if the script loads after cleanup', () => {
|
||||
useSessionMock.mockReturnValue(sessionWith())
|
||||
const { unmount } = render(<SupportHQWidget />)
|
||||
|
||||
const pending = scripts()[0] as HTMLScriptElement
|
||||
unmount()
|
||||
// A cached src can resolve after the effect was torn down; initialising
|
||||
// then would leave a widget that nothing destroys.
|
||||
pending.onload?.(new Event('load'))
|
||||
|
||||
expect(init).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('omits metadata when nobody is signed in', () => {
|
||||
useSessionMock.mockReturnValue({ data: null })
|
||||
render(<SupportHQWidget />)
|
||||
fireLoad()
|
||||
|
||||
expect(init).toHaveBeenCalledTimes(1)
|
||||
expect(init.mock.calls[0][0]).not.toHaveProperty('metadata')
|
||||
})
|
||||
})
|
||||
@@ -3,32 +3,49 @@ import { useSession } from 'next-auth/react'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
export default function SupportHQWidget() {
|
||||
const { data: session } = useSession()
|
||||
|
||||
const { data: session } = useSession()
|
||||
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://cdn.supporthq.app/widget/latest/supporthq-widget.js'
|
||||
script.async = true
|
||||
// @ts-ignore
|
||||
script.onload = () => window.SupportHQWidget?.init({
|
||||
projectId: process.env.NEXT_PUBLIC_SUPPORT_HQ_PROJECT_ID,
|
||||
themeColor: process.env.NEXT_PUBLIC_SUPPORT_HQ_THEME_COLOR ?? '#2563eb',
|
||||
...(session?.user && {
|
||||
metadata: {
|
||||
userId: session.user.id || '',
|
||||
name: session.user.name || '',
|
||||
email: session.user.email || '',
|
||||
phone: session.user.phone || '',
|
||||
}
|
||||
})
|
||||
})
|
||||
document.body.appendChild(script)
|
||||
// @ts-ignore
|
||||
return () => { window.SupportHQWidget?.destroy() }
|
||||
}, [session])
|
||||
// Depended on as individual strings rather than as `session`: SessionProvider
|
||||
// hands back a new object on every refetch, and comparing that by reference
|
||||
// tore the widget down and re-injected its script even when nothing about the
|
||||
// user had changed, closing any open chat.
|
||||
const hasUser = Boolean(session?.user)
|
||||
const userId = session?.user?.id ?? ''
|
||||
const name = session?.user?.name ?? ''
|
||||
const email = session?.user?.email ?? ''
|
||||
const phone = session?.user?.phone ?? ''
|
||||
|
||||
return (
|
||||
<></>
|
||||
)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://cdn.supporthq.app/widget/latest/supporthq-widget.js'
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
// The script can finish loading after this effect was cleaned up (the
|
||||
// src is cached, so a re-run resolves immediately), and initialising
|
||||
// then would leave a widget behind that nothing destroys.
|
||||
if (cancelled) return
|
||||
// @ts-ignore
|
||||
window.SupportHQWidget?.init({
|
||||
projectId: process.env.NEXT_PUBLIC_SUPPORT_HQ_PROJECT_ID,
|
||||
themeColor: process.env.NEXT_PUBLIC_SUPPORT_HQ_THEME_COLOR ?? '#2563eb',
|
||||
...(hasUser && {
|
||||
metadata: { userId, name, email, phone },
|
||||
}),
|
||||
})
|
||||
}
|
||||
document.body.appendChild(script)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
// @ts-ignore
|
||||
window.SupportHQWidget?.destroy()
|
||||
// destroy() tears down the widget but leaves this tag behind, so each
|
||||
// re-run used to add another one for the life of the page.
|
||||
script.remove()
|
||||
}
|
||||
}, [hasUser, userId, name, email, phone])
|
||||
|
||||
return <></>
|
||||
}
|
||||
|
||||
@@ -65,6 +65,33 @@ test.describe('account settings (mocked API, no real backend)', () => {
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
// The sidebar used to link to /dashboard/account, whose page is a server
|
||||
// redirect stub, so every click paid navigation + redirect + navigation.
|
||||
test('the sidebar Account link goes straight to billing with no redirect hop', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
|
||||
const stubHits: string[] = []
|
||||
page.on('request', (request) => {
|
||||
if (new URL(request.url()).pathname === '/dashboard/account') {
|
||||
stubHits.push(request.url())
|
||||
}
|
||||
})
|
||||
|
||||
await page.goto('/dashboard')
|
||||
await page
|
||||
.getByRole('navigation', { name: 'Main' })
|
||||
.getByRole('link', { name: 'Account' })
|
||||
.click()
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/account\/billing$/)
|
||||
await expect(page.getByRole('heading', { name: 'Pro' })).toBeVisible()
|
||||
expect(stubHits).toEqual([])
|
||||
})
|
||||
|
||||
test('the pricing page is reachable from billing on any plan', async ({
|
||||
page,
|
||||
context,
|
||||
|
||||
@@ -55,14 +55,49 @@ test.describe('api guide (mocked API, no real backend)', () => {
|
||||
await expect(page.getByText('package main').first()).toBeVisible()
|
||||
})
|
||||
|
||||
// The guide applies Prism themes, so it must render with the Prism
|
||||
// highlighter. It previously used the package's default export, which is the
|
||||
// highlight.js build: that emits hljs-* classes the Prism theme never styles
|
||||
// (so samples went uncoloured) and compiles in every language it supports.
|
||||
test('samples are highlighted by Prism, in every language', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging/api-guide')
|
||||
|
||||
for (const language of ['cURL', 'Node.js', 'Python', 'PHP', 'Go']) {
|
||||
await page.getByRole('tab', { name: language, exact: true }).click()
|
||||
await expect(
|
||||
page.locator('pre code .token').first(),
|
||||
`${language} samples must carry Prism token markup`
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
// A registered language yields more than one token type; a missing one
|
||||
// would fall back to a single undifferentiated text run.
|
||||
expect(
|
||||
await page.locator('pre code .token').count()
|
||||
).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
test('code can be copied', async ({ page, context }) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await page.goto('/dashboard/messaging/api-guide')
|
||||
// navigator.clipboard.writeText rejects when the document is not focused,
|
||||
// which is the state a backgrounded page sits in while workers run in
|
||||
// parallel. Granting permissions alone does not cover it.
|
||||
await page.bringToFront()
|
||||
|
||||
await page.getByRole('button', { name: 'Copy code' }).first().click()
|
||||
|
||||
// The button relabels itself only after the async clipboard write resolves,
|
||||
// so this is the signal that there is something to read back.
|
||||
await expect(page.getByRole('button', { name: 'Copied' }).first()).toBeVisible()
|
||||
|
||||
const clipboard = await page.evaluate(() =>
|
||||
navigator.clipboard.readText()
|
||||
)
|
||||
@@ -74,7 +109,10 @@ test.describe('api guide (mocked API, no real backend)', () => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging/api-guide')
|
||||
await page.waitForLoadState('networkidle')
|
||||
// Not networkidle: link prefetching keeps the network busy, so that state
|
||||
// can go unreached under parallel workers. A rendered sample proves the
|
||||
// code blocks (the thing that would widen the page) are laid out.
|
||||
await expect(page.getByText('curl -X POST').first()).toBeVisible()
|
||||
|
||||
// Code blocks are the classic way to widen a mobile page.
|
||||
const { scrollWidth, innerWidth } = await page.evaluate(() => ({
|
||||
|
||||
@@ -23,6 +23,20 @@ async function uploadCsv(page: import('@playwright/test').Page, csv: string) {
|
||||
})
|
||||
}
|
||||
|
||||
// The section streams behind a loading.tsx boundary, so the form's HTML can
|
||||
// paint before React hydrates it, and a file offered during that gap is
|
||||
// silently discarded (react-dropzone binds its change handler on mount).
|
||||
// The row cap in the dropzone copy is derived from useSubscription inside this
|
||||
// page's own hook, so seeing it proves this component hydrated and has data.
|
||||
// Waiting on the devices response would not: the dashboard layout requests the
|
||||
// same query key, so it can resolve before this page has hydrated.
|
||||
async function gotoBulkPage(page: import('@playwright/test').Page) {
|
||||
await page.goto('/dashboard/messaging/bulk')
|
||||
await expect(
|
||||
page.getByText(/Up to 1 MB and [\d,]+ rows on your plan/)
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
// Keyboard selection rather than clicking the option: the Radix popper can
|
||||
// render outside the viewport on a short page, which made the click flaky.
|
||||
async function selectDevice(page: import('@playwright/test').Page) {
|
||||
@@ -51,7 +65,7 @@ test.describe('bulk send (mocked API, no real backend)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto('/dashboard/messaging/bulk')
|
||||
await gotoBulkPage(page)
|
||||
|
||||
await uploadCsv(page, CSV_WITH_PROBLEM_ROWS)
|
||||
|
||||
@@ -90,7 +104,7 @@ test.describe('bulk send (mocked API, no real backend)', () => {
|
||||
test('offers a downloadable sample CSV', async ({ page, context }) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging/bulk')
|
||||
await gotoBulkPage(page)
|
||||
|
||||
const link = page.getByRole('link', { name: /download a sample csv/i })
|
||||
await expect(link).toBeVisible()
|
||||
@@ -110,7 +124,7 @@ test.describe('bulk send (mocked API, no real backend)', () => {
|
||||
}) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging/bulk')
|
||||
await gotoBulkPage(page)
|
||||
|
||||
// The old copy printed the raw byte count ("max 1048576 bytes").
|
||||
await expect(page.getByText('1048576')).toHaveCount(0)
|
||||
@@ -124,7 +138,7 @@ test.describe('bulk send (mocked API, no real backend)', () => {
|
||||
await page.setViewportSize({ width: 375, height: 800 })
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging/bulk')
|
||||
await gotoBulkPage(page)
|
||||
|
||||
// A wide table is the obvious way to reintroduce the sideways-scroll bug,
|
||||
// and it only exists once a file has been parsed.
|
||||
@@ -144,7 +158,7 @@ test.describe('bulk send (mocked API, no real backend)', () => {
|
||||
}) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging/bulk')
|
||||
await gotoBulkPage(page)
|
||||
|
||||
await uploadCsv(page, CSV_WITH_PROBLEM_ROWS)
|
||||
await selectDevice(page)
|
||||
@@ -165,7 +179,7 @@ test.describe('bulk send (mocked API, no real backend)', () => {
|
||||
}) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging/bulk')
|
||||
await gotoBulkPage(page)
|
||||
|
||||
await uploadCsv(
|
||||
page,
|
||||
@@ -187,7 +201,7 @@ test.describe('bulk send (mocked API, no real backend)', () => {
|
||||
test('explains why a non-CSV file was rejected', async ({ page, context }) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging/bulk')
|
||||
await gotoBulkPage(page)
|
||||
|
||||
// Rejected drops used to return silently, so nothing happened and nothing
|
||||
// said why.
|
||||
|
||||
@@ -42,23 +42,29 @@ test.describe('app chrome (mocked API, no real backend)', () => {
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard')
|
||||
|
||||
// Scroll to the very bottom so the footer is in view.
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
|
||||
|
||||
const footer = page.locator('footer')
|
||||
await expect(footer).toBeVisible()
|
||||
const tabBar = page.locator('nav.fixed').first()
|
||||
|
||||
const footerBox = await footer.boundingBox()
|
||||
const tabBarBox = await page.locator('nav.fixed').first().boundingBox()
|
||||
|
||||
expect(footerBox, 'footer must have a layout box').not.toBeNull()
|
||||
expect(tabBarBox, 'mobile tab bar must have a layout box').not.toBeNull()
|
||||
|
||||
// The footer's last pixel must sit above the tab bar's first pixel.
|
||||
expect(
|
||||
footerBox!.y + footerBox!.height,
|
||||
'footer bottom must clear the fixed tab bar'
|
||||
).toBeLessThanOrEqual(tabBarBox!.y + 1)
|
||||
// Scrolled again on every attempt rather than once up front: the page
|
||||
// starts as a short loading skeleton and grows as each card's data lands,
|
||||
// so a single scroll to the bottom can be measured from a document that is
|
||||
// still shorter than it ends up, leaving the footer below the viewport.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await page.evaluate(() =>
|
||||
window.scrollTo(0, document.body.scrollHeight)
|
||||
)
|
||||
const footerBox = await footer.boundingBox()
|
||||
const tabBarBox = await tabBar.boundingBox()
|
||||
if (!footerBox || !tabBarBox) return Number.POSITIVE_INFINITY
|
||||
// The footer's last pixel must sit above the tab bar's first pixel.
|
||||
return footerBox.y + footerBox.height - (tabBarBox.y + 1)
|
||||
},
|
||||
{ message: 'footer bottom must clear the fixed tab bar' }
|
||||
)
|
||||
.toBeLessThanOrEqual(0)
|
||||
})
|
||||
|
||||
test('mobile: footer links stack vertically', async ({ page, context }) => {
|
||||
|
||||
@@ -15,7 +15,10 @@ const AUTHED_PAGES = [
|
||||
'/dashboard/messaging/api-guide',
|
||||
'/dashboard/webhooks',
|
||||
'/dashboard/webhooks/deliveries',
|
||||
'/dashboard/account',
|
||||
// The billing tab, not /dashboard/account: that route is a redirect stub
|
||||
// with no layout of its own, and measuring it raced the redirect. The
|
||||
// redirect itself is covered in account.spec.ts.
|
||||
'/dashboard/account/billing',
|
||||
'/dashboard/account/profile',
|
||||
'/dashboard/account/security',
|
||||
'/dashboard/account/support',
|
||||
@@ -24,13 +27,22 @@ const AUTHED_PAGES = [
|
||||
'/dashboard/community',
|
||||
]
|
||||
|
||||
// Deterministic replacement for waitForLoadState('networkidle'), which needs a
|
||||
// 500ms window with zero connections. Link prefetching keeps issuing and
|
||||
// aborting RSC requests, so under parallel workers that window can be missed
|
||||
// until the test times out. Waiting for the page's own heading and for its
|
||||
// loading placeholders to clear proves the same thing: content is laid out.
|
||||
async function waitForContent(page: import('@playwright/test').Page) {
|
||||
await expect(page.locator('main#main-content h2').first()).toBeVisible()
|
||||
await expect(page.locator('main#main-content [role="status"]')).toHaveCount(0)
|
||||
}
|
||||
|
||||
async function expectNoHorizontalScroll(page: import('@playwright/test').Page) {
|
||||
const { scrollWidth, innerWidth } = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
innerWidth: window.innerWidth,
|
||||
}))
|
||||
expect(scrollWidth, 'page must not scroll sideways at 375px').toBeLessThanOrEqual(
|
||||
innerWidth
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - window.innerWidth
|
||||
)
|
||||
expect(overflow, 'page must not scroll sideways at 375px').toBeLessThanOrEqual(
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,7 +54,7 @@ test.describe('no horizontal overflow at 375px (mocked API)', () => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto(path)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await waitForContent(page)
|
||||
await expectNoHorizontalScroll(page)
|
||||
})
|
||||
}
|
||||
@@ -70,7 +82,7 @@ test.describe('no horizontal overflow at 375px (mocked API)', () => {
|
||||
test('login page', async ({ page }) => {
|
||||
await mockApi(page)
|
||||
await page.goto('/login')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page.getByText('Welcome back')).toBeVisible()
|
||||
await expectNoHorizontalScroll(page)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,16 @@ async function captureSend(page: import('@playwright/test').Page) {
|
||||
return payloads
|
||||
}
|
||||
|
||||
// The section streams behind a loading.tsx boundary, so the form's HTML can
|
||||
// paint before React hydrates it; typing that early into the controlled To
|
||||
// input is silently wiped when hydration commits. The device name renders
|
||||
// only after the client-side devices fetch, so its presence proves the form
|
||||
// is hydrated and its handlers are attached.
|
||||
async function gotoSendPage(page: import('@playwright/test').Page) {
|
||||
await page.goto('/dashboard/messaging')
|
||||
await expect(page.getByLabel('Send from')).toContainText('Pixel 8')
|
||||
}
|
||||
|
||||
test.describe('send sms (mocked API, no real backend)', () => {
|
||||
test('commits recipients as chips and posts exactly those numbers', async ({
|
||||
page,
|
||||
@@ -24,7 +34,7 @@ test.describe('send sms (mocked API, no real backend)', () => {
|
||||
await mockApi(page)
|
||||
const payloads = await captureSend(page)
|
||||
|
||||
await page.goto('/dashboard/messaging')
|
||||
await gotoSendPage(page)
|
||||
|
||||
const to = page.getByLabel('To', { exact: true })
|
||||
await to.fill('+1 (415) 555-0101')
|
||||
@@ -57,7 +67,7 @@ test.describe('send sms (mocked API, no real backend)', () => {
|
||||
}) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging')
|
||||
await gotoSendPage(page)
|
||||
|
||||
await page
|
||||
.getByLabel('To', { exact: true })
|
||||
@@ -77,7 +87,7 @@ test.describe('send sms (mocked API, no real backend)', () => {
|
||||
}) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging')
|
||||
await gotoSendPage(page)
|
||||
|
||||
// Spaces are formatting inside one number here, not separators.
|
||||
const to = page.getByLabel('To', { exact: true })
|
||||
@@ -94,7 +104,7 @@ test.describe('send sms (mocked API, no real backend)', () => {
|
||||
test('rejects a number that cannot be dialled', async ({ page, context }) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging')
|
||||
await gotoSendPage(page)
|
||||
|
||||
const to = page.getByLabel('To', { exact: true })
|
||||
await to.fill('not a number')
|
||||
@@ -113,7 +123,7 @@ test.describe('send sms (mocked API, no real backend)', () => {
|
||||
await mockApi(page)
|
||||
const payloads = await captureSend(page)
|
||||
|
||||
await page.goto('/dashboard/messaging')
|
||||
await gotoSendPage(page)
|
||||
|
||||
// Type a number then go straight to the message without pressing Enter.
|
||||
await page.getByLabel('To', { exact: true }).fill('+14155550101')
|
||||
@@ -127,7 +137,7 @@ test.describe('send sms (mocked API, no real backend)', () => {
|
||||
test('preselects the only enabled device', async ({ page, context }) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging')
|
||||
await gotoSendPage(page)
|
||||
|
||||
// Fixtures have one enabled device (Pixel 8) and one disabled. The old
|
||||
// implementation computed this in defaultValues before devices loaded, so
|
||||
@@ -138,7 +148,7 @@ test.describe('send sms (mocked API, no real backend)', () => {
|
||||
test('counts SMS segments', async ({ page, context }) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging')
|
||||
await gotoSendPage(page)
|
||||
|
||||
await page.getByLabel('Message').fill('a'.repeat(161))
|
||||
await expect(page.getByText(/161 characters, 2 segments/)).toBeVisible()
|
||||
@@ -149,7 +159,7 @@ test.describe('send sms (mocked API, no real backend)', () => {
|
||||
await mockApi(page)
|
||||
await captureSend(page)
|
||||
|
||||
await page.goto('/dashboard/messaging')
|
||||
await gotoSendPage(page)
|
||||
const to = page.getByLabel('To', { exact: true })
|
||||
await to.fill('+14155550101')
|
||||
await to.press('Enter')
|
||||
|
||||
46
web/e2e/session-calls.spec.ts
Normal file
46
web/e2e/session-calls.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { authenticate } from './session'
|
||||
import { mockApi } from './mock-api'
|
||||
|
||||
// Serverless-cost regression guard. The API client's token is seeded from the
|
||||
// server-fetched session, so browsing the dashboard must not keep calling
|
||||
// /api/auth/session. Before this guard, the axios interceptor refetched the
|
||||
// session every 2 minutes with no dedupe, so a burst of queries fanned out
|
||||
// one session call each.
|
||||
test('dashboard navigation makes at most one /api/auth/session call', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
|
||||
let sessionCalls = 0
|
||||
page.on('request', (request) => {
|
||||
if (new URL(request.url()).pathname === '/api/auth/session') {
|
||||
sessionCalls += 1
|
||||
}
|
||||
})
|
||||
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Welcome back, Test', level: 2 })
|
||||
).toBeVisible()
|
||||
|
||||
const mainNav = page.getByRole('navigation', { name: 'Main' })
|
||||
const tabs = page.getByRole('navigation', { name: 'Section navigation' })
|
||||
|
||||
await mainNav.getByRole('link', { name: 'Messaging' }).click()
|
||||
await expect(page).toHaveURL(/\/dashboard\/messaging$/)
|
||||
await tabs.getByRole('link', { name: 'History' }).click()
|
||||
await expect(page).toHaveURL(/\/dashboard\/messaging\/history$/)
|
||||
|
||||
await mainNav.getByRole('link', { name: 'Webhooks' }).click()
|
||||
await expect(page).toHaveURL(/\/dashboard\/webhooks$/)
|
||||
await tabs.getByRole('link', { name: 'Deliveries' }).click()
|
||||
await expect(page).toHaveURL(/\/dashboard\/webhooks\/deliveries$/)
|
||||
|
||||
await mainNav.getByRole('link', { name: 'Account' }).click()
|
||||
await expect(page).toHaveURL(/\/dashboard\/account\/billing$/)
|
||||
|
||||
expect(sessionCalls).toBeLessThanOrEqual(1)
|
||||
})
|
||||
@@ -358,6 +358,9 @@ export function useWebhookNotifications(filters: WebhookNotificationFilters) {
|
||||
`${ApiEndpoints.gateway.getWebhookNotifications()}?eventType=${eventType}&page=${page}&limit=${limit}&status=${status}&start=${start}&end=${end}&deviceId=${deviceId}&webhookSubscriptionId=${webhookSubscriptionId}`
|
||||
)
|
||||
.then(unwrapBody<WebhookNotificationsEnvelope>),
|
||||
// Deliveries arrive from outside the tab, so stay fresher than the 60s
|
||||
// client-wide default.
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -420,6 +423,9 @@ export function useDeviceMessages(
|
||||
.get(`${ApiEndpoints.gateway.getMessages(deviceId)}?${query}`)
|
||||
.then(unwrapBody<DeviceMessagesEnvelope>)
|
||||
},
|
||||
// Inbound messages arrive from outside the tab, so stay fresher than the
|
||||
// 60s client-wide default. Before ...options so callers can override.
|
||||
staleTime: 15_000,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
83
web/lib/httpBrowserClient.test.ts
Normal file
83
web/lib/httpBrowserClient.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '@/test/msw/server'
|
||||
import { API_BASE_URL } from '@/test/fixtures'
|
||||
|
||||
// Own getSession mock (overriding the global one in test/setup.ts) so calls
|
||||
// can be counted and resolved per test.
|
||||
const getSessionMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('next-auth/react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('next-auth/react')>()
|
||||
return { ...actual, getSession: getSessionMock }
|
||||
})
|
||||
|
||||
// The token lives in module state, so each test loads a fresh module copy.
|
||||
async function loadClient() {
|
||||
vi.resetModules()
|
||||
return import('@/lib/httpBrowserClient')
|
||||
}
|
||||
|
||||
function captureAuthHeaders() {
|
||||
const headers: (string | null)[] = []
|
||||
server.use(
|
||||
http.get(`${API_BASE_URL}/ping`, ({ request }) => {
|
||||
headers.push(request.headers.get('authorization'))
|
||||
return HttpResponse.json({ ok: true })
|
||||
})
|
||||
)
|
||||
return headers
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
getSessionMock.mockReset()
|
||||
})
|
||||
|
||||
describe('httpBrowserClient auth interceptor', () => {
|
||||
it('attaches a seeded token without calling getSession', async () => {
|
||||
const { default: client, setSessionToken } = await loadClient()
|
||||
setSessionToken('abc')
|
||||
const headers = captureAuthHeaders()
|
||||
|
||||
await client.get('/ping')
|
||||
|
||||
expect(headers).toEqual(['Bearer abc'])
|
||||
expect(getSessionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dedupes concurrent session fetches when the token is not seeded', async () => {
|
||||
getSessionMock.mockResolvedValue({ user: { accessToken: 'tok1' } })
|
||||
const { default: client } = await loadClient()
|
||||
const headers = captureAuthHeaders()
|
||||
|
||||
await Promise.all([client.get('/ping'), client.get('/ping')])
|
||||
|
||||
expect(getSessionMock).toHaveBeenCalledTimes(1)
|
||||
expect(headers).toEqual(['Bearer tok1', 'Bearer tok1'])
|
||||
})
|
||||
|
||||
it('sends no header and no session fetch when seeded signed out', async () => {
|
||||
const { default: client, setSessionToken } = await loadClient()
|
||||
// null means known signed out (the 401 handler and the auth pages), so
|
||||
// requests must not fall back to a /api/auth/session round trip.
|
||||
setSessionToken(null)
|
||||
const headers = captureAuthHeaders()
|
||||
|
||||
await client.get('/ping')
|
||||
|
||||
expect(headers).toEqual([null])
|
||||
expect(getSessionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recovers with a fresh fetch after a failed session lookup', async () => {
|
||||
getSessionMock.mockRejectedValueOnce(new Error('network down'))
|
||||
getSessionMock.mockResolvedValueOnce({ user: { accessToken: 'tok2' } })
|
||||
const { default: client } = await loadClient()
|
||||
const headers = captureAuthHeaders()
|
||||
|
||||
await expect(client.get('/ping')).rejects.toThrow('network down')
|
||||
await client.get('/ping')
|
||||
|
||||
expect(getSessionMock).toHaveBeenCalledTimes(2)
|
||||
expect(headers).toEqual(['Bearer tok2'])
|
||||
})
|
||||
})
|
||||
@@ -5,32 +5,41 @@ const httpBrowserClient = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || '',
|
||||
})
|
||||
|
||||
// Cache for session data to reduce API calls
|
||||
let sessionCache: any = null
|
||||
let cacheTimestamp = 0
|
||||
const CACHE_DURATION = 2 * 60 * 1000 // 2 minutes
|
||||
// API access token, seeded from the server-fetched session by Providers and
|
||||
// kept current by its SessionTokenBridge. Held in module state so the request
|
||||
// interceptor attaches it synchronously instead of paying a /api/auth/session
|
||||
// round trip per request (Vercel cost and added latency). undefined means not
|
||||
// seeded yet; null means known signed out, so auth pages never fetch either.
|
||||
let sessionToken: string | null | undefined
|
||||
|
||||
const getCachedSession = async () => {
|
||||
const now = Date.now()
|
||||
|
||||
// Return cached session if it's still valid
|
||||
if (sessionCache && (now - cacheTimestamp) < CACHE_DURATION) {
|
||||
return sessionCache
|
||||
export function setSessionToken(token: string | null) {
|
||||
sessionToken = token
|
||||
}
|
||||
|
||||
// Fallback for the rare request that fires before the token is seeded (deep
|
||||
// hard load). One shared promise so a burst of queries costs one session call.
|
||||
let sessionFetch: Promise<string | null> | null = null
|
||||
|
||||
function fetchSessionToken() {
|
||||
if (!sessionFetch) {
|
||||
sessionFetch = getSession()
|
||||
.then((session) => {
|
||||
sessionToken = session?.user?.accessToken ?? null
|
||||
return sessionToken
|
||||
})
|
||||
.finally(() => {
|
||||
sessionFetch = null
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch fresh session and update cache
|
||||
const session = await getSession()
|
||||
sessionCache = session
|
||||
cacheTimestamp = now
|
||||
|
||||
return session
|
||||
return sessionFetch
|
||||
}
|
||||
|
||||
httpBrowserClient.interceptors.request.use(async (config) => {
|
||||
const session = await getCachedSession()
|
||||
const token =
|
||||
sessionToken === undefined ? await fetchSessionToken() : sessionToken
|
||||
|
||||
if (session?.user?.accessToken) {
|
||||
config.headers.Authorization = `Bearer ${session.user.accessToken}`
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
@@ -47,7 +56,7 @@ httpBrowserClient.interceptors.response.use(
|
||||
) {
|
||||
const { pathname } = window.location
|
||||
if (!pathname.includes('/logout') && !pathname.includes('/login')) {
|
||||
sessionCache = null
|
||||
setSessionToken(null)
|
||||
window.location.href = '/logout'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { cleanup } from '@testing-library/react'
|
||||
import { afterAll, afterEach, beforeAll, vi } from 'vitest'
|
||||
import { server } from './msw/server'
|
||||
|
||||
// The axios browser client's interceptor calls next-auth's getSession(), which
|
||||
// would otherwise trigger a real /api/auth/session fetch. Mock it so the
|
||||
// interceptor can attach the Bearer token without any network access.
|
||||
// The axios browser client's interceptor falls back to next-auth's
|
||||
// getSession() when no token has been seeded (tests render components without
|
||||
// the app's Providers), which would otherwise trigger a real /api/auth/session
|
||||
// fetch. Mock it so the interceptor can attach the Bearer token without any
|
||||
// network access.
|
||||
const hoisted = vi.hoisted(() => ({ accessToken: 'test-access-token' }))
|
||||
vi.mock('next-auth/react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('next-auth/react')>()
|
||||
|
||||
Reference in New Issue
Block a user