diff --git a/apps/web/e2e/drawer-focus.spec.ts b/apps/web/e2e/drawer-focus.spec.ts index bb49f328..e145ca4b 100644 --- a/apps/web/e2e/drawer-focus.spec.ts +++ b/apps/web/e2e/drawer-focus.spec.ts @@ -1,6 +1,6 @@ import { expect, type Locator, type Page } from '@playwright/test' import { test } from './fixtures' -import { enterCreatedRoom, openCurrentRoomSettings } from './helpers' +import { enterCreatedRoom, openCurrentRoomSettings, waitForHydratedControl } from './helpers' /** * The shared drawer primitive is modal for the keyboard, not only for the mouse. @@ -38,7 +38,9 @@ const focusShape = (control: Locator) => test.beforeEach(async ({ page }) => { await page.goto('/new') - await page.getByTestId('room-name').fill('Focus behaviour') + const roomName = page.getByTestId('room-name') + await waitForHydratedControl(roomName) + await roomName.fill('Focus behaviour') await page.getByTestId('creator-name').fill('Ana') await page.getByTestId('create-room').click() await enterCreatedRoom(page) @@ -70,12 +72,20 @@ test('focus enters the sheet, stays inside it, and comes back to the trigger', a await page.keyboard.press('Enter') const switcher = page.getByTestId('room-switcher-sheet') await expect(switcher).toBeVisible({ timeout: 10_000 }) + const closeSwitcher = switcher.getByRole('button', { name: 'Close room switcher' }) const settingsTrigger = switcher.locator('[data-testid="room-switcher-settings"][data-current="true"]') const recentRoom = switcher.locator( '[data-testid="room-switcher-tile"][data-slug="focus-neighbour-brave-otter-lamp"]' ) - await recentRoom.focus() + // Drive the real keyboard order. Firefox deliberately does not apply + // `:focus-visible` to locator.focus(), because that is script focus rather + // than keyboard focus; Chromium happened to make the old shortcut pass. + await expect(closeSwitcher).toBeFocused() + await page.keyboard.press('Tab') + await expect(settingsTrigger).toBeFocused() + await page.keyboard.press('Tab') + await expect(recentRoom).toBeFocused() await expect .poll(() => focusShape(recentRoom)) .toEqual(['rgb(33, 28, 23)', 'solid', '2px', '-2px', '12px', '0px', '0px', '12px']) @@ -83,12 +93,16 @@ test('focus enters the sheet, stays inside it, and comes back to the trigger', a const recentSettings = switcher.locator( '[data-testid="room-switcher-settings"][data-slug="focus-neighbour-brave-otter-lamp"]' ) - await recentSettings.focus() + await page.keyboard.press('Tab') + await expect(recentSettings).toBeFocused() await expect .poll(() => focusShape(recentSettings)) .toEqual(['rgb(33, 28, 23)', 'solid', '2px', '-2px', '0px', '12px', '12px', '0px']) - await settingsTrigger.focus() + await page.keyboard.press('Shift+Tab') + await expect(recentRoom).toBeFocused() + await page.keyboard.press('Shift+Tab') + await expect(settingsTrigger).toBeFocused() await expect .poll(() => focusShape(settingsTrigger)) .toEqual(['rgb(33, 28, 23)', 'solid', '2px', '-2px', '0px', '12px', '12px', '0px']) diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index 2abb69e1..8f662f0d 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -10,6 +10,21 @@ import { expect, type Locator, type Page } from '@playwright/test' export const balanceCard = (page: Page, member: string): Locator => page.locator(`[data-testid="balance-card"][data-member="${member}"]`) +/** + * Wait until React owns a server-rendered control before typing into it. + * + * `page.goto()` can finish while WebKit is still hydrating. The input is already visible then, so + * Playwright can fill the inert server HTML and React immediately replaces that value with its + * initial state. Chromium usually hydrates quickly enough to hide the race. The attached props + * slot is React DOM's direct signal that its event handler is ready; waiting on it keeps the test + * coupled to the actual boundary instead of an arbitrary sleep. + */ +export async function waitForHydratedControl(control: Locator): Promise { + await expect + .poll(() => control.evaluate((element) => Object.keys(element).some((key) => key.startsWith('__reactProps$')))) + .toBe(true) +} + /** * Assert a member's net in minor units, off `data-net` — raw server truth rather than rendered * text, so no assertion catches a NumberFlow frame mid-animation. diff --git a/apps/web/e2e/link-moment.spec.ts b/apps/web/e2e/link-moment.spec.ts index c272310e..64cd8faf 100644 --- a/apps/web/e2e/link-moment.spec.ts +++ b/apps/web/e2e/link-moment.spec.ts @@ -1,10 +1,12 @@ import { expect } from '@playwright/test' import { test } from './fixtures' -import { openCurrentRoomSettings } from './helpers' +import { openCurrentRoomSettings, waitForHydratedControl } from './helpers' const createRoom = async (page: import('@playwright/test').Page, name: string) => { await page.goto('/new') - await page.getByTestId('room-name').fill(name) + const roomName = page.getByTestId('room-name') + await waitForHydratedControl(roomName) + await roomName.fill(name) await page.getByTestId('room-currency').selectOption('EUR') await page.getByTestId('creator-name').fill('Ana') await page.getByTestId('create-room').click() @@ -34,6 +36,17 @@ test('creation pauses at a concise roster checkpoint before entering the room', }) test('the in-room hand-off keeps copy inline and makes sharing the primary action', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, 'share', { configurable: true, value: undefined }) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: async (text: string) => { + ;(window as Window & { __copiedRoomText?: string }).__copiedRoomText = text + }, + }, + }) + }) const previewRequestPromise = page.waitForRequest((request) => request.url().includes('/opengraph-image')) await createRoom(page, 'Beer trip') const previewRequest = await previewRequestPromise @@ -80,12 +93,13 @@ test('the in-room hand-off keeps copy inline and makes sharing the primary actio expect(qrBox!.x).toBeGreaterThanOrEqual(cardBox!.x) expect(qrBox!.x + qrBox!.width).toBeLessThanOrEqual(cardBox!.x + cardBox!.width) - // Desktop Chromium has no native share sheet. The primary action still does - // useful work there: it copies the link and the inline icon confirms it. - await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { - origin: new URL(page.url()).origin, - }) - await page.evaluate(() => Object.defineProperty(navigator, 'share', { configurable: true, value: undefined })) + // Desktop browsers have no native share sheet in this matrix. Stub the + // clipboard contract directly: Firefox does not support Playwright's + // Chromium-only clipboard-read permission, but the product promise is the + // same — the primary action copies the room package and confirms it inline. await share.click() await expect(row.getByTestId('copy-link')).toHaveAccessibleName('Copied!') + await expect + .poll(() => page.evaluate(() => (window as Window & { __copiedRoomText?: string }).__copiedRoomText)) + .toContain('/r/beer-trip-') }) diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index b6ea84b0..2a52dbdc 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -4,6 +4,21 @@ const PORT = Number(process.env.E2E_PORT ?? 3100) const baseURL = process.env.E2E_BASE_URL ?? `http://localhost:${PORT}` const browserName = process.env.E2E_BROWSER === 'firefox' || process.env.E2E_BROWSER === 'webkit' ? process.env.E2E_BROWSER : 'chromium' +const iphone = devices['iPhone 14'] +/** + * Firefox cannot emulate WebKit's `isMobile` context flag. Feeding it the full iPhone descriptor + * creates a hybrid browser with a Safari user agent and makes viewport changes hang. Keep the + * same narrow/touch test surface on Firefox's own UA and supported desktop context instead. + */ +const mobileDevice = + browserName === 'firefox' + ? { + ...devices['Desktop Firefox'], + viewport: iphone.viewport, + screen: { width: 390, height: 844 }, + hasTouch: true, + } + : iphone /** * Mobile-first: 390x844 is the design target. @@ -71,7 +86,7 @@ export default defineConfig({ { name: 'mobile', use: { - ...devices['iPhone 14'], + ...mobileDevice, browserName, // The two projects model independent visitors. Keeping their // TEST-NET addresses distinct prevents one project's room/member