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 the same locale bundles the app reads from so any copy edits to // the subheading labels stay covered without test churn. 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") }; // E2E coverage for Task #207 — custom subheadings inside the attendees // cell. Subheadings are user-defined section labels that live alongside // person rows in a meeting's attendee list, but are NOT counted as // attendees and do NOT advance the running "1-, 2-, 3-…" person index. // // What we verify: // 1. Mixed group: a meeting with 2 internal persons + 1 subheading // between them + 1 more person renders all four rows. The // subheading carries `data-testid="em-attendee-subheading-…"`, // and the three persons get the indices 1-, 2-, 3-. // 2. Zero subheading: a meeting with only persons keeps the legacy // numbering exactly as before (no extra "subheading" testids, // no off-by-one). // Both scenarios run in BOTH locales so the RTL/LTR mirror behaves the // same. const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error( "DATABASE_URL must be set to run the executive-meetings subheading UI tests", ); } // Parse to silence the "unused" lint until we use the bundle below. void localeBundles; const pool = new pg.Pool({ connectionString: DATABASE_URL }); const createdMeetingIds = []; // Pick a far-future, randomised base so we never collide with real // scheduled meetings while running side by side with other specs that // also seed in the future. const RANDOM_DATE_BASE_DAYS = 900 + 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, kind, }) { await pool.query( `INSERT INTO executive_meeting_attendees (meeting_id, name, attendance_type, sort_order, kind) VALUES ($1, $2, $3, $4, $5)`, [meetingId, name, attendanceType, sortOrder, kind ?? "person"], ); } 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(), ]); } 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 () => { 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}]: subheading rows render and do not advance the person index`, async ({ page, }) => { const date = uniqueFutureDate(langOffsets[lang]); const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `Subheading Mixed ${lang} ${Date.now().toString(36)}`, titleAr: `عنوان فرعي مختلط ${lang} ${Date.now().toString(36)}`, startTime: "09:00:00", endTime: "10:00:00", }); // Internal group with a subheading wedged between the second and // third person. The subheading starts a fresh numbered section, so // persons render as 1-, 2- (before the subheading) and 1- (after), // PLUS the subheading row itself. await insertAttendee({ meetingId, name: "Person Alpha", attendanceType: "internal", sortOrder: 0, kind: "person", }); await insertAttendee({ meetingId, name: "Person Beta", attendanceType: "internal", sortOrder: 1, kind: "person", }); await insertAttendee({ meetingId, // Distinctive label so the assertion below cannot trip on a // person name that incidentally contains the same word. name: "GuestsLabel-X9", attendanceType: "internal", sortOrder: 2, kind: "subheading", }); await insertAttendee({ meetingId, name: "Person Gamma", attendanceType: "internal", sortOrder: 3, kind: "person", }); 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 }); // Exactly one subheading row in this meeting. const subheadings = row.locator( '[data-testid^="em-attendee-subheading-"]', ); await expect(subheadings).toHaveCount(1, { timeout: 10_000 }); await expect(subheadings.first()).toContainText("GuestsLabel-X9"); // Three person rows rendered (subheading is excluded by the // person-only `em-attendee-row-…` testid). const personRows = row.locator('[data-testid^="em-attendee-row-"]'); await expect(personRows).toHaveCount(3); // Numbering restarts after the subheading: section 1 has Alpha // and Beta numbered "1-" and "2-"; section 2 has Gamma alone, // numbered "1-" (the cell has a subheading so we always show the // index, even for a solo person inside a section). const indices = row.locator('[data-testid^="em-attendee-index-"]'); await expect(indices).toHaveCount(3); const indexTexts = await indices.allInnerTexts(); const trimmed = indexTexts.map((s) => s.trim().replace(/\s+/g, "")); expect(trimmed).toEqual(["1-", "2-", "1-"]); } finally { if (originalAdminPreferredLanguage) { await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch( () => { /* best-effort; afterAll also restores */ }, ); } } }); test(`Schedule [${lang}]: subheading-only group survives deletion of all its persons (hasSplit preservation)`, async ({ page, }) => { // Regression guard for the hasSplit edge case the architect flagged: // if a group still has a subheading after the user deletes every // person in it, the group must keep its own AttendeeGroup wrapper // (and its label) — its subheading should NOT silently fold into // a sibling group's row. We seed virtual = [subheading] + // internal = [persons] directly via the DB to skip the editing // path and assert only the rendering invariant. const date = uniqueFutureDate(langOffsets[lang] + 10); const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `Sub Solo Group ${lang} ${Date.now().toString(36)}`, titleAr: `قسم بعنوان فرعي ${lang} ${Date.now().toString(36)}`, startTime: "09:00:00", endTime: "10:00:00", }); // Virtual group: ONE subheading row, zero persons. This is the // post-delete state described in the architect comment. await insertAttendee({ meetingId, name: "Virtual Block Label", attendanceType: "virtual", sortOrder: 0, kind: "subheading", }); // Internal group: two real persons. await insertAttendee({ meetingId, name: "Internal Person 1", attendanceType: "internal", sortOrder: 1, kind: "person", }); await insertAttendee({ meetingId, name: "Internal Person 2", attendanceType: "internal", sortOrder: 2, kind: "person", }); 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 }); // The subheading row must still be in the DOM, with its label // text intact. const subheadings = row.locator( '[data-testid^="em-attendee-subheading-"]', ); await expect(subheadings).toHaveCount(1, { timeout: 10_000 }); await expect(subheadings.first()).toContainText("Virtual Block Label"); // The two internal persons must still be numbered 1- and 2- (the // virtual subheading does NOT get a number, and crucially does // NOT push the internal counter to start at 2). const indices = row.locator('[data-testid^="em-attendee-index-"]'); await expect(indices).toHaveCount(2); const indexTexts = (await indices.allInnerTexts()).map((s) => s.trim().replace(/\s+/g, ""), ); expect(indexTexts).toEqual(["1-", "2-"]); } finally { if (originalAdminPreferredLanguage) { await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch( () => { /* best-effort */ }, ); } } }); test(`Schedule [${lang}]: meeting with no subheadings still numbers persons 1..N with no stray subheading rows`, async ({ page, }) => { const date = uniqueFutureDate(langOffsets[lang] + 5); const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `No Subheadings ${lang} ${Date.now().toString(36)}`, titleAr: `بدون عناوين فرعية ${lang} ${Date.now().toString(36)}`, startTime: "09:00:00", endTime: "10:00:00", }); await insertAttendee({ meetingId, name: "Solo Person A", attendanceType: "internal", sortOrder: 0, kind: "person", }); await insertAttendee({ meetingId, name: "Solo Person B", attendanceType: "internal", sortOrder: 1, kind: "person", }); 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 }); // Zero subheadings — make sure the new code path didn't sneak // one in for legacy "person"-only meetings. const subheadings = row.locator( '[data-testid^="em-attendee-subheading-"]', ); await expect(subheadings).toHaveCount(0); const indices = row.locator('[data-testid^="em-attendee-index-"]'); await expect(indices).toHaveCount(2); const trimmed = (await indices.allInnerTexts()).map((s) => s.trim().replace(/\s+/g, ""), ); expect(trimmed).toEqual(["1-", "2-"]); } finally { if (originalAdminPreferredLanguage) { await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch( () => { /* best-effort */ }, ); } } }); }