+ {/* Left-aligned on mobile: centred links in a single column read as a
+ ragged stack with no common edge to scan down. */}
+
+
{links.map((link) => (
{
await expect(page.getByText('Reply from a customer')).toBeVisible()
})
+ // Checked at both widths: the mobile break was a header pinned to the same
+ // offset as the search bar, and the desktop one was a header covering rows
+ // and swallowing their clicks.
+ for (const viewport of [
+ { name: 'mobile', width: 390, height: 700 },
+ { name: 'desktop', width: 1280, height: 800 },
+ ]) {
+ test(`day headers never overlap message rows on ${viewport.name}`, async ({
+ page,
+ context,
+ }) => {
+ await page.setViewportSize({
+ width: viewport.width,
+ height: viewport.height,
+ })
+ await authenticate(context)
+ await mockApi(page)
+
+ // Enough messages across several days to force scrolling.
+ const at = (days: number, hours: number) =>
+ new Date(Date.now() - days * 86400000 - hours * 3600000).toISOString()
+ await page.route('**/api/v1/gateway/devices/*/messages*', (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ data: Array.from({ length: 12 }).map((_, i) => ({
+ _id: `m${i}`,
+ sender: `+2519403617${40 + i}`,
+ message: 'A message long enough to wrap onto a second line here',
+ status: 'received',
+ type: 'received',
+ receivedAt: at(Math.floor(i / 3), i),
+ createdAt: at(Math.floor(i / 3), i),
+ })),
+ meta: { total: 12, page: 1, limit: 20, totalPages: 1 },
+ }),
+ })
+ )
+
+ await page.goto('/dashboard/messaging/history')
+ await expect(page.getByRole('heading', { name: 'Today' })).toBeVisible()
+ await page.evaluate(() => window.scrollTo(0, 420))
+
+ const headers = await page.locator('h3').all()
+ const rows = await page.getByRole('button', { name: /\+2519/ }).all()
+
+ for (const header of headers) {
+ const hb = await header.boundingBox()
+ if (!hb) continue
+ for (const row of rows) {
+ const rb = await row.boundingBox()
+ if (!rb) continue
+ const overlap =
+ Math.min(hb.y + hb.height, rb.y + rb.height) - Math.max(hb.y, rb.y)
+ expect(
+ overlap,
+ 'a day header must not cover a message row'
+ ).toBeLessThanOrEqual(1)
+ }
+ }
+
+ // A covering header also swallowed the row's click, so the row must
+ // still be clickable after scrolling.
+ await page.getByRole('button', { name: /\+2519/ }).first().click()
+ await expect(page.getByRole('dialog')).toBeVisible()
+ })
+ }
+
test('search is sent to the server, not applied to the loaded page', async ({
page,
context,
diff --git a/web/test/fixtures.ts b/web/test/fixtures.ts
index f59ee59..f66cd00 100644
--- a/web/test/fixtures.ts
+++ b/web/test/fixtures.ts
@@ -107,9 +107,18 @@ export const mockWebhookNotifications = { data: [], total: 0 }
// The real endpoint populates `device` (select: _id brand model buildId
// enabled), so the fixtures carry it too: replying reads message.device._id,
// and without it the mocked path would not exercise what production does.
-// Dates are relative so the day-grouped list always has a "Today" section.
-const hoursAgo = (hours: number) =>
- new Date(Date.now() - hours * 60 * 60 * 1000).toISOString()
+// Anchored to local midnight rather than "N hours ago": a message 2 hours old
+// falls on the previous day when the suite runs just after midnight, which
+// made the Today/Yesterday grouping assertions depend on the wall clock.
+const startOfToday = () => {
+ const d = new Date()
+ d.setHours(0, 0, 0, 0)
+ return d.getTime()
+}
+// Now is always today and never in the future.
+const todayIso = () => new Date().toISOString()
+// An hour before local midnight is always yesterday.
+const yesterdayIso = () => new Date(startOfToday() - 60 * 60 * 1000).toISOString()
export const mockMessages = {
data: [
@@ -120,8 +129,8 @@ export const mockMessages = {
status: 'sent',
type: 'sent',
device: { _id: 'device_1', brand: 'Google', model: 'Pixel 8' },
- requestedAt: hoursAgo(2),
- createdAt: hoursAgo(2),
+ requestedAt: todayIso(),
+ createdAt: todayIso(),
},
{
_id: 'msg_2',
@@ -130,8 +139,8 @@ export const mockMessages = {
status: 'received',
type: 'received',
device: { _id: 'device_1', brand: 'Google', model: 'Pixel 8' },
- receivedAt: hoursAgo(30),
- createdAt: hoursAgo(30),
+ receivedAt: yesterdayIso(),
+ createdAt: yesterdayIso(),
},
],
meta: { total: 2, page: 1, limit: 20, totalPages: 1 },