|
1 | | -import { test, expect } from '@playwright/test'; |
| 1 | +import { test, expect, type Page } from '@playwright/test'; |
2 | 2 | import { setLocale, stubSupabaseSession } from './helpers/auth'; |
3 | 3 |
|
4 | 4 | test.beforeEach(async ({ page }) => { |
@@ -173,6 +173,133 @@ test('phone tab bar holds Tools, Current debate, Library, and Settings', async ( |
173 | 173 | expect(termsBox!.y + termsBox!.height).toBeLessThanOrEqual(tabBox!.y + 1); |
174 | 174 | }); |
175 | 175 |
|
| 176 | +async function paintViewport(page: Page, next: { height: number; offsetTop: number; scale: number }) { |
| 177 | + await page.evaluate((value) => { |
| 178 | + const viewport = window.visualViewport; |
| 179 | + if (!viewport) throw new Error('visualViewport is missing'); |
| 180 | + for (const key of ['height', 'offsetTop', 'scale'] as const) { |
| 181 | + Object.defineProperty(viewport, key, { configurable: true, get: () => value[key] }); |
| 182 | + } |
| 183 | + viewport.dispatchEvent(new Event('resize')); |
| 184 | + }, next); |
| 185 | +} |
| 186 | + |
| 187 | +test('phone tab bar hides for a keyboard and returns, and ignores pinch-zoom', async ({ page }) => { |
| 188 | + await page.setViewportSize({ width: 390, height: 844 }); |
| 189 | + await page.goto('/app/ask', { waitUntil: 'domcontentloaded' }); |
| 190 | + const field = page.getByRole('textbox', { name: 'Message to your debate coach' }); |
| 191 | + const tabBar = page.getByRole('navigation', { name: 'Mobile navigation' }); |
| 192 | + await expect(field).toBeVisible({ timeout: 15_000 }); |
| 193 | + await expect(tabBar).toBeVisible(); |
| 194 | + |
| 195 | + await field.focus(); |
| 196 | + await paintViewport(page, { height: 380, offsetTop: 0, scale: 1.5 }); |
| 197 | + await expect(tabBar).toBeVisible(); |
| 198 | + |
| 199 | + await paintViewport(page, { height: 380, offsetTop: 0, scale: 1 }); |
| 200 | + await expect(tabBar).toBeHidden(); |
| 201 | + const hidden = await page.evaluate(() => ({ |
| 202 | + reserve: document.documentElement.style.getPropertyValue('--phone-tab-reserve').trim(), |
| 203 | + keyboard: document.documentElement.style.getPropertyValue('--native-keyboard-height').trim(), |
| 204 | + })); |
| 205 | + expect(hidden.reserve).toBe('0px'); |
| 206 | + expect(Number.parseFloat(hidden.keyboard)).toBeGreaterThan(120); |
| 207 | + |
| 208 | + await paintViewport(page, { height: 844, offsetTop: 0, scale: 1 }); |
| 209 | + await expect(tabBar).toBeVisible(); |
| 210 | + const shown = await page.evaluate(() => document.documentElement.style.getPropertyValue('--phone-tab-reserve').trim()); |
| 211 | + expect(Number.parseFloat(shown)).toBeGreaterThan(0); |
| 212 | +}); |
| 213 | + |
| 214 | +test('Ask composer stays above the tab bar while an announcement is up', async ({ page }) => { |
| 215 | + await page.route('**/functions/v1/announcements*', (route) => route.fulfill({ |
| 216 | + json: { |
| 217 | + announcements: [{ |
| 218 | + id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', |
| 219 | + title: 'Scheduled maintenance', |
| 220 | + body: 'DebateLab will be unavailable for ten minutes.', |
| 221 | + starts_at: '2026-08-31T12:00:00.000Z', |
| 222 | + expires_at: null, |
| 223 | + created_at: '2026-08-31T11:00:00.000Z', |
| 224 | + }], |
| 225 | + }, |
| 226 | + })); |
| 227 | + await page.setViewportSize({ width: 390, height: 844 }); |
| 228 | + await page.goto('/app/ask', { waitUntil: 'domcontentloaded' }); |
| 229 | + await expect(page.getByRole('heading', { name: 'Scheduled maintenance' })).toBeVisible({ timeout: 15_000 }); |
| 230 | + const send = page.getByRole('button', { name: 'Send' }); |
| 231 | + const tabBar = page.getByRole('navigation', { name: 'Mobile navigation' }); |
| 232 | + await expect(send).toBeVisible(); |
| 233 | + await expect(tabBar).toBeVisible(); |
| 234 | + const [sendBox, tabBox, tops] = await Promise.all([ |
| 235 | + send.boundingBox(), |
| 236 | + tabBar.boundingBox(), |
| 237 | + page.evaluate(() => ({ |
| 238 | + main: document.documentElement.style.getPropertyValue('--app-main-top').trim(), |
| 239 | + header: document.documentElement.style.getPropertyValue('--app-header-bottom').trim(), |
| 240 | + })), |
| 241 | + ]); |
| 242 | + expect(sendBox).not.toBeNull(); |
| 243 | + expect(tabBox).not.toBeNull(); |
| 244 | + expect(sendBox!.y + sendBox!.height).toBeLessThanOrEqual(tabBox!.y + 1); |
| 245 | + expect(Number.parseFloat(tops.main)).toBeGreaterThan(Number.parseFloat(tops.header)); |
| 246 | +}); |
| 247 | + |
| 248 | +test('a toast at 700px sits above the tab bar', async ({ page }) => { |
| 249 | + await page.setViewportSize({ width: 700, height: 800 }); |
| 250 | + await page.goto('/app', { waitUntil: 'domcontentloaded' }); |
| 251 | + await expect(page.getByRole('heading', { name: 'Prepare' })).toBeVisible({ timeout: 15_000 }); |
| 252 | + const tabBar = page.getByRole('navigation', { name: 'Mobile navigation' }); |
| 253 | + await expect(tabBar).toBeVisible(); |
| 254 | + |
| 255 | + const toasted = await page.evaluate(async () => { |
| 256 | + const match = performance.getEntriesByType('resource') |
| 257 | + .map((entry) => entry.name) |
| 258 | + .find((name) => name.includes('/sonner')); |
| 259 | + if (!match) return 'missing'; |
| 260 | + const mod = await import(/* @vite-ignore */ match) as { toast?: { success: (message: string, options: { duration: number }) => void } }; |
| 261 | + if (!mod.toast?.success) return 'no-toast'; |
| 262 | + mod.toast.success('Copied to clipboard', { duration: 60_000 }); |
| 263 | + return 'ok'; |
| 264 | + }); |
| 265 | + expect(toasted).toBe('ok'); |
| 266 | + |
| 267 | + const toast = page.locator('[data-sonner-toast]').last(); |
| 268 | + await expect(toast).toHaveAttribute('data-mounted', 'true'); |
| 269 | + const offset = await page.evaluate(() => { |
| 270 | + const ol = document.querySelector('[data-sonner-toaster]'); |
| 271 | + return { |
| 272 | + reserve: Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--phone-tab-reserve')), |
| 273 | + usedBottom: ol ? Number.parseFloat(getComputedStyle(ol).bottom) : 0, |
| 274 | + }; |
| 275 | + }); |
| 276 | + expect(offset.reserve).toBeGreaterThan(40); |
| 277 | + expect(offset.usedBottom).toBeGreaterThan(offset.reserve); |
| 278 | + // The enter slide starts under the offset. The resting box is what has to clear the bar. |
| 279 | + await expect.poll(async () => { |
| 280 | + const [toastBox, tabBox] = await Promise.all([toast.boundingBox(), tabBar.boundingBox()]); |
| 281 | + if (!toastBox || !tabBox) return Number.POSITIVE_INFINITY; |
| 282 | + return toastBox.y + toastBox.height - tabBox.y; |
| 283 | + }).toBeLessThanOrEqual(1); |
| 284 | +}); |
| 285 | + |
| 286 | +test('iPad width does not keep a keyboard pad', async ({ page }) => { |
| 287 | + await page.setViewportSize({ width: 820, height: 1180 }); |
| 288 | + await page.goto('/app/ask', { waitUntil: 'domcontentloaded' }); |
| 289 | + const field = page.getByRole('textbox', { name: 'Message to your debate coach' }); |
| 290 | + await expect(field).toBeVisible({ timeout: 15_000 }); |
| 291 | + await field.focus(); |
| 292 | + await paintViewport(page, { height: 500, offsetTop: 0, scale: 1 }); |
| 293 | + const pad = await page.evaluate(() => ({ |
| 294 | + keyboard: document.documentElement.style.getPropertyValue('--native-keyboard-height').trim(), |
| 295 | + reserve: document.documentElement.style.getPropertyValue('--phone-tab-reserve').trim(), |
| 296 | + main: getComputedStyle(document.querySelector('main')!).paddingBottom, |
| 297 | + })); |
| 298 | + expect(pad.keyboard).toBe(''); |
| 299 | + expect(pad.reserve).toBe(''); |
| 300 | + expect(pad.main).toBe('0px'); |
| 301 | +}); |
| 302 | + |
176 | 303 | test('iPad portrait keeps the header menu instead of the tab bar', async ({ page }) => { |
177 | 304 | await page.setViewportSize({ width: 820, height: 1180 }); |
178 | 305 | await page.goto('/app', { waitUntil: 'domcontentloaded' }); |
|
0 commit comments