Add Playwright e2e tests for Executive Meetings schedule features

Task #129 — added 4 browser test scenarios in
artifacts/tx-os/tests/executive-meetings-schedule-features.spec.mjs:

1. Rich-text title editing (bold + red color via Tiptap toolbar)
   round-trips through the API and persists after reload — checks
   both the rendered cell HTML and the DB column.
2. Drag-to-reorder rows: dragging row 2 above row 1 swaps daily
   numbers AND start times; verified after page reload.
3. Custom highlight color from the customize popover paints the
   current meeting row with an inset box-shadow ring matching the
   chosen swatch (default green vs custom red).
4. A non-mutate user (executive_viewer) sees no grip handle and no
   edit-mode toggle on the schedule.

Implementation notes / drift:
- Tests seed meetings directly via DATABASE_URL using pg.Pool and
  clean up in afterAll (meetings, attendees, audit logs, and any
  granted executive_viewer role assignments are revoked).
- Meeting dates use a per-process random base ~1+ year out so reruns
  never collide on the (meeting_date, daily_number) unique key.
- The bold+color assertion checks whichever language column was
  written (title_ar vs title_en), since admin's preferredLanguage
  overrides the localStorage tx-lang init script after login.
- Re-used existing test IDs already exposed by the schedule UI
  (em-edit-title, em-edit-toolbar, em-edit-bold, em-edit-color-red,
  em-edit-save, em-row-grip, em-customize-columns-trigger,
  em-highlight-toggle, em-highlight-color-#hex, em-edit-mode-toggle,
  data-current-meeting). No production code changes.

All 4 tests pass against the live workflows (40s total).

