diff --git a/web/app/(app)/(auth)/(components)/login-with-google.tsx b/web/app/(app)/(auth)/(components)/login-with-google.tsx index 41329d3..d3d2603 100644 --- a/web/app/(app)/(auth)/(components)/login-with-google.tsx +++ b/web/app/(app)/(auth)/(components)/login-with-google.tsx @@ -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 ( + + + + ) +} + +function GoogleLoginButton() { const router = useRouter() const searchParams = useSearchParams() const redirect = searchParams.get('redirect') diff --git a/web/app/(app)/dashboard/messaging/(components)/api-guide/code-block.tsx b/web/app/(app)/dashboard/messaging/(components)/api-guide/code-block.tsx index d008b81..0b2ab08 100644 --- a/web/app/(app)/dashboard/messaging/(components)/api-guide/code-block.tsx +++ b/web/app/(app)/dashboard/messaging/(components)/api-guide/code-block.tsx @@ -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({ diff --git a/web/app/(app)/providers.tsx b/web/app/(app)/providers.tsx index d5813be..601d86e 100644 --- a/web/app/(app)/providers.tsx +++ b/web/app/(app)/providers.tsx @@ -1,6 +1,5 @@ 'use client' -import { GoogleOAuthProvider } from '@react-oauth/google' import { SessionProvider, useSession } from 'next-auth/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useEffect, useState, type PropsWithChildren } from 'react' @@ -54,13 +53,7 @@ export default function Providers({ return ( - - - {children} - - + {children} ) } diff --git a/web/e2e/api-guide.spec.ts b/web/e2e/api-guide.spec.ts index e0107f6..44d4c36 100644 --- a/web/e2e/api-guide.spec.ts +++ b/web/e2e/api-guide.spec.ts @@ -55,6 +55,33 @@ 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) @@ -63,6 +90,10 @@ test.describe('api guide (mocked API, no real backend)', () => { 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 +105,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(() => ({ diff --git a/web/e2e/bulk-send.spec.ts b/web/e2e/bulk-send.spec.ts index 7fcccc7..9328a3a 100644 --- a/web/e2e/bulk-send.spec.ts +++ b/web/e2e/bulk-send.spec.ts @@ -24,13 +24,17 @@ 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; interacting that early is silently lost. -// The devices query only fires after hydration effects run, so its response -// is proof the dropzone and inputs have their handlers attached. +// 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) { - const devicesLoaded = page.waitForResponse('**/gateway/devices') await page.goto('/dashboard/messaging/bulk') - await devicesLoaded + 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 diff --git a/web/e2e/chrome.spec.ts b/web/e2e/chrome.spec.ts index 40bea71..2c9001f 100644 --- a/web/e2e/chrome.spec.ts +++ b/web/e2e/chrome.spec.ts @@ -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 }) => { diff --git a/web/e2e/mobile-overflow.spec.ts b/web/e2e/mobile-overflow.spec.ts index 88b167a..72b0927 100644 --- a/web/e2e/mobile-overflow.spec.ts +++ b/web/e2e/mobile-overflow.spec.ts @@ -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) }) })