Files
textbee/web/e2e/send-sms.spec.ts
isra el ab96e731a6 test(web): wait for hydration before typing in messaging e2e specs
The messaging pages now stream behind a loading.tsx boundary, so their
HTML can paint before React hydrates. Tests that filled the To field or
dropped a CSV immediately after page.goto raced that window: the input
was there, the handlers were not, and the interaction was silently lost.
Under parallel workers the race lost often enough to fail runs at random.

Both specs now navigate through a helper that waits for the client-side
devices fetch, which only fires after hydration effects run, so the form
is proven interactive before the test touches it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 14:02:13 +03:00

179 lines
5.9 KiB
TypeScript

import { expect, test } from '@playwright/test'
import { authenticate } from './session'
import { mockApi } from './mock-api'
async function captureSend(page: import('@playwright/test').Page) {
const payloads: any[] = []
await page.route('**/api/v1/gateway/devices/*/send-sms', (route) => {
payloads.push(route.request().postDataJSON())
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: { success: true } }),
})
})
return payloads
}
// The section streams behind a loading.tsx boundary, so the form's HTML can
// paint before React hydrates it; typing that early into the controlled To
// input is silently wiped when hydration commits. The device name renders
// only after the client-side devices fetch, so its presence proves the form
// is hydrated and its handlers are attached.
async function gotoSendPage(page: import('@playwright/test').Page) {
await page.goto('/dashboard/messaging')
await expect(page.getByLabel('Send from')).toContainText('Pixel 8')
}
test.describe('send sms (mocked API, no real backend)', () => {
test('commits recipients as chips and posts exactly those numbers', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
const payloads = await captureSend(page)
await gotoSendPage(page)
const to = page.getByLabel('To', { exact: true })
await to.fill('+1 (415) 555-0101')
await to.press('Enter')
await to.fill('+16475550187')
await to.press('Enter')
// Both are chips now, each removable.
await expect(
page.getByRole('button', { name: 'Remove +14155550101' })
).toBeVisible()
await expect(
page.getByRole('button', { name: 'Remove +16475550187' })
).toBeVisible()
await page.getByLabel('Message').fill('Hello from the send page')
await page.getByRole('button', { name: /Send to 2 recipients/ }).click()
await expect(page.getByText('Message sent')).toBeVisible()
expect(payloads).toHaveLength(1)
// Formatting is stripped, because the API schema accepts digits with an
// optional leading plus.
expect(payloads[0].recipients).toEqual(['+14155550101', '+16475550187'])
expect(payloads[0].message).toBe('Hello from the send page')
})
test('splits a pasted list into separate chips', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await gotoSendPage(page)
await page
.getByLabel('To', { exact: true })
.fill('+14155550101, +16475550187 +12065550142')
await page.getByLabel('To', { exact: true }).press('Enter')
for (const number of ['+14155550101', '+16475550187', '+12065550142']) {
await expect(
page.getByRole('button', { name: `Remove ${number}` })
).toBeVisible()
}
})
test('keeps a spaced number whole instead of shredding it', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await gotoSendPage(page)
// Spaces are formatting inside one number here, not separators.
const to = page.getByLabel('To', { exact: true })
await to.fill('+1 415 555 0101')
await to.press('Enter')
await expect(
page.getByRole('button', { name: 'Remove +14155550101' })
).toBeVisible()
// Exactly one chip, not four fragments.
await expect(page.getByRole('button', { name: /^Remove/ })).toHaveCount(1)
})
test('rejects a number that cannot be dialled', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await gotoSendPage(page)
const to = page.getByLabel('To', { exact: true })
await to.fill('not a number')
await to.press('Enter')
await expect(
page.getByText(/does not look like a phone number/)
).toBeVisible()
})
test('does not silently drop a number typed but not committed', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
const payloads = await captureSend(page)
await gotoSendPage(page)
// Type a number then go straight to the message without pressing Enter.
await page.getByLabel('To', { exact: true }).fill('+14155550101')
await page.getByLabel('Message').fill('Committed on blur')
await page.getByRole('button', { name: /Send message/ }).click()
await expect(page.getByText('Message sent')).toBeVisible()
expect(payloads[0].recipients).toEqual(['+14155550101'])
})
test('preselects the only enabled device', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await gotoSendPage(page)
// Fixtures have one enabled device (Pixel 8) and one disabled. The old
// implementation computed this in defaultValues before devices loaded, so
// it never preselected anything.
await expect(page.getByLabel('Send from')).toContainText('Pixel 8')
})
test('counts SMS segments', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await gotoSendPage(page)
await page.getByLabel('Message').fill('a'.repeat(161))
await expect(page.getByText(/161 characters, 2 segments/)).toBeVisible()
})
test('clears the form after a successful send', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await captureSend(page)
await gotoSendPage(page)
const to = page.getByLabel('To', { exact: true })
await to.fill('+14155550101')
await to.press('Enter')
await page.getByLabel('Message').fill('One off')
await page.getByRole('button', { name: /Send message/ }).click()
await expect(page.getByText('Message sent')).toBeVisible()
await expect(page.getByLabel('Message')).toHaveValue('')
await expect(
page.getByRole('button', { name: 'Remove +14155550101' })
).toHaveCount(0)
// The device stays selected, since the next message usually goes out the
// same way.
await expect(page.getByLabel('Send from')).toContainText('Pixel 8')
})
})