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