Files
TX/artifacts/tx-os/tests/executive-meetings-schedule-features.spec.mjs
T
riyadhafraa 11f169f8c5 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
2026-04-30 05:31:34 +00:00

565 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});