2026-05-01 09:23:36 +00:00
|
|
|
// #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
|
2026-05-01 09:54:18 +00:00
|
|
|
// viewport, scroll the page, and assert that all three sticky
|
2026-05-01 09:23:36 +00:00
|
|
|
// elements line up flush with the top of the viewport (header at
|
|
|
|
|
// y=0, heading row directly under it, then the column header row).
|
2026-05-01 09:54:18 +00:00
|
|
|
// The column header must stick at every viewport width — mobile,
|
|
|
|
|
// tablet, and desktop.
|
2026-05-01 09:23:36 +00:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-01 09:54:18 +00:00
|
|
|
// 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 ({
|
2026-05-01 09:23:36 +00:00
|
|
|
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);
|
|
|
|
|
|
2026-05-01 09:54:18 +00:00
|
|
|
await expect(page.getByTestId(`em-row-${seededIds[0]}`)).toBeVisible({
|
|
|
|
|
timeout: 10_000,
|
|
|
|
|
});
|
2026-05-01 09:23:36 +00:00
|
|
|
|
|
|
|
|
await page.evaluate(() => window.scrollTo(0, 1500));
|
2026-05-01 09:54:18 +00:00
|
|
|
await page.waitForTimeout(200);
|
2026-05-01 09:23:36 +00:00
|
|
|
|
2026-05-01 09:54:18 +00:00
|
|
|
await assertStickyAtTop(page);
|
2026-05-01 09:23:36 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-01 09:54:18 +00:00
|
|
|
test("Schedule [desktop, RTL]: page header, heading row, and column header all stick after scroll in Arabic", async ({
|
2026-05-01 09:23:36 +00:00
|
|
|
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));
|
2026-05-01 09:54:18 +00:00
|
|
|
await page.waitForTimeout(200);
|
2026-05-01 09:23:36 +00:00
|
|
|
|
2026-05-01 09:54:18 +00:00
|
|
|
await assertStickyAtTop(page);
|
2026-05-01 09:23:36 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-01 09:54:18 +00:00
|
|
|
test("Schedule [tablet, ~900px]: column header still sticks at sub-xl widths", async ({
|
2026-05-01 09:23:36 +00:00
|
|
|
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,
|
2026-05-01 09:54:18 +00:00
|
|
|
titleEn: `StickyTest Tablet ${i}`,
|
2026-05-01 09:23:36 +00:00
|
|
|
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));
|
2026-05-01 09:54:18 +00:00
|
|
|
await page.waitForTimeout(200);
|
2026-05-01 09:23:36 +00:00
|
|
|
|
2026-05-01 09:54:18 +00:00
|
|
|
await assertStickyAtTop(page);
|
|
|
|
|
});
|
2026-05-01 09:23:36 +00:00
|
|
|
|
2026-05-01 09:54:18 +00:00
|
|
|
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);
|
2026-05-01 09:23:36 +00:00
|
|
|
});
|