| 1 | // views.mjs — switching views holds still (R2, Paul: "views jumping"). |
| 2 | // |
| 3 | // Driven by TestViewSwitchingHoldsStillInARealBrowser, which seeds the |
| 4 | // week of 21 September 2026 with an all-day event and timed events. |
| 5 | // |
| 6 | // At 1440 and 390 wide, over every hop Day → Week → Month → Agenda → Day |
| 7 | // and every view's Previous and Next: |
| 8 | // 1. CLS inside the new document is 0. |
| 9 | // 2. The page heading, the view switcher, Quick add, New event, and the |
| 10 | // Previous and Next buttons are at the same x/y as on the page |
| 11 | // before (within 0.05px: the bold current tab once moved the switcher by 0.1px). |
| 12 | // 3. Every view's <main> has the same box, so there is one layout. |
| 13 | // And, at 1440: |
| 14 | // 4. Week and Month are not squeezed: a day column is at least 160px. |
| 15 | // 5. The week's sticky day headings sit at the top of the grid's own |
| 16 | // scroll box once it has scrolled to working hours, painted over |
| 17 | // the hours scrolled beneath them, with the all-day row still shown. |
| 18 | import { chromium } from 'playwright'; |
| 19 | |
| 20 | const [origin, cookieName, cookieValue] = process.argv.slice(2); |
| 21 | if (!origin || !cookieName || !cookieValue) { |
| 22 | console.error('usage: views.mjs <origin> <cookieName> <cookieValue>'); |
| 23 | process.exit(2); |
| 24 | } |
| 25 | |
| 26 | const failures = []; |
| 27 | function check(ok, what) { |
| 28 | if (ok) { console.log('ok: ' + what); return; } |
| 29 | failures.push(what); |
| 30 | console.error('FAIL: ' + what); |
| 31 | } |
| 32 | |
| 33 | const views = { |
| 34 | Day: '/day/2026-09-24', |
| 35 | Week: '/week/2026-09-24', |
| 36 | Month: '/month/2026-09', |
| 37 | Agenda: '/agenda?from=2026-09-24', |
| 38 | }; |
| 39 | const fields = ['heading', 'switcher', 'quickAdd', 'newEvent', 'prev', 'next', 'main']; |
| 40 | |
| 41 | const browser = await chromium.launch(); |
| 42 | for (const [width, height] of [[1440, 900], [390, 844]]) { |
| 43 | const ctx = await browser.newContext({ viewport: { width, height } }); |
| 44 | await ctx.addCookies([{ name: cookieName, value: cookieValue, url: origin, httpOnly: true }]); |
| 45 | await ctx.addInitScript(() => { |
| 46 | window.__cls = 0; |
| 47 | new PerformanceObserver((list) => { |
| 48 | for (const e of list.getEntries()) if (!e.hadRecentInput) window.__cls += e.value; |
| 49 | }).observe({ type: 'layout-shift', buffered: true }); |
| 50 | }); |
| 51 | const page = await ctx.newPage(); |
| 52 | |
| 53 | const facts = async () => { |
| 54 | await page.waitForLoadState('networkidle'); |
| 55 | await page.waitForTimeout(300); |
| 56 | return page.evaluate(() => { |
| 57 | const box = (e) => { |
| 58 | if (!e) return null; |
| 59 | const r = e.getBoundingClientRect(); |
| 60 | return r.width > 0 && r.height > 0 ? { x: r.x, y: r.y, w: r.width } : null; |
| 61 | }; |
| 62 | const bar = [...document.querySelectorAll('[cal-bar] a')]; |
| 63 | return { |
| 64 | cls: window.__cls || 0, |
| 65 | heading: box(document.querySelector('main h1')), |
| 66 | switcher: box(document.querySelector('[rst-seg-tabs][aria-label="Calendar view"]')), |
| 67 | quickAdd: box(document.querySelector('[cal-quick-summary]')), |
| 68 | newEvent: box(document.querySelector('[cal-header-actions] a[rst-btn="primary"]')), |
| 69 | prev: box(bar.find((a) => /^Previous/.test(a.textContent.trim()))), |
| 70 | next: box(bar.find((a) => /^Next/.test(a.textContent.trim()))), |
| 71 | main: box(document.querySelector('main')), |
| 72 | }; |
| 73 | }); |
| 74 | }; |
| 75 | const hop = async (name, path) => { |
| 76 | const before = await facts(); |
| 77 | await page.goto(origin + path); |
| 78 | const after = await facts(); |
| 79 | check(after.cls === 0, `${width}: ${name}: CLS is 0 (${after.cls})`); |
| 80 | for (const f of fields) { |
| 81 | const a = before[f], b = after[f]; |
| 82 | const still = a && b && Math.abs(a.x - b.x) < 0.05 && Math.abs(a.y - b.y) < 0.05 && (f !== 'main' || Math.abs(a.w - b.w) < 0.05); |
| 83 | check(still, `${width}: ${name}: ${f} holds still (${a ? `${a.x.toFixed(1)},${a.y.toFixed(1)}` : 'missing'} → ${b ? `${b.x.toFixed(1)},${b.y.toFixed(1)}` : 'missing'})`); |
| 84 | } |
| 85 | }; |
| 86 | |
| 87 | await page.goto(origin + views.Day); |
| 88 | await hop('Day → Week', views.Week); |
| 89 | await hop('Week → Month', views.Month); |
| 90 | await hop('Month → Agenda', views.Agenda); |
| 91 | await hop('Agenda → Day', views.Day); |
| 92 | for (const [view, path] of Object.entries(views)) { |
| 93 | for (const dir of ['Previous', 'Next']) { |
| 94 | await page.goto(origin + path); |
| 95 | await facts(); |
| 96 | const href = await page.locator('[cal-bar] a').filter({ hasText: new RegExp('^' + dir) }).first().getAttribute('href'); |
| 97 | await hop(`${view} ${dir.toLowerCase()}`, href); |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | if (width === 1440) { |
| 102 | await page.goto(origin + views.Week); |
| 103 | await facts(); |
| 104 | const week = await page.evaluate(() => { |
| 105 | const heads = [...document.querySelectorAll('[cal-column-head]')].slice(1).map((h) => h.getBoundingClientRect()); |
| 106 | const scroller = document.querySelector('[cal-week-scroll]'); |
| 107 | const top = scroller.getBoundingClientRect().top; |
| 108 | // What paints at each heading's centre: the heading, not an hour |
| 109 | // row scrolled over it. |
| 110 | const covered = heads.filter((r) => !document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.closest('[cal-column-head]')).length; |
| 111 | // The all-day row stays on the first screen once the grid has |
| 112 | // scrolled to working hours: the offsite's chip paints at its centre. |
| 113 | const chip = [...document.querySelectorAll('[cal-allday] [cal-chip]')].find((c) => c.textContent.includes('Team offsite')); |
| 114 | const cr = chip && chip.getBoundingClientRect(); |
| 115 | const allDay = !!(cr && document.elementFromPoint(cr.x + cr.width / 2, cr.y + cr.height / 2)?.closest('[cal-chip]') === chip); |
| 116 | return { col: Math.min(...heads.map((r) => r.width)), headTop: heads[0].top, top, scrolled: scroller.scrollTop, covered, allDay }; |
| 117 | }); |
| 118 | check(week.col >= 160, `1440: a week column is at least 160px (${week.col.toFixed(1)})`); |
| 119 | check(week.scrolled > 0, `1440: the week opened scrolled to working hours (scrollTop ${week.scrolled})`); |
| 120 | check(Math.abs(week.headTop - week.top) < 1, `1440: the day headings stick to the grid's top (${week.headTop.toFixed(1)} vs ${week.top.toFixed(1)})`); |
| 121 | check(week.allDay, '1440: the all-day row is still on screen after the scroll to working hours'); |
| 122 | check(week.covered === 0, `1440: every day heading paints over the scrolled grid (${week.covered} covered)`); |
| 123 | |
| 124 | await page.goto(origin + views.Month); |
| 125 | await facts(); |
| 126 | const cell = await page.evaluate(() => Math.min(...[...document.querySelectorAll('[cal-month] tbody tr:first-child td')].map((td) => td.getBoundingClientRect().width))); |
| 127 | check(cell >= 160, `1440: a month column is at least 160px (${cell.toFixed(1)})`); |
| 128 | } |
| 129 | await ctx.close(); |
| 130 | } |
| 131 | await browser.close(); |
| 132 | if (failures.length) { |
| 133 | console.error(`\n${failures.length} check(s) failed`); |
| 134 | process.exit(1); |
| 135 | } |
| 136 | |