7a2ae8434d
Refactor documentation files and code comments to remove references to Replit, specific task numbers, and other platform-specific identifiers. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: fa18e5d4-a810-4bd5-8cde-2a60d64d9e3f Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/kI0sxlu Replit-Helium-Checkpoint-Created: true
318 lines
11 KiB
JavaScript
318 lines
11 KiB
JavaScript
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 */
|
|
},
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|