Add browser test for editing attendee names with formatting (Task #186)

Original task: cover the inline attendee-name edit flow in the
Executive Meetings schedule. The attendee row uses the same
EditableCell + Tiptap toolbar as titles (parent task #122), but until
now only the title path had end-to-end coverage. A regression in
attendee-name formatting would only have surfaced via manual QA.

Changes:
- Extended artifacts/tx-os/tests/executive-meetings-schedule-features.spec.mjs
  with a new scenario:
  * seeds a meeting + one plain-text attendee directly in the DB,
  * logs in as admin, navigates to the schedule, jumps to the seeded
    date, flips on edit mode,
  * clicks the inline attendee EditableCell, selects all, applies bold
    + the red color swatch via the toolbar, and saves via the check
    button while waiting for PUT /api/executive-meetings/:id/attendees
    to succeed,
  * reloads the page, re-navigates to the date, and asserts the
    rendered HTML in the cell still contains <strong>/<b> and the red
    color (#dc2626 or rgb(220,38,38)),
  * additionally queries executive_meeting_attendees.name in the DB to
    confirm the formatted HTML round-tripped through the server, not
    just survived in the local Tiptap document.
- Added a small insertAttendee helper in the same spec to seed initial
  state without driving the manage-dialog flow.
- The existing afterAll already deletes attendee rows for the created
  meetings (cascade-safe), so no cleanup changes were needed.

Verification: ran the new test in isolation (passed in 9.1s) and the
full schedule-features spec (5/5 passed in 40.1s). No code changes
outside the test file.

Follow-up filed: #270 — browser test for clearing an attendee's name
to trigger the delete-row gesture (separate documented behavior path
with no end-to-end coverage today).
This commit is contained in:
Riyadh
2026-05-01 11:02:39 +00:00
parent ff1c7764ea
commit ffe4400838
@@ -76,6 +76,23 @@ async function setLangEn(page) {
});
}
// Insert a single attendee row directly. Used by the attendee-name
// editing scenario so we don't have to drive the manage-dialog flow
// just to seed initial state.
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],
);
}
// Insert a meeting row directly. Returns the new id.
async function insertMeeting({
meetingDate,
@@ -562,3 +579,127 @@ test("Schedule: a non-mutate user (executive_viewer) sees no grip handle and no
);
expect(reorderResp.status()).toBeGreaterThanOrEqual(400);
});
test("Schedule: editing an attendee name with bold + color via the Tiptap toolbar persists after reload", async ({
page,
}) => {
await setLangEn(page);
// Seed a meeting with one attendee whose name is plain text. The
// attendee row uses the SAME EditableCell + Tiptap toolbar as titles
// (parent task #122), so applying bold + a color swatch and saving
// via the toolbar's check button must round-trip through the PUT
// attendees endpoint and survive a full reload, just like titles do.
const date = uniqueFutureDate(4);
const uniq = `${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 6)}`;
const originalName = `RichAttendee Original ${uniq}`;
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Attendee Host ${uniq}`,
titleAr: `مضيف ${uniq}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
await insertAttendee({
meetingId,
name: originalName,
attendanceType: "internal",
sortOrder: 0,
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await resetSchedulePrefs(page);
await page.reload();
// Jump to the seeded date and wait for the row.
const dateInput = page.locator('input[type="date"]').first();
await dateInput.fill(date);
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 10_000 });
// Flip on edit mode so the attendee EditableCell becomes interactive.
const toggle = page.getByTestId("em-edit-mode-toggle");
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
// The testid uses the original index in meeting.attendees (0). Scope
// under the seeded row so the selector stays unambiguous.
const attendeeCell = row.getByTestId(`em-edit-attendee-0`);
await expect(attendeeCell).toBeVisible();
await expect(attendeeCell).toContainText(originalName);
// Click to enter edit mode, then select the entire name and apply
// bold + the red color swatch. Same flow as the title test above —
// we keep the original text so the assertion is purely about the
// toolbar persisting *formatting*, not typing.
await attendeeCell.click();
const toolbar = page.getByTestId("em-edit-toolbar");
await expect(toolbar).toBeVisible();
const editorContent = attendeeCell.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 PUT that saves the attendees array before clicking
// save. Inline attendee edits go through PUT /attendees, NOT the
// meeting-level PATCH used by titles.
const savePromise = page.waitForResponse(
(resp) => {
const u = new URL(resp.url());
return (
u.pathname === `/api/executive-meetings/${meetingId}/attendees` &&
resp.request().method() === "PUT" &&
resp.ok()
);
},
{ timeout: 15_000 },
);
await page.getByTestId("em-edit-save").click();
await savePromise;
await expect(toolbar).toBeHidden();
// Reload to confirm the formatting wasn't just sitting in the local
// 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 attendeeAfterReload = rowAfterReload.getByTestId(`em-edit-attendee-0`);
await expect(attendeeAfterReload).toContainText(originalName);
// Inspect the rendered HTML in the cell. The server-sanitized output
// must keep the bold tag and the inline color span. 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 attendeeAfterReload.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.
// Attendees have a single `name` column (no AR/EN split), so the
// assertion is simpler than the title case.
const { rows: dbRows } = await pool.query(
`SELECT name FROM executive_meeting_attendees
WHERE meeting_id = $1 ORDER BY sort_order`,
[meetingId],
);
expect(dbRows.length).toBe(1);
const storedName = String(dbRows[0].name).toLowerCase();
expect(storedName).toMatch(/<(strong|b)[\s>]/);
expect(storedName).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/);
// The original plain text is still readable inside the formatted markup.
expect(storedName).toContain(originalName.toLowerCase());
});