mirror of
https://github.com/vernu/textbee.git
synced 2026-07-31 09:28:14 -04:00
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>
179 lines
5.9 KiB
TypeScript
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')
|
|
})
|
|
})
|