Files
textbee/web/e2e/chrome.spec.ts
isra el 9913b7cbe7 feat: rebuild the send page with recipient chips and segment counting
Brings the send screen up to the standard of the rebuilt bulk send flow.

Fixes real defects:
- The single-device auto-select never worked. It was computed in
  defaultValues from devices?.data?.length === 1, but react-hook-form reads
  defaultValues once on mount, while the devices query is still pending, so
  the condition was always false. It now preselects once devices resolve.
- Submitting before devices loaded showed "Required" on the device field,
  which then auto-filled a moment later, leaving a stale error beside a
  populated field. Submit is disabled until devices load.
- Moved off the legacy raw useQuery(['devices']) plus devices.data, the shared
  cache key with a different unwrapped shape that caused the devices?.filter
  crash previously.
- Every field used a placeholder as its label. Placeholders vanish on focus
  and are not reliably announced, so all three now have real labels.

Recipients are chips instead of a growing stack of inputs. They commit on
Enter, comma or blur, so a number typed and left uncommitted is not silently
dropped at send time. Pasting a list adds several at once.

Splitting a pasted list needed care, and an e2e caught it: splitting on
whitespace shredded "+1 (415) 555-0101" into three fragments. Only unambiguous
separators split first; whitespace splitting is a fallback for tokens that
cannot be a single number.

Also adds a segment counter, inline alerts for success and failure instead of
bare text, and a form reset after sending that keeps the device selected.

Phone helpers moved to lib/sms.ts alongside the segment helpers, so the send
page and bulk send share one tested implementation.

One test correction: the desktop footer assertion required links to sit on
exactly one row, but the row layout wraps by design, so it failed whenever
font metrics pushed a link to a second line. It now asserts the property the
design actually guarantees, that links are not one-per-row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:04:35 +03:00

144 lines
4.8 KiB
TypeScript

import { expect, test } from '@playwright/test'
import { authenticate } from './session'
import { mockApi } from './mock-api'
// Guards for the app chrome: the footer used to be rendered above the
// dashboard's fixed sidebar, so the sidebar painted over its left edge, and the
// fixed mobile tab bar covered its bottom edge.
test.describe('app chrome (mocked API, no real backend)', () => {
test('desktop: sidebar does not overlap the footer', async ({
page,
context,
}) => {
await page.setViewportSize({ width: 1280, height: 900 })
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard')
const footer = page.locator('footer')
await expect(footer).toBeVisible()
const sidebar = page.locator('aside')
const footerBox = await footer.boundingBox()
const sidebarBox = await sidebar.boundingBox()
expect(footerBox, 'footer must have a layout box').not.toBeNull()
expect(sidebarBox, 'sidebar must have a layout box').not.toBeNull()
// The footer must start where the sidebar ends, not underneath it.
expect(
footerBox!.x,
'footer must start at or after the sidebar right edge'
).toBeGreaterThanOrEqual(sidebarBox!.x + sidebarBox!.width - 1)
})
test('mobile: bottom tab bar does not cover the footer', async ({
page,
context,
}) => {
await page.setViewportSize({ width: 375, height: 800 })
await authenticate(context)
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 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)
})
test('mobile: footer links stack vertically', async ({ page, context }) => {
await page.setViewportSize({ width: 375, height: 800 })
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard')
const links = page.locator('footer nav a')
const count = await links.count()
expect(count).toBeGreaterThan(2)
// Stacked means every link sits on its own row, so each y is distinct.
const ys: number[] = []
for (let i = 0; i < count; i += 1) {
const box = await links.nth(i).boundingBox()
expect(box).not.toBeNull()
ys.push(Math.round(box!.y))
}
expect(new Set(ys).size, 'each footer link should be on its own row').toBe(
count
)
})
test('desktop: footer links share rows instead of stacking', async ({
page,
context,
}) => {
await page.setViewportSize({ width: 1280, height: 900 })
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard')
// Text metrics decide wrapping, so wait for fonts before measuring.
await page.evaluate(() => document.fonts.ready)
const links = page.locator('footer nav a')
const count = await links.count()
const ys: number[] = []
for (let i = 0; i < count; i += 1) {
const box = await links.nth(i).boundingBox()
ys.push(Math.round(box!.y))
}
// The row layout wraps by design, so the guarantee is "not one per row",
// not "exactly one row": asserting a single row made this flaky whenever
// font metrics pushed a link onto a second line.
expect(new Set(ys).size).toBeLessThan(count)
})
test('top bar is reduced to brand and account', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard')
const header = page.locator('header')
await expect(
header.getByRole('link', { name: /contribute/i })
).toHaveCount(0)
await expect(header.getByRole('button', { name: 'Toggle theme' })).toHaveCount(
0
)
})
test('theme control lives in the sidebar', async ({ page, context }) => {
await page.setViewportSize({ width: 1280, height: 900 })
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard')
const themeGroup = page
.locator('aside')
.getByRole('group', { name: 'Color theme' })
await expect(themeGroup).toBeVisible()
await themeGroup.getByRole('button', { name: 'Dark' }).click()
await expect(page.locator('html')).toHaveClass(/dark/)
await themeGroup.getByRole('button', { name: 'Light' }).click()
await expect(page.locator('html')).not.toHaveClass(/dark/)
})
})