import { test, expect } from "@playwright/test"; import pg from "pg"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); // Load expected header strings from the SAME locale bundles the app // renders from, so the assertions stay in sync if the copy ever // changes. function loadLocale(lang) { const p = join(__dirname, "..", "src", "locales", `${lang}.json`); return JSON.parse(readFileSync(p, "utf8")); } const localeBundles = { en: loadLocale("en"), ar: loadLocale("ar") }; function attendanceLabel(lang, kind) { const em = localeBundles[lang]?.executiveMeetings; if (!em) throw new Error(`Missing executiveMeetings namespace in ${lang}.json`); const key = `${kind}Attendance`; // virtualAttendance | internalAttendance | externalAttendance const val = em[key]; if (!val) throw new Error(`Missing locale key executiveMeetings.${key} in ${lang}.json`); return val; } // E2E coverage for: the per-group attendee headers // ("Virtual Attendance / Internal Attendance / External Attendance") // rendered above each attendee group in the schedule's attendee cell. // // Before #204 the headers were `text-xs` with em-dash decoration and // were easy to mistake for a stray attendee name. After #204 they // render bolder, larger, centered, and with a hairline underline so // they read clearly as section labels. These tests seed a meeting with // attendees in two groups (virtual + internal) on a far-future date — // so we never collide with real schedule data — and assert that BOTH // expected localised headers render with the new stable // `em-attendee-group-header` testid, in BOTH locales. const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error( "DATABASE_URL must be set to run the executive-meetings attendee-group-header UI tests", ); } const pool = new pg.Pool({ connectionString: DATABASE_URL }); const createdMeetingIds = []; const RANDOM_DATE_BASE_DAYS = 730 + 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; } async function insertAttendee({ meetingId, name, attendanceType, sortOrder }) { await pool.query( `INSERT INTO executive_meeting_attendees (meeting_id, name, attendance_type, sort_order) VALUES ($1, $2, $3, $4)`, [meetingId, name, attendanceType, sortOrder], ); } async function loginViaUi(page) { await page.goto("/login"); await page.locator("#username").fill("admin"); await page.locator("#password").fill("admin123"); await Promise.all([ page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15_000, }), page.locator('form button[type="submit"]').click(), ]); } // Admin's preferred_language in the DB drives AuthContext to call // i18n.changeLanguage(...) right after login/auth restore — that // would clobber any localStorage-only override we apply from the // test. We capture the seeded value before mutating it per-test, then // restore it in afterAll so other specs/sessions see no drift. let originalAdminPreferredLanguage = null; async function setAdminPreferredLanguage(lang) { const { rows } = await pool.query( `UPDATE users SET preferred_language = $1 WHERE username = 'admin' RETURNING preferred_language`, [lang], ); return rows[0]?.preferred_language; } test.beforeAll(async () => { const { rows } = await pool.query( `SELECT preferred_language FROM users WHERE username = 'admin'`, ); originalAdminPreferredLanguage = rows[0]?.preferred_language ?? "ar"; }); test.afterAll(async () => { // Restore admin's preferred language so we leave the DB exactly as // we found it. if (originalAdminPreferredLanguage) { await pool .query(`UPDATE users SET preferred_language = $1 WHERE username = 'admin'`, [ originalAdminPreferredLanguage, ]) .catch(() => { /* best-effort */ }); } 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(); }); const langOffsets = { en: 1, ar: 2 }; for (const lang of ["en", "ar"]) { test(`Schedule [${lang}]: attendee group headers render with the localised label for multi-group meetings`, async ({ page, }) => { // Seed: one meeting with 2 virtual + 3 internal attendees so the // attendee cell renders BOTH a "Virtual Attendance" header and an // "Internal Attendance" header. Each lang gets its OWN unique // future date so the (meeting_date, daily_number) unique // constraint never collides between iterations. const date = uniqueFutureDate(langOffsets[lang]); const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `Group Header ${lang} ${Date.now().toString(36)}`, titleAr: `عناوين المجموعة ${lang} ${Date.now().toString(36)}`, startTime: "09:00:00", endTime: "10:00:00", }); await insertAttendee({ meetingId, name: "Virtual Person A", attendanceType: "virtual", sortOrder: 0, }); await insertAttendee({ meetingId, name: "Virtual Person B", attendanceType: "virtual", sortOrder: 1, }); await insertAttendee({ meetingId, name: "Internal Person A", attendanceType: "internal", sortOrder: 2, }); await insertAttendee({ meetingId, name: "Internal Person B", attendanceType: "internal", sortOrder: 3, }); await insertAttendee({ meetingId, name: "Internal Person C", attendanceType: "internal", sortOrder: 4, }); // Align the admin's preferred_language with the locale this test // exercises so AuthContext does not flip the UI back to the seed // value after login. Wrap in try/finally so we restore the // original value as soon as the test finishes — even on assertion // failure — minimising the window in which the shared admin row // carries a test-set language. await setAdminPreferredLanguage(lang); try { await loginViaUi(page); await page.goto("/executive-meetings"); await expect(page.locator("html")).toHaveAttribute("lang", lang, { timeout: 5_000, }); await page.locator('input[type="date"]').first().fill(date); const row = page.getByTestId(`em-row-${meetingId}`); await expect(row).toBeVisible({ timeout: 15_000 }); const headers = row.locator('[data-testid="em-attendee-group-header"]'); await expect(headers).toHaveCount(2, { timeout: 10_000 }); // We seeded EXACTLY one virtual group and one internal group, so // require BOTH expected localised labels to appear (substring // match because the virtual header may be suffixed with the // platform name, e.g. " — Zoom"). This rejects any regression // that drops a header, mislabels a group, or renders the same // label twice. Labels are read from the SAME locale bundle the // app renders from so copy edits won't silently break the spec. const virtualLabel = attendanceLabel(lang, "virtual"); const internalLabel = attendanceLabel(lang, "internal"); const externalLabel = attendanceLabel(lang, "external"); const seenTexts = []; for (let idx = 0; idx < 2; idx++) { seenTexts.push((await headers.nth(idx).innerText()).trim()); } const hasVirtual = seenTexts.some((t) => t.includes(virtualLabel)); const hasInternal = seenTexts.some((t) => t.includes(internalLabel)); const hasExternal = seenTexts.some((t) => t.includes(externalLabel)); expect( hasVirtual, `expected a "${virtualLabel}" header; saw ${JSON.stringify(seenTexts)}`, ).toBe(true); expect( hasInternal, `expected a "${internalLabel}" header; saw ${JSON.stringify(seenTexts)}`, ).toBe(true); expect( hasExternal, `did not seed external attendees; "${externalLabel}" header should be absent. Saw ${JSON.stringify(seenTexts)}`, ).toBe(false); } finally { if (originalAdminPreferredLanguage) { await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch( () => { /* best-effort; afterAll also restores */ }, ); } } }); // Regression guard for the single-group fallback: a meeting with // attendees in only one group (e.g. all internal) and no virtual // platform must render NO `em-attendee-group-header` — that path // bypasses AttendeeGroup entirely. Without this guard a regression // could start rendering a stray "Internal Attendance" header above // every solo-group meeting. test(`Schedule [${lang}]: single-group meeting renders no attendee group header`, async ({ page, }) => { const date = uniqueFutureDate(langOffsets[lang] + 5); const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `Solo Group ${lang} ${Date.now().toString(36)}`, titleAr: `مجموعة فردية ${lang} ${Date.now().toString(36)}`, startTime: "09:00:00", endTime: "10:00:00", }); await insertAttendee({ meetingId, name: "Internal Person A", attendanceType: "internal", sortOrder: 0, }); await insertAttendee({ meetingId, name: "Internal Person B", attendanceType: "internal", sortOrder: 1, }); await setAdminPreferredLanguage(lang); try { await loginViaUi(page); await page.goto("/executive-meetings"); await expect(page.locator("html")).toHaveAttribute("lang", lang, { timeout: 5_000, }); await page.locator('input[type="date"]').first().fill(date); const row = page.getByTestId(`em-row-${meetingId}`); await expect(row).toBeVisible({ timeout: 15_000 }); const headers = row.locator('[data-testid="em-attendee-group-header"]'); await expect(headers).toHaveCount(0); } finally { if (originalAdminPreferredLanguage) { await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch( () => { /* best-effort */ }, ); } } }); }