Replit-Task-Id: 761dc96a-5bcb-47ef-bdb2-acfbebc68fc5
This commit is contained in:
riyadhafraa
2026-04-30 05:31:34 +00:00
parent 6908207df5
commit 11f169f8c5
@@ -0,0 +1,564 @@
import { test, expect } from "@playwright/test";
import pg from "pg";
// E2E coverage for the four "interactive schedule" features that
// previously only had backend integration tests:
// 1. Tiptap rich-text title editing (bold + color, persisted as HTML)
// 2. Drag-to-reorder rows (dnd-kit) — daily numbers + start times swap
// 3. Custom highlight color from the customize popover — applied as
// an inset box-shadow ring on the current meeting row
// 4. Non-mutate user (executive_viewer role) sees no grip handle
//
// Each scenario seeds its own meetings via direct DB inserts on a
// far-future date (or today, for the highlight scenario), so we never
// collide with real schedule data and afterAll cleanup leaves the DB
// untouched.
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set to run the executive-meetings schedule UI tests",
);
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
// IDs we created so the afterAll cleanup can DELETE them.
const createdMeetingIds = [];
// Track temporary executive_viewer role assignments so we can revoke
// them on cleanup (the "ahmed" demo user must end the run with the
// same role set he started with).
const grantedRoleAssignments = []; // [{ userId, roleId }]
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(),
]);
}
// Wipe the schedule's persisted UI prefs so each test starts from a
// known baseline (view mode off, default highlight color, default
// columns, no per-row tints). Must be called AFTER login because the
// edit-mode storage key is namespaced by userId.
async function resetSchedulePrefs(page) {
await page.evaluate(() => {
try {
const keys = Object.keys(window.localStorage);
for (const k of keys) {
if (
k.startsWith("em-schedule-edit-mode-v1") ||
k === "em-schedule-cols-v1" ||
k === "em-schedule-row-colors-v1" ||
k === "em-current-meeting-highlight-v1"
) {
window.localStorage.removeItem(k);
}
}
} catch {
/* ignore */
}
});
}
async function setLangEn(page) {
await page.addInitScript(() => {
try {
window.localStorage.setItem("tx-lang", "en");
} catch {
/* localStorage may not be ready before navigation */
}
});
}
// Insert a meeting row directly. Returns the new id.
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;
}
// Pick a meeting date that won't collide with any real schedule data
// OR with stale rows leftover from a previously-failed run. We jitter
// the year-out offset by a per-process random base so reruns within
// the same minute land on different days.
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);
}
function todayIso() {
return new Date().toISOString().slice(0, 10);
}
// HH:MM:SS string for a wall-clock time `minutesOffset` minutes from
// the local "now". Used to seed a meeting whose [startTime, endTime)
// window contains the moment the highlight test runs.
function localTimeWithOffset(minutesOffset) {
const d = new Date();
d.setMinutes(d.getMinutes() + minutesOffset);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${hh}:${mm}:00`;
}
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],
);
}
for (const { userId, roleId } of grantedRoleAssignments) {
await pool.query(
`DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2`,
[userId, roleId],
);
}
await pool.end();
});
test("Schedule: editing a title with bold + color via the Tiptap toolbar persists after reload", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(1);
const uniq = `${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 6)}`;
const originalTitle = `RichEdit Original ${uniq}`;
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: originalTitle,
titleAr: originalTitle,
startTime: "09:00:00",
endTime: "10:00:00",
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await resetSchedulePrefs(page);
await page.reload();
// Jump to the seeded date.
const dateInput = page.locator('input[type="date"]').first();
await dateInput.fill(date);
// Wait for the row we just inserted.
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 10_000 });
// Flip on edit mode so the EditableCell becomes interactive.
const toggle = page.getByTestId("em-edit-mode-toggle");
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
// Open the title cell editor and apply bold + a red color to ALL of
// the existing text, then save via the toolbar's check button. We
// deliberately keep the original text — the assertion is that the
// toolbar persists *formatting*, not that typing also works (typing
// is covered by the existing "edit toggle" tests).
const titleCell = page.getByTestId(`em-edit-title-${meetingId}`);
await titleCell.click();
const toolbar = page.getByTestId("em-edit-toolbar");
await expect(toolbar).toBeVisible();
// Wait for the contenteditable inside the editor to be ready, then
// select the entire title so the bold + color marks apply to every
// character. The editor itself opens with the cursor at the end of
// the existing text, so a simple ControlOrMeta+a is enough.
const editorContent = titleCell.locator('[contenteditable="true"]');
await expect(editorContent).toBeVisible();
await page.keyboard.press("ControlOrMeta+a");
await page.getByTestId("em-edit-bold").click();
await page.getByTestId("em-edit-color-red").click();
// Watch for the PATCH that saves the title before clicking save.
const savePromise = page.waitForResponse(
(resp) => {
const u = new URL(resp.url());
return (
u.pathname === `/api/executive-meetings/${meetingId}` &&
resp.request().method() === "PATCH" &&
resp.ok()
);
},
{ timeout: 15_000 },
);
await page.getByTestId("em-edit-save").click();
await savePromise;
await expect(toolbar).toBeHidden();
// Reload the page to confirm the formatting wasn't just sitting in
// a Tiptap document — it must round-trip via the server.
await page.reload();
await dateInput.fill(date);
const rowAfterReload = page.getByTestId(`em-row-${meetingId}`);
await expect(rowAfterReload).toBeVisible({ timeout: 10_000 });
const titleAfterReload = page.getByTestId(`em-edit-title-${meetingId}`);
await expect(titleAfterReload).toContainText(originalTitle);
// Inspect the rendered HTML in the cell. The server-sanitized output
// must keep the bold tag and the inline color span. We accept either
// <strong> (StarterKit's default tag) or <b> (some sanitizers
// collapse to <b>) so the assertion stays robust against the
// server's exact allowlist serialization.
const html = await titleAfterReload.innerHTML();
expect(html.toLowerCase()).toMatch(/<(strong|b)[\s>]/);
// The Tiptap "red" swatch is #dc2626. Browsers serialize inline
// styles in either hex or rgb form; accept both.
expect(html.toLowerCase()).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/);
// And confirm the DB itself stored the formatted HTML, not the plain
// text — this catches any frontend-only "looks formatted" regression.
// The page may render in either Arabic or English depending on the
// signed-in user's preferredLanguage, so we check whichever column
// the editor wrote to (the EditableCell saves to title_ar when RTL,
// title_en otherwise).
const { rows: dbRows } = await pool.query(
`SELECT title_ar, title_en FROM executive_meetings WHERE id = $1`,
[meetingId],
);
expect(dbRows.length).toBe(1);
const editedColumn = String(
/<(strong|b)[\s>]/i.test(String(dbRows[0].title_ar))
? dbRows[0].title_ar
: dbRows[0].title_en,
).toLowerCase();
expect(editedColumn).toMatch(/<(strong|b)[\s>]/);
expect(editedColumn).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/);
});
test("Schedule: dragging row 2 above row 1 swaps daily numbers and start times", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(2);
const uniq = `${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 6)}`;
const idA = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Drag A ${uniq}`,
titleAr: `سحب أ ${uniq}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
const idB = await insertMeeting({
meetingDate: date,
dailyNumber: 2,
titleEn: `Drag B ${uniq}`,
titleAr: `سحب ب ${uniq}`,
startTime: "11:00:00",
endTime: "12:00:00",
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await resetSchedulePrefs(page);
await page.reload();
await page.locator('input[type="date"]').first().fill(date);
await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId(`em-row-${idB}`)).toBeVisible();
// Edit mode must be on for the grip handle to render.
const toggle = page.getByTestId("em-edit-mode-toggle");
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
const gripA = page.getByTestId(`em-row-grip-${idA}`);
const gripB = page.getByTestId(`em-row-grip-${idB}`);
await expect(gripA).toBeVisible();
await expect(gripB).toBeVisible();
const fromBox = await gripB.boundingBox();
const toBox = await gripA.boundingBox();
expect(fromBox).not.toBeNull();
expect(toBox).not.toBeNull();
// dnd-kit's PointerSensor activates only after a 6px move from the
// pointerdown position, so we issue a small "warm-up" move before
// the long move to the target. Using `steps` keeps the synthetic
// pointer events spread across enough frames for dnd-kit to pick
// them up reliably.
const startX = fromBox.x + fromBox.width / 2;
const startY = fromBox.y + fromBox.height / 2;
const endX = toBox.x + toBox.width / 2;
// Aim slightly ABOVE the target row's vertical center so dnd-kit
// inserts the dragged row before the target instead of after.
const endY = toBox.y + 4;
const reorderPromise = page.waitForResponse(
(resp) => {
const u = new URL(resp.url());
return (
u.pathname === "/api/executive-meetings/reorder" &&
resp.request().method() === "POST" &&
resp.ok()
);
},
{ timeout: 15_000 },
);
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX, startY + 12, { steps: 5 });
await page.mouse.move(endX, endY, { steps: 15 });
await page.mouse.up();
await reorderPromise;
// The reorder API rewrites both daily_number and start_time/end_time
// so that the requested order owns the chronologically-sorted slots.
// After moving B above A, B should have daily_number=1 and the
// earlier 09:0010:00 slot, and A should fall to daily_number=2 with
// the later 11:0012:00 slot.
const { rows } = await pool.query(
`SELECT id, daily_number, start_time, end_time
FROM executive_meetings WHERE id = ANY($1::int[]) ORDER BY id`,
[[idA, idB]],
);
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
expect(byId[idB].daily_number).toBe(1);
expect(String(byId[idB].start_time)).toBe("09:00:00");
expect(String(byId[idB].end_time)).toBe("10:00:00");
expect(byId[idA].daily_number).toBe(2);
expect(String(byId[idA].start_time)).toBe("11:00:00");
expect(String(byId[idA].end_time)).toBe("12:00:00");
// A reload should reflect the new visual order in the table.
await page.reload();
await page.locator('input[type="date"]').first().fill(date);
await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
timeout: 10_000,
});
const rowsInOrder = await page
.locator('tr[data-testid^="em-row-"]')
.evaluateAll((els) => els.map((el) => el.getAttribute("data-testid")));
// Drop any rows from other dates (defensive — the schedule is
// date-scoped so there shouldn't be any, but just in case).
const orderedIds = rowsInOrder
.map((tid) => Number(tid?.replace("em-row-", "")))
.filter((n) => n === idA || n === idB);
expect(orderedIds).toEqual([idB, idA]);
});
test("Schedule: choosing a custom highlight color paints the current meeting's box-shadow ring", async ({
page,
}) => {
await setLangEn(page);
// The "current meeting" detection requires the row to be on TODAY
// and for now() to fall inside [startTime, endTime). Build a 60-min
// window centered on now so the test is robust to a few seconds of
// drift between page load and the highlight tick.
const date = todayIso();
const startTime = localTimeWithOffset(-30);
const endTime = localTimeWithOffset(+30);
// Use a dailyNumber high enough not to collide with anything seeded
// elsewhere in the test suite for today. The DB has a UNIQUE
// (meeting_date, daily_number) index so we pick something out of
// the way and recover with a single retry on collision.
let meetingId = null;
for (const dailyNumber of [991, 992, 993, 994, 995]) {
try {
meetingId = await insertMeeting({
meetingDate: date,
dailyNumber,
titleEn: `Highlight Now ${dailyNumber}`,
titleAr: `إبراز الآن ${dailyNumber}`,
startTime,
endTime,
});
break;
} catch (err) {
if (!/duplicate/i.test(String(err.message))) throw err;
}
}
expect(meetingId).not.toBeNull();
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await resetSchedulePrefs(page);
await page.reload();
// Today is the default date, but we set it explicitly to make the
// test robust to any future change of the page's initial state.
await page.locator('input[type="date"]').first().fill(date);
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 10_000 });
// The seeded row should be flagged as the current meeting via the
// data attribute. Wait for it — the highlight scheduling tick can
// need a frame to land after the data arrives.
await expect(row).toHaveAttribute("data-current-meeting", "true", {
timeout: 5_000,
});
// Default highlight color is green (#16a34a). The ring is rendered
// as `inset 0 0 0 2px <color>` via inline box-shadow on the <tr>.
const initialShadow = await row.evaluate(
(el) => getComputedStyle(el).boxShadow,
);
// Computed style normalizes hex to rgb; #16a34a → rgb(22, 163, 74).
expect(initialShadow).toMatch(/22,\s*163,\s*74/);
// Now open the customize popover and pick the red swatch (#dc2626).
await page.getByTestId("em-customize-columns-trigger").click();
const highlightToggle = page.getByTestId("em-highlight-toggle");
await expect(highlightToggle).toBeVisible();
// Make sure the highlight is enabled (the default is on, but assert
// it explicitly so the test isn't sensitive to future default flips).
const isOn = await highlightToggle.getAttribute("aria-checked");
if (isOn !== "true") {
await highlightToggle.click();
await expect(highlightToggle).toHaveAttribute("aria-checked", "true");
}
await page.getByTestId("em-highlight-color-#dc2626").click();
// Close the popover so it doesn't overlap further assertions.
await page.keyboard.press("Escape");
// The ring should now reflect the new color: #dc2626 → rgb(220, 38, 38).
await expect
.poll(
async () => row.evaluate((el) => getComputedStyle(el).boxShadow),
{ timeout: 5_000 },
)
.toMatch(/220,\s*38,\s*38/);
});
test("Schedule: a non-mutate user (executive_viewer) sees no grip handle and no edit toggle", async ({
page,
}) => {
await setLangEn(page);
// Seed a meeting so there's something to (try to) drag.
const date = uniqueFutureDate(3);
const uniq = `${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 6)}`;
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Viewer Only ${uniq}`,
titleAr: `مشاهد فقط ${uniq}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
// Grant the demo "ahmed" user the executive_viewer role (read-only).
// We track the assignment so afterAll can revoke it. The role is
// already seeded by scripts/src/seed.ts.
const { rows: ahmedRows } = await pool.query(
`SELECT id FROM users WHERE username = 'ahmed'`,
);
expect(ahmedRows.length).toBe(1);
const ahmedId = ahmedRows[0].id;
const { rows: roleRows } = await pool.query(
`SELECT id FROM roles WHERE name = 'executive_viewer'`,
);
expect(roleRows.length).toBe(1);
const viewerRoleId = roleRows[0].id;
const { rowCount } = await pool.query(
`INSERT INTO user_roles (user_id, role_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
[ahmedId, viewerRoleId],
);
// Only schedule cleanup if WE were the ones who added the row —
// otherwise we'd revoke a role ahmed had legitimately.
if (rowCount === 1) {
grantedRoleAssignments.push({ userId: ahmedId, roleId: viewerRoleId });
}
await loginViaUi(page, "ahmed", "user123");
await page.goto("/executive-meetings");
await resetSchedulePrefs(page);
await page.reload();
await page.locator('input[type="date"]').first().fill(date);
// The seeded row must be visible (read access works).
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 10_000 });
// No grip handles anywhere — they only render when the row prop
// canMutate is true, which equals me.canMutate && editMode.
await expect(
page.locator('[data-testid^="em-row-grip-"]'),
).toHaveCount(0);
// The view/edit toggle is rendered ONLY for users who have the
// mutate capability, so a viewer should never see it.
await expect(page.getByTestId("em-edit-mode-toggle")).toHaveCount(0);
// Sanity: the customize popover is still available (it's purely a
// presentation control and does not require mutate access), but the
// row-color trigger and merge trigger — both edit-mode-only — must
// not be present.
await expect(page.getByTestId("em-row-color-trigger")).toHaveCount(0);
await expect(
page.locator('[data-testid^="em-merge-trigger-"]'),
).toHaveCount(0);
// Reorder API must reject a viewer's POST as well — defense in depth.
const reorderResp = await page.request.post(
"/api/executive-meetings/reorder",
{
data: { meetingDate: date, orderedIds: [meetingId] },
},
);
expect(reorderResp.status()).toBeGreaterThanOrEqual(400);
});