Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions apps/web/e2e/drawer-focus.spec.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -70,25 +72,37 @@ 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'])

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'])
Expand Down
15 changes: 15 additions & 0 deletions apps/web/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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.
Expand Down
30 changes: 22 additions & 8 deletions apps/web/e2e/link-moment.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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-')
})
17 changes: 16 additions & 1 deletion apps/web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down