mirror of
https://github.com/vernu/textbee.git
synced 2026-07-30 17:07:46 -04:00
Switching tabs shifted the layout. The cause was broader than History being full width: all four subroutes set their own width, so nothing lined up. Send max-w-xl 576px Bulk max-w-3xl 768px API max-w-4xl 896px History none full bleed The header and tab strip were also full width while the view beneath them was constrained, so on Send the tabs ran roughly 200px past the form. The column is now defined once in the messaging layout and wraps the header, the tabs and the children together. The per-page wrappers are gone, so a new subroute inherits the width instead of inventing one. Guard: an e2e visits all four tabs, measures the view rendered beneath the tab strip, and asserts the widths match and that the column is constrained rather than stretched. Getting that guard to actually work took three attempts, each a real hole: - Comparing the column against the viewport passed even with the constraint removed, because the sidebar and padding already made it narrower. - Comparing against the parent's border box passed too, since that box includes the padding the column sits inside. It now subtracts the padding. - Measuring the layout column could not detect a page re-constraining itself, because that column is identical on every tab by construction. It now measures the rendered view instead. Verified by reintroducing both regressions separately and watching it fail each one, with the offending tab named in the message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
125 lines
4.4 KiB
TypeScript
125 lines
4.4 KiB
TypeScript
import { expect, test } from '@playwright/test'
|
|
import { authenticate } from './session'
|
|
import { mockApi } from './mock-api'
|
|
|
|
test.describe('messaging (mocked API, no real backend)', () => {
|
|
test('renders the send view with route tabs', async ({ page, context }) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
await page.goto('/dashboard/messaging')
|
|
|
|
await expect(
|
|
page.getByRole('heading', { name: 'Messaging', level: 2 })
|
|
).toBeVisible()
|
|
// Route tabs are links now.
|
|
const nav = page.getByRole('navigation', { name: 'Section navigation' })
|
|
await expect(nav.getByRole('link', { name: 'Send', exact: true })).toBeVisible()
|
|
await expect(nav.getByRole('link', { name: 'History' })).toBeVisible()
|
|
// Send is the active tab on the index route.
|
|
await expect(
|
|
nav.getByRole('link', { name: 'Send', exact: true })
|
|
).toHaveAttribute('aria-current', 'page')
|
|
})
|
|
|
|
test('history tab navigates to its own URL and renders mocked messages', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
await page.goto('/dashboard/messaging')
|
|
|
|
await page
|
|
.getByRole('navigation', { name: 'Section navigation' })
|
|
.getByRole('link', { name: 'History' })
|
|
.click()
|
|
|
|
await expect(page).toHaveURL(/\/dashboard\/messaging\/history/)
|
|
await expect(page.getByText('Hello from textbee')).toBeVisible()
|
|
})
|
|
|
|
test('history deep link survives refresh (route-based tabs)', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
|
|
// Load the subroute directly: this is the refresh-survival guarantee.
|
|
await page.goto('/dashboard/messaging/history')
|
|
|
|
const nav = page.getByRole('navigation', { name: 'Section navigation' })
|
|
await expect(nav.getByRole('link', { name: 'History' })).toHaveAttribute(
|
|
'aria-current',
|
|
'page'
|
|
)
|
|
await expect(page.getByText('Hello from textbee')).toBeVisible()
|
|
// Never fell through to an error boundary.
|
|
await expect(page.getByText('This page couldn')).toHaveCount(0)
|
|
})
|
|
|
|
test('every tab renders in the same content column', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 })
|
|
await authenticate(context)
|
|
await mockApi(page)
|
|
|
|
// Each subroute used to set its own width (Send xl, Bulk 3xl, API 4xl,
|
|
// History none), so the layout jumped on every tab change.
|
|
const columns: Record<string, number> = {}
|
|
let available = 0
|
|
for (const path of [
|
|
'/dashboard/messaging',
|
|
'/dashboard/messaging/bulk',
|
|
'/dashboard/messaging/history',
|
|
'/dashboard/messaging/api-guide',
|
|
]) {
|
|
await page.goto(path)
|
|
await expect(
|
|
page.getByRole('navigation', { name: 'Section navigation' })
|
|
).toBeVisible()
|
|
|
|
// Measure the view rendered beneath the tabs, not the layout column.
|
|
// The column is identical on every tab by construction, so measuring it
|
|
// cannot detect a page that re-constrains its own content.
|
|
const measured = await page.evaluate(() => {
|
|
const nav = document.querySelector(
|
|
'nav[aria-label="Section navigation"]'
|
|
)
|
|
const content = nav!.nextElementSibling as HTMLElement
|
|
const parent = nav!.parentElement!.parentElement!
|
|
const style = getComputedStyle(parent)
|
|
return {
|
|
content: Math.round(content.getBoundingClientRect().width),
|
|
// Content width, with the parent's padding subtracted. Using the
|
|
// border box would include that padding, so an unconstrained
|
|
// full-width column would still measure as narrower than its parent.
|
|
available: Math.round(
|
|
parent.clientWidth -
|
|
parseFloat(style.paddingLeft) -
|
|
parseFloat(style.paddingRight)
|
|
),
|
|
}
|
|
})
|
|
columns[path] = measured.content
|
|
available = measured.available
|
|
}
|
|
|
|
const widths = Object.values(columns)
|
|
expect(
|
|
new Set(widths).size,
|
|
`tabs should share one column width, got ${JSON.stringify(columns)}`
|
|
).toBe(1)
|
|
|
|
// Compared against the space actually available, not the viewport:
|
|
// dropping the constraint makes every tab equally full-bleed, which would
|
|
// otherwise satisfy the equality check above.
|
|
expect(
|
|
widths[0],
|
|
'the column should be constrained, not stretched to fill'
|
|
).toBeLessThan(available)
|
|
})
|
|
})
|