// #267: e2e coverage for the sticky top bar / schedule heading / table // header on the Executive Meetings page. We seed enough rows on a // far-future date that the page is comfortably taller than the // viewport, scroll the page, and assert that all three sticky // elements line up flush with the top of the viewport (header at // y=0, heading row directly under it, then the column header row). // The column header must stick at every viewport width — mobile, // tablet, and desktop. import { test, expect } from "@playwright/test"; import pg from "pg"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error( "DATABASE_URL must be set to run the sticky-header UI test", ); } const pool = new pg.Pool({ connectionString: DATABASE_URL }); const createdMeetingIds = []; async function loginViaUi(page, username, password) { await page.goto("/login"); await page.locator("#username").fill(username); await page.locator("#password").fill(password); await Promise.all([ page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15_000, }), page.locator('form button[type="submit"]').click(), ]); } async function setLangEn(page) { await page.addInitScript(() => { try { window.localStorage.setItem("tx-lang", "en"); } catch { /* ignore */ } }); } const RANDOM_DATE_BASE_DAYS = 365 + Math.floor(Math.random() * 1000) + ((Date.now() / 1000) | 0) % 500; function uniqueFutureDate(offsetDays) { const d = new Date(); d.setUTCDate(d.getUTCDate() + RANDOM_DATE_BASE_DAYS + offsetDays * 7); return d.toISOString().slice(0, 10); } async function insertMeeting({ meetingDate, dailyNumber, titleEn, titleAr, startTime, endTime, }) { const { rows } = await pool.query( `INSERT INTO executive_meetings (daily_number, title_ar, title_en, meeting_date, start_time, end_time, status) VALUES ($1, $2, $3, $4, $5, $6, 'scheduled') RETURNING id`, [dailyNumber, titleAr, titleEn, meetingDate, startTime, endTime], ); const id = rows[0].id; createdMeetingIds.push(id); return id; } test.afterAll(async () => { if (createdMeetingIds.length > 0) { await pool.query( `DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`, [createdMeetingIds], ); await pool.query( `DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`, [createdMeetingIds], ); await pool.query( `DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, [createdMeetingIds], ); } await pool.end(); }); // The floating sticky thead renders before the (display:none) actual // thead in the DOM, so .first() reliably targets the visible one. function visibleColHeader(page, colId) { return page.getByTestId(`em-col-header-${colId}`).first(); } async function assertStickyAtTop(page, { allowFloatingY = 4 } = {}) { const header = page.getByTestId("em-page-header"); const heading = page.getByTestId("em-schedule-heading-bar"); const colHeader = visibleColHeader(page, "meeting"); await expect(header).toBeVisible(); await expect(heading).toBeVisible(); await expect(colHeader).toBeVisible(); const headerBox = await header.boundingBox(); const headingBox = await heading.boundingBox(); const colBox = await colHeader.boundingBox(); expect(headerBox).not.toBeNull(); expect(headingBox).not.toBeNull(); expect(colBox).not.toBeNull(); // Page header is flush with the viewport top. expect(headerBox.y).toBeGreaterThanOrEqual(-1); expect(headerBox.y).toBeLessThanOrEqual(1); // Heading row sits directly below the page header. const expectedHeadingY = headerBox.y + headerBox.height; expect(headingBox.y).toBeGreaterThanOrEqual(expectedHeadingY - 2); expect(headingBox.y).toBeLessThan(expectedHeadingY + 4); // Column header sits directly below the heading row — the // critical assertion: it actually stuck instead of scrolling // away with the table. const expectedColY = headingBox.y + headingBox.height; expect(colBox.y).toBeGreaterThanOrEqual(expectedColY - 2); expect(colBox.y).toBeLessThan(expectedColY + allowFloatingY); } test("Schedule [desktop, LTR]: page header, heading row, and column header all stick to the top of the viewport on scroll", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(2); const seededIds = []; for (let i = 1; i <= 30; i++) { const hh = String(7 + Math.floor(i / 4)).padStart(2, "0"); const mm = String((i % 4) * 15).padStart(2, "0"); const id = await insertMeeting({ meetingDate: date, dailyNumber: i, titleEn: `StickyTest Meeting ${i}`, titleAr: `اجتماع ثبات ${i}`, startTime: `${hh}:${mm}:00`, endTime: `${hh}:${mm}:00`, }); seededIds.push(id); } await loginViaUi(page, "admin", "admin123"); await page.setViewportSize({ width: 1280, height: 720 }); await page.goto("/executive-meetings"); const dateInput = page.locator('input[type="date"]').first(); await dateInput.fill(date); await expect(page.getByTestId(`em-row-${seededIds[0]}`)).toBeVisible({ timeout: 10_000, }); await page.evaluate(() => window.scrollTo(0, 1500)); await page.waitForTimeout(200); await assertStickyAtTop(page); }); test("Schedule [desktop, RTL]: page header, heading row, and column header all stick after scroll in Arabic", async ({ page, }) => { await page.addInitScript(() => { try { window.localStorage.setItem("tx-lang", "ar"); } catch { /* ignore */ } }); const date = uniqueFutureDate(3); const seededIds = []; for (let i = 1; i <= 30; i++) { const hh = String(7 + Math.floor(i / 4)).padStart(2, "0"); const mm = String((i % 4) * 15).padStart(2, "0"); const id = await insertMeeting({ meetingDate: date, dailyNumber: i, titleEn: `StickyTest RTL ${i}`, titleAr: `اجتماع ثبات RTL ${i}`, startTime: `${hh}:${mm}:00`, endTime: `${hh}:${mm}:00`, }); seededIds.push(id); } await loginViaUi(page, "admin", "admin123"); await page.setViewportSize({ width: 1280, height: 720 }); await page.goto("/executive-meetings"); const dateInput = page.locator('input[type="date"]').first(); await dateInput.fill(date); await expect(page.getByTestId(`em-row-${seededIds[0]}`)).toBeVisible({ timeout: 10_000, }); // Sanity-check the document is actually mirrored. const dir = await page.evaluate(() => document.documentElement.dir); expect(dir).toBe("rtl"); await page.evaluate(() => window.scrollTo(0, 1500)); await page.waitForTimeout(200); await assertStickyAtTop(page); }); test("Schedule [tablet, ~900px]: column header still sticks at sub-xl widths", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(4); const seededIds = []; for (let i = 1; i <= 20; i++) { const hh = String(7 + Math.floor(i / 4)).padStart(2, "0"); const mm = String((i % 4) * 15).padStart(2, "0"); const id = await insertMeeting({ meetingDate: date, dailyNumber: i, titleEn: `StickyTest Tablet ${i}`, titleAr: `اجتماع ضيق ${i}`, startTime: `${hh}:${mm}:00`, endTime: `${hh}:${mm}:00`, }); seededIds.push(id); } await loginViaUi(page, "admin", "admin123"); await page.setViewportSize({ width: 900, height: 700 }); await page.goto("/executive-meetings"); const dateInput = page.locator('input[type="date"]').first(); await dateInput.fill(date); await expect(page.getByTestId(`em-row-${seededIds[0]}`)).toBeVisible({ timeout: 10_000, }); await page.evaluate(() => window.scrollTo(0, 1500)); await page.waitForTimeout(200); await assertStickyAtTop(page); }); test("Schedule [mobile, 414px]: column header still sticks on a narrow phone-sized viewport", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(5); const seededIds = []; for (let i = 1; i <= 20; i++) { const hh = String(7 + Math.floor(i / 4)).padStart(2, "0"); const mm = String((i % 4) * 15).padStart(2, "0"); const id = await insertMeeting({ meetingDate: date, dailyNumber: i, titleEn: `StickyTest Mobile ${i}`, titleAr: `اجتماع جوال ${i}`, startTime: `${hh}:${mm}:00`, endTime: `${hh}:${mm}:00`, }); seededIds.push(id); } await loginViaUi(page, "admin", "admin123"); await page.setViewportSize({ width: 414, height: 800 }); await page.goto("/executive-meetings"); const dateInput = page.locator('input[type="date"]').first(); await dateInput.fill(date); await expect(page.getByTestId(`em-row-${seededIds[0]}`)).toBeVisible({ timeout: 10_000, }); await page.evaluate(() => window.scrollTo(0, 1500)); await page.waitForTimeout(200); await assertStickyAtTop(page); });