mirror of
https://github.com/vernu/textbee.git
synced 2026-07-30 17:07:46 -04:00
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>
144 lines
4.8 KiB
TypeScript
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/)
|
|
})
|
|
})
|