import { test, expect } from "@playwright/test"; import pg from "pg"; // E2E coverage for keyboard editing in the executive-meetings schedule. // These tests verify the keyboard contract for the three inline-edit // cells (time range, title, attendee name): // // - Tab navigation reaches the cell (it is keyboard-reachable via // tabIndex=0). // - Pressing Enter on the focused display opens edit mode. // - Pressing Enter inside the editor saves (PATCH/PUT succeeds, // editor collapses to display mode). // - Pressing Escape cancels — original value is restored, NO PATCH/PUT // is sent, editor collapses. // - Focus does not get stranded on an unmounted node after save/cancel // (it returns to body or the row, not into the void). // // Each scenario seeds its own meeting row on a far-future date so we // never collide with real schedule data, and afterAll cleanup deletes // the rows we created. const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error( "DATABASE_URL must be set to run the executive-meetings keyboard-editing UI tests", ); } const pool = new pg.Pool({ connectionString: DATABASE_URL }); const createdMeetingIds = []; 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. 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 */ } }); } 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], ); } 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); } // Switch the schedule into edit mode so the inline editors are // interactive. Idempotent — clicks the toggle only when off. async function ensureEditMode(page) { const toggle = page.getByTestId("em-edit-mode-toggle"); await expect(toggle).toBeVisible(); const pressed = await toggle.getAttribute("aria-pressed"); if (pressed !== "true") { await toggle.click(); await expect(toggle).toHaveAttribute("aria-pressed", "true"); } } // Common setup: log in, navigate to /executive-meetings, reset prefs, // reload, jump to the seeded date, wait for the row, flip on edit mode. async function gotoMeetingRow(page, meetingId, date) { 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); const row = page.getByTestId(`em-row-${meetingId}`); await expect(row).toBeVisible({ timeout: 10_000 }); await ensureEditMode(page); return row; } // Returns the data-testid attribute of the currently-focused element, // or null when focus is on body / has no testid. Useful for asserting // keyboard navigation lands on the right cell without depending on a // fragile Tab count. async function activeTestId(page) { return await page.evaluate(() => { const el = document.activeElement; if (!el || el === document.body) return null; return el.getAttribute("data-testid"); }); } // Tab forward up to `maxSteps` times until the focused element has the // given test id. Throws via expect() if we never reach it. We use this // instead of a hard-coded number of Tab presses so the test stays // stable against small DOM changes (e.g. extra row-overlay buttons). async function tabUntilFocused(page, targetTestId, maxSteps = 25) { for (let i = 0; i < maxSteps; i++) { const cur = await activeTestId(page); if (cur === targetTestId) return; await page.keyboard.press("Tab"); } const cur = await activeTestId(page); expect( cur, `expected focus to reach ${targetTestId} within ${maxSteps} Tab presses`, ).toBe(targetTestId); } // Wait for the schedule's day-level GET refetch to land. After every // inline save the page calls qc.invalidateQueries({ queryKey: [ // "/api/executive-meetings", date ] }), which fires a fresh GET. The // cell re-renders only AFTER that GET resolves, so polling for the new // text without first awaiting the refetch can race. function waitForDayRefetch(page, date) { return page.waitForResponse( (resp) => { const u = new URL(resp.url()); return ( u.pathname === "/api/executive-meetings" && u.searchParams.get("date") === date && resp.request().method() === "GET" && resp.ok() ); }, { timeout: 15_000 }, ); } 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], ); } await pool.end(); }); test("Schedule keyboard: Tab into time cell, Enter opens edit, valid times save via Enter and PATCH succeeds", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(1); const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `Keyboard Time ${Date.now().toString(36)}`, titleAr: `وقت لوحة المفاتيح ${Date.now().toString(36)}`, startTime: "09:00:00", endTime: "10:00:00", }); await gotoMeetingRow(page, meetingId, date); const timeCell = page.getByTestId(`em-time-${meetingId}`); await expect(timeCell).toBeVisible(); // Display variant is rendered as
. // Confirm the cell is keyboard-reachable: focus it via the DOM (a // raw Tab walk is also covered by a separate tab-order test below; // the exact step count from the start of the page would depend on // how many overlay buttons render in this row, which is not the // target of this assertion). await timeCell.evaluate((el) => el.focus()); expect(await activeTestId(page)).toBe(`em-time-${meetingId}`); // Enter on the focused display opens edit mode. await page.keyboard.press("Enter"); const startInput = page.getByTestId(`em-time-start-${meetingId}`); const endInput = page.getByTestId(`em-time-end-${meetingId}`); await expect(startInput).toBeVisible(); await expect(endInput).toBeVisible(); // The start input auto-focuses on enter-edit so the user can begin // typing immediately. await expect(startInput).toBeFocused(); // Type valid new times. await startInput.fill("13:30"); await endInput.focus(); await endInput.fill("14:45"); // Watch for the PATCH that the Enter keypress should trigger AND // for the day-level GET refetch the page kicks off afterwards. The // displayed cell only updates once the refetch resolves. 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 }, ); const refetchPromise = waitForDayRefetch(page, date); await page.keyboard.press("Enter"); await Promise.all([savePromise, refetchPromise]); // Editor must collapse back to display mode and show the new values. // The display cell renders compact 12h with NO AM/PM marker per // formatTime() — "13:30" (24h wire) renders as "1:30" in the cell. // The DB row asserted below is the source of truth for the wire // value; the regex here just confirms the display refreshed. await expect(startInput).toHaveCount(0); await expect(endInput).toHaveCount(0); await expect(timeCell).toHaveText(/1:30\s*[\u2013\-]\s*2:45/, { timeout: 10_000, }); // The DB must reflect the PATCH (server is the source of truth). const { rows } = await pool.query( `SELECT start_time, end_time FROM executive_meetings WHERE id = $1`, [meetingId], ); expect(String(rows[0].start_time)).toBe("13:30:00"); expect(String(rows[0].end_time)).toBe("14:45:00"); // Focus must return to a sensible element — either the (re-rendered) // display cell or the document body. What it MUST NOT do is land on // an unmounted input or escape the page entirely. const focused = await activeTestId(page); expect(focused === null || focused === `em-time-${meetingId}`).toBe(true); }); test("Schedule keyboard: time cell Esc restores original times and sends no PATCH", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(2); const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `Keyboard Time Esc ${Date.now().toString(36)}`, titleAr: `إلغاء الوقت ${Date.now().toString(36)}`, startTime: "08:15:00", endTime: "09:45:00", }); await gotoMeetingRow(page, meetingId, date); const timeCell = page.getByTestId(`em-time-${meetingId}`); // formatTime() renders compact 12h with no AM/PM marker, dropping // the leading zero — "08:15" (24h wire) shows as "8:15" in the cell. await expect(timeCell).toHaveText(/8:15\s*[\u2013\-]\s*9:45/); // Spy on any PATCH to this meeting — Escape must not trigger one. let patchSeen = false; page.on("response", (resp) => { try { const u = new URL(resp.url()); if ( u.pathname === `/api/executive-meetings/${meetingId}` && resp.request().method() === "PATCH" ) { patchSeen = true; } } catch { /* ignore */ } }); await timeCell.evaluate((el) => el.focus()); await page.keyboard.press("Enter"); const startInput = page.getByTestId(`em-time-start-${meetingId}`); const endInput = page.getByTestId(`em-time-end-${meetingId}`); await expect(startInput).toBeFocused(); // Type something different from the saved value so that, if Escape // failed to cancel, we'd see a real change in the cell. await startInput.fill("17:00"); await endInput.focus(); await endInput.fill("18:30"); // Esc cancels — editor closes, original values restored. Display // cell uses the same compact 12h, no AM/PM, no-leading-zero shape // formatTime() produces. await page.keyboard.press("Escape"); await expect(startInput).toHaveCount(0); await expect(endInput).toHaveCount(0); await expect(timeCell).toHaveText(/8:15\s*[\u2013\-]\s*9:45/); // Give the page a beat to make sure no late PATCH fires from a // delayed blur path before we assert. await page.waitForTimeout(400); expect(patchSeen).toBe(false); // DB must still hold the originals. const { rows } = await pool.query( `SELECT start_time, end_time FROM executive_meetings WHERE id = $1`, [meetingId], ); expect(String(rows[0].start_time)).toBe("08:15:00"); expect(String(rows[0].end_time)).toBe("09:45:00"); // Focus is sensible after cancel (cell or body, not on a removed input). const focused = await activeTestId(page); expect(focused === null || focused === `em-time-${meetingId}`).toBe(true); }); test("Schedule keyboard: title cell Enter opens edit and Enter saves the new value via PATCH", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(3); const uniq = Date.now().toString(36); const originalTitle = `KbdTitle Original ${uniq}`; // We append (instead of select-all + replace) because typing-as-append // exercises the same Enter-saves contract without depending on the // ProseMirror selectAll keymap (which can be flaky to drive from // outside the editor in headless Chromium). const appended = ` SAVED-${uniq}`; const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: originalTitle, titleAr: originalTitle, startTime: "09:00:00", endTime: "10:00:00", }); await gotoMeetingRow(page, meetingId, date); const titleCell = page.getByTestId(`em-edit-title-${meetingId}`); await expect(titleCell).toContainText(originalTitle); // Confirm the title cell is keyboard-reachable (tabIndex=0) by // focusing it directly, then press Enter to enter edit mode. await titleCell.evaluate((el) => el.focus()); expect(await activeTestId(page)).toBe(`em-edit-title-${meetingId}`); await page.keyboard.press("Enter"); const toolbar = page.getByTestId("em-edit-toolbar"); await expect(toolbar).toBeVisible(); const editorContent = titleCell.locator('[contenteditable="true"]'); await expect(editorContent).toBeVisible(); await expect(editorContent).toBeFocused(); // The editor opens with the cursor at the end of the existing text // (commands.focus("end")), so plain typing appends. await page.keyboard.type(appended); 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 }, ); const refetchPromise = waitForDayRefetch(page, date); // Single-line title (multiline=false): Enter (no Shift) saves via // the EditableCell keydown handler. await page.keyboard.press("Enter"); await Promise.all([savePromise, refetchPromise]); const expectedTitle = originalTitle + appended; await expect(toolbar).toBeHidden(); await expect(titleCell).toContainText(expectedTitle, { timeout: 10_000 }); // DB confirms the save round-tripped. The EditableCell saves to // title_ar when the page is RTL (admin's preferredLanguage drives // this) and title_en when LTR — so check whichever column actually // received the saved HTML, exactly like the schedule-features // formatting test does. const { rows } = await pool.query( `SELECT title_ar, title_en FROM executive_meetings WHERE id = $1`, [meetingId], ); const savedTitle = String(rows[0].title_ar).includes(appended) ? String(rows[0].title_ar) : String(rows[0].title_en); expect(savedTitle).toContain(originalTitle); expect(savedTitle).toContain(appended); const focused = await activeTestId(page); expect(focused === null || focused === `em-edit-title-${meetingId}`).toBe( true, ); }); test("Schedule keyboard: title cell Esc closes editor, restores the original, and sends no PATCH", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(4); const uniq = Date.now().toString(36); const originalTitle = `KbdTitleEsc Original ${uniq}`; const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: originalTitle, titleAr: originalTitle, startTime: "09:00:00", endTime: "10:00:00", }); await gotoMeetingRow(page, meetingId, date); const titleCell = page.getByTestId(`em-edit-title-${meetingId}`); await expect(titleCell).toContainText(originalTitle); let patchSeen = false; page.on("response", (resp) => { try { const u = new URL(resp.url()); if ( u.pathname === `/api/executive-meetings/${meetingId}` && resp.request().method() === "PATCH" ) { patchSeen = true; } } catch { /* ignore */ } }); await titleCell.evaluate((el) => el.focus()); await page.keyboard.press("Enter"); const toolbar = page.getByTestId("em-edit-toolbar"); await expect(toolbar).toBeVisible(); const editorContent = titleCell.locator('[contenteditable="true"]'); await expect(editorContent).toBeFocused(); // Type a draft change that we expect Esc to throw away. await page.keyboard.type(" DRAFT-NEVER-SAVED"); await page.keyboard.press("Escape"); await expect(toolbar).toBeHidden(); await expect(titleCell).toContainText(originalTitle); await expect(titleCell).not.toContainText("DRAFT-NEVER-SAVED"); await page.waitForTimeout(400); expect(patchSeen).toBe(false); // DB still holds the original. const { rows } = await pool.query( `SELECT title_en FROM executive_meetings WHERE id = $1`, [meetingId], ); expect(String(rows[0].title_en)).toBe(originalTitle); const focused = await activeTestId(page); expect(focused === null || focused === `em-edit-title-${meetingId}`).toBe( true, ); }); test("Schedule keyboard: attendee cell Enter opens edit and Enter saves the new name via PUT", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(5); const uniq = Date.now().toString(36); const originalName = `KbdAtt Original ${uniq}`; // Append a suffix rather than replace, for the same reason as the // title test above: it exercises the same Enter-saves contract // without depending on an external select-all keymap. const appended = ` SAVED-${uniq}`; const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `Att Host ${uniq}`, titleAr: `مضيف ${uniq}`, startTime: "09:00:00", endTime: "10:00:00", }); await insertAttendee({ meetingId, name: originalName, attendanceType: "internal", sortOrder: 0, }); const row = await gotoMeetingRow(page, meetingId, date); // The testid uses the original index in meeting.attendees (0). // Scope under the seeded row so the selector stays unambiguous even // if a future test ever shares the same date. const attendeeCell = row.getByTestId(`em-edit-attendee-0`); await expect(attendeeCell).toBeVisible(); await expect(attendeeCell).toContainText(originalName); await attendeeCell.evaluate((el) => el.focus()); expect(await activeTestId(page)).toBe(`em-edit-attendee-0`); await page.keyboard.press("Enter"); const toolbar = page.getByTestId("em-edit-toolbar"); await expect(toolbar).toBeVisible(); const editorContent = attendeeCell.locator('[contenteditable="true"]'); await expect(editorContent).toBeVisible(); await expect(editorContent).toBeFocused(); // Cursor opens at end (focus("end")), so typing appends. await page.keyboard.type(appended); 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 }, ); const refetchPromise = waitForDayRefetch(page, date); await page.keyboard.press("Enter"); await Promise.all([savePromise, refetchPromise]); const expectedName = originalName + appended; await expect(toolbar).toBeHidden(); await expect(attendeeCell).toContainText(expectedName, { timeout: 10_000 }); const { rows } = await pool.query( `SELECT name FROM executive_meeting_attendees WHERE meeting_id = $1 ORDER BY sort_order`, [meetingId], ); expect(rows.length).toBe(1); expect(String(rows[0].name)).toContain(originalName); expect(String(rows[0].name)).toContain(appended); const focused = await activeTestId(page); expect(focused === null || focused === `em-edit-attendee-0`).toBe(true); }); test("Schedule keyboard: attendee cell Esc closes editor, restores the original, and sends no PUT", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(6); const uniq = Date.now().toString(36); const originalName = `KbdAttEsc Original ${uniq}`; const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `Att Host Esc ${uniq}`, titleAr: `مضيف إلغاء ${uniq}`, startTime: "09:00:00", endTime: "10:00:00", }); await insertAttendee({ meetingId, name: originalName, attendanceType: "internal", sortOrder: 0, }); const row = await gotoMeetingRow(page, meetingId, date); const attendeeCell = row.getByTestId(`em-edit-attendee-0`); await expect(attendeeCell).toContainText(originalName); let putSeen = false; page.on("response", (resp) => { try { const u = new URL(resp.url()); if ( u.pathname === `/api/executive-meetings/${meetingId}/attendees` && resp.request().method() === "PUT" ) { putSeen = true; } } catch { /* ignore */ } }); await attendeeCell.evaluate((el) => el.focus()); await page.keyboard.press("Enter"); const toolbar = page.getByTestId("em-edit-toolbar"); await expect(toolbar).toBeVisible(); const editorContent = attendeeCell.locator('[contenteditable="true"]'); await expect(editorContent).toBeFocused(); await page.keyboard.type(" DRAFT-NEVER-SAVED"); await page.keyboard.press("Escape"); await expect(toolbar).toBeHidden(); await expect(attendeeCell).toContainText(originalName); await expect(attendeeCell).not.toContainText("DRAFT-NEVER-SAVED"); await page.waitForTimeout(400); expect(putSeen).toBe(false); // DB unchanged. const { rows } = await pool.query( `SELECT name FROM executive_meeting_attendees WHERE meeting_id = $1 ORDER BY sort_order`, [meetingId], ); expect(rows.length).toBe(1); expect(String(rows[0].name)).toBe(originalName); const focused = await activeTestId(page); expect(focused === null || focused === `em-edit-attendee-0`).toBe(true); }); test("Schedule keyboard: Tab from the title cell reaches the time cell in the same row", async ({ page, }) => { // Covers the keyboard-reachability requirement ("Tab into a time // cell"). We seed a row with NO attendees so the tab path between // the title and the time cell is short and stable. await setLangEn(page); const date = uniqueFutureDate(7); const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `Tab Order ${Date.now().toString(36)}`, titleAr: `ترتيب التبويب ${Date.now().toString(36)}`, startTime: "09:00:00", endTime: "10:00:00", }); await gotoMeetingRow(page, meetingId, date); const titleCell = page.getByTestId(`em-edit-title-${meetingId}`); await titleCell.evaluate((el) => el.focus()); expect(await activeTestId(page)).toBe(`em-edit-title-${meetingId}`); // Walk forward with Tab until we land on the time cell. Bounded so a // future regression that breaks tab order fails fast instead of // tabbing forever. await tabUntilFocused(page, `em-time-${meetingId}`, 30); // Pressing Enter on the focused time display opens edit mode. await page.keyboard.press("Enter"); await expect(page.getByTestId(`em-time-start-${meetingId}`)).toBeFocused(); }); // ------------------------------------------------------------------- // Task #308: typing on the keyboard + Enter must save. // // The historical bug: the time cell used , which // only fires onChange when the value is a complete, canonical "HH:MM" // string. Typing "9:30" without a leading zero, "0930" compact, "9:30 // PM", or Arabic-Indic digits left the controlled React state empty, // so pressing Enter ran save() with the stale value, equality with // the saved value short-circuited, no PATCH fired, and the cell // snapped back to "—" (or the prior value) — looking to the user // like Enter "didn't save". // // These cases exercise each typed shape through the full UI stack // (keystrokes → React state → save() → PATCH → DB → refetched cell // text) so a regression at any layer trips a test. // ------------------------------------------------------------------- // Reusable helper: open a meeting's time cell, focus the start input, // clear it, type a new value, optionally pick the AM/PM toggle, do // the same for end, press Enter, and wait for the PATCH + day GET // refetch. // // Per task #315 the time cell is now a 12-hour picker — typing a // 1–12 hour without first picking AM or PM is intentionally an // ambiguous parse error (the fix for the "1:15 silently saved as // AM" bug). For typed shapes that the picker can resolve on its // own (an explicit AM/PM marker like "9:30 PM", or a 24h-only hour // like "13:30") `period` should be `null`; for ambiguous shapes // pass "am" or "pm" so the helper clicks the matching toggle // before pressing Enter. async function typeTimesAndSave( page, meetingId, date, startText, endText, startPeriod = null, endPeriod = null, ) { const timeCell = page.getByTestId(`em-time-${meetingId}`); await timeCell.evaluate((el) => el.focus()); await page.keyboard.press("Enter"); const startInput = page.getByTestId(`em-time-start-${meetingId}`); const endInput = page.getByTestId(`em-time-end-${meetingId}`); await expect(startInput).toBeVisible(); await expect(startInput).toBeFocused(); // Clear and type into start. Use page.keyboard.type for the AR-digit // case so the OS IME doesn't intercept; for ASCII we use fill() which // is faster and equivalent. await startInput.click(); await page.keyboard.press("ControlOrMeta+a"); await page.keyboard.press("Delete"); await page.keyboard.type(startText, { delay: 10 }); if (startPeriod) { await page .getByTestId(`em-time-start-period-${startPeriod}-${meetingId}`) .click(); } await endInput.click(); await page.keyboard.press("ControlOrMeta+a"); await page.keyboard.press("Delete"); await page.keyboard.type(endText, { delay: 10 }); if (endPeriod) { await page .getByTestId(`em-time-end-period-${endPeriod}-${meetingId}`) .click(); } 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 }, ); const refetchPromise = waitForDayRefetch(page, date); // Re-focus the end input so Enter triggers save() rather than // re-activating the AM/PM toggle that was last clicked. await endInput.focus(); await page.keyboard.press("Enter"); await Promise.all([savePromise, refetchPromise]); } // Each row in this matrix is one accepted typing shape from the task's // "Done looks like" list. Both fields use the same shape so the // assertions stay simple, and the tested shapes intentionally vary in // hour/minute so a test that accidentally passes by reading stale // state (rather than the new value) would fail. // // `startPeriod` / `endPeriod` mirror task #315's "the picker has no // implicit default" rule: ambiguous typed shapes (1–12 hour, no // marker) require the helper to click the AM/PM toggle, while // shapes with an explicit marker or a 24h-only hour are resolved by // the picker without a toggle pick. const TYPING_SHAPES = [ { offset: 8, label: "canonical 24h", startText: "13:30", endText: "14:45", startPeriod: null, endPeriod: null, expectStart: "13:30:00", expectEnd: "14:45:00" }, { offset: 9, label: "no leading zero + AM", startText: "9:30", endText: "10:45", startPeriod: "am", endPeriod: "am", expectStart: "09:30:00", expectEnd: "10:45:00" }, { offset: 10, label: "compact HHMM + AM", startText: "0930", endText: "1045", startPeriod: "am", endPeriod: "am", expectStart: "09:30:00", expectEnd: "10:45:00" }, { offset: 11, label: "12h with PM", startText: "9:30 AM", endText: "9:30 PM", startPeriod: null, endPeriod: null, expectStart: "09:30:00", expectEnd: "21:30:00" }, // The "0115 + PM = 13:15" shape from the task spec — the toggle // promotes the typed compact hour to PM. { offset: 12, label: "compact + PM toggle", startText: "0115", endText: "0230", startPeriod: "pm", endPeriod: "pm", expectStart: "13:15:00", expectEnd: "14:30:00" }, ]; for (const shape of TYPING_SHAPES) { test(`Schedule keyboard: typing "${shape.label}" + Enter saves and PATCH succeeds`, async ({ page, }) => { await setLangEn(page); // Each shape gets its own future date offset so the loop never // collides on the (date, daily_number) unique constraint when // these tests run in the same worker. const date = uniqueFutureDate(shape.offset); const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `KbdType ${shape.label} ${Date.now().toString(36)}`, titleAr: `إدخال ${shape.label} ${Date.now().toString(36)}`, // Seed with an empty time cell so we exercise the // "type from scratch" path the user reported broken. startTime: null, endTime: null, }); await gotoMeetingRow(page, meetingId, date); await typeTimesAndSave( page, meetingId, date, shape.startText, shape.endText, shape.startPeriod, shape.endPeriod, ); const timeCell = page.getByTestId(`em-time-${meetingId}`); await expect(page.getByTestId(`em-time-start-${meetingId}`)).toHaveCount(0); await expect(page.getByTestId(`em-time-end-${meetingId}`)).toHaveCount(0); // Don't pin the displayed text format — formatTime() picks 12h/24h // per locale and the dash glyph varies. The DB row is the source // of truth and is asserted below. await expect(timeCell).not.toHaveText("—", { timeout: 10_000 }); const { rows } = await pool.query( `SELECT start_time, end_time FROM executive_meetings WHERE id = $1`, [meetingId], ); expect(String(rows[0].start_time)).toBe(shape.expectStart); expect(String(rows[0].end_time)).toBe(shape.expectEnd); }); } test("Schedule keyboard (AR): typing Arabic-Indic digits + Enter saves canonical HH:MM", async ({ page, }) => { // Default lang is Arabic — don't set EN. This mirrors how an Arabic // user types times from their keyboard layout. const date = uniqueFutureDate(13); const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `KbdAr ${Date.now().toString(36)}`, titleAr: `أرقام عربية ${Date.now().toString(36)}`, startTime: null, endTime: null, }); await gotoMeetingRow(page, meetingId, date); // ٠٩:٣٠ -> 09:30, ١٠:٤٥ -> 10:45. Both are 1–12 hours so the // picker requires an explicit AM/PM choice (task #315) — pass the // Arabic morning marker via the toggle so the helper clicks ص. await typeTimesAndSave(page, meetingId, date, "٠٩:٣٠", "١٠:٤٥", "am", "am"); const { rows } = await pool.query( `SELECT start_time, end_time FROM executive_meetings WHERE id = $1`, [meetingId], ); expect(String(rows[0].start_time)).toBe("09:30:00"); expect(String(rows[0].end_time)).toBe("10:45:00"); }); // ------------------------------------------------------------------- // Task #311: dragging a visible meeting must produce a chronologically- // sorted visible list, even when the same day contains hidden cancelled // meetings. Before the fix, cancelled meetings (filtered out of // `orderedMeetings` for display) were still included in the reorder // POST under their raw-list positions, so the server's slot-swap stole // chronologically-sorted slots for them and the visible rows ended up // out of order on screen. // ------------------------------------------------------------------- test("Schedule drag-reorder: cancelled rows on the same day do not disturb the visible chronological order", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(20); // Three visible meetings (A, C, D) bracketing a cancelled meeting B // sandwiched in the middle of the day's daily_number sequence. The // cancelled meeting MUST keep its time slot and daily number; the // visible meetings MUST end up in chronological start-time order // top-to-bottom after the drag. const a = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `DragA ${Date.now().toString(36)}`, titleAr: `سحبأ ${Date.now().toString(36)}`, startTime: "09:00:00", endTime: "09:30:00", }); const b = await insertMeeting({ meetingDate: date, dailyNumber: 2, titleEn: `DragB-cancelled ${Date.now().toString(36)}`, titleAr: `سحبب-ملغى ${Date.now().toString(36)}`, startTime: "10:00:00", endTime: "10:30:00", }); const c = await insertMeeting({ meetingDate: date, dailyNumber: 3, titleEn: `DragC ${Date.now().toString(36)}`, titleAr: `سحبج ${Date.now().toString(36)}`, startTime: "11:00:00", endTime: "11:30:00", }); const d = await insertMeeting({ meetingDate: date, dailyNumber: 4, titleEn: `DragD ${Date.now().toString(36)}`, titleAr: `سحبد ${Date.now().toString(36)}`, startTime: "12:00:00", endTime: "12:30:00", }); // Mark B cancelled directly via SQL so the visible list seen by // dnd-kit is [A, C, D]; the underlying day still holds 4 rows. await pool.query( `UPDATE executive_meetings SET status = 'cancelled' WHERE id = $1`, [b], ); // Capture B's pre-reorder slot so we can assert it is untouched // after the drag. const { rows: bBeforeRows } = await pool.query( `SELECT daily_number, start_time, end_time FROM executive_meetings WHERE id = $1`, [b], ); const bBefore = bBeforeRows[0]; // Log in, navigate, switch to edit mode (drag handles only show // when edit mode is on). 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); const rowA = page.getByTestId(`em-row-${a}`); const rowC = page.getByTestId(`em-row-${c}`); const rowD = page.getByTestId(`em-row-${d}`); await expect(rowA).toBeVisible({ timeout: 10_000 }); await expect(rowC).toBeVisible(); await expect(rowD).toBeVisible(); // Cancelled rows are hidden from the schedule (#273). await expect(page.getByTestId(`em-row-${b}`)).toHaveCount(0); await ensureEditMode(page); // Drive the reorder through dnd-kit's keyboard sensor — focus the // grip on the bottom visible row (D) and use Space + ArrowUp twice // + Space to move it to the top. This exercises the REAL onDragEnd // callback path (including the client's `orderedMeetings`-based // index math) end-to-end. Headless pixel drag against // SortableContext is unreliable, so keyboard drag is the high- // fidelity equivalent — dnd-kit treats both the same downstream. const gripD = page.getByTestId(`em-row-grip-${d}`); await expect(gripD).toBeVisible(); await gripD.focus(); const savePromise = 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 }, ); const refetchPromise = waitForDayRefetch(page, date); await page.keyboard.press("Space"); // pick up D // Brief settle pause — dnd-kit re-measures the SortableContext after // pick-up before the keyboard sensor accepts movement keys, so a // back-to-back ArrowUp can otherwise be dropped on the floor. await page.waitForTimeout(150); await page.keyboard.press("ArrowUp"); // D over C await page.waitForTimeout(150); await page.keyboard.press("ArrowUp"); // D over A await page.waitForTimeout(150); await page.keyboard.press("Space"); // drop -> visible order [D, A, C] await Promise.all([savePromise, refetchPromise]); // After the reorder the visible list must read D, A, C top-to-bottom // and the times must be chronologically sorted. const { rows: visibleAfter } = await pool.query( `SELECT id, daily_number, start_time, end_time, status FROM executive_meetings WHERE meeting_date = $1 AND status <> 'cancelled' ORDER BY daily_number ASC`, [date], ); expect(visibleAfter.map((r) => r.id)).toEqual([d, a, c]); // Chronologically increasing. for (let i = 1; i < visibleAfter.length; i++) { expect( String(visibleAfter[i - 1].start_time) <= String(visibleAfter[i].start_time), ).toBe(true); } // Concretely the slot-swap must produce 09:00, 11:00, 12:00 for D, A, C // (B's 10:00 stays with B). expect(String(visibleAfter[0].start_time)).toBe("09:00:00"); expect(String(visibleAfter[1].start_time)).toBe("11:00:00"); expect(String(visibleAfter[2].start_time)).toBe("12:00:00"); // Cancelled B: time slot AND daily_number must be unchanged. const { rows: bAfterRows } = await pool.query( `SELECT daily_number, start_time, end_time, status FROM executive_meetings WHERE id = $1`, [b], ); const bAfter = bAfterRows[0]; expect(bAfter.status).toBe("cancelled"); expect(String(bAfter.start_time)).toBe(String(bBefore.start_time)); expect(String(bAfter.end_time)).toBe(String(bBefore.end_time)); expect(bAfter.daily_number).toBe(bBefore.daily_number); }); test("Schedule drag-reorder: a null-startTime row drags deterministically and inherits its new slot", async ({ page, }) => { // The slot-swap sort puts non-null startTimes first (chronological) // and null-startTime rows last. After dragging the null row to the // top of a 3-row visible day, it should inherit the first // chronological slot (09:00) and the originally-null slot should // shift to the bottom row. await setLangEn(page); const date = uniqueFutureDate(25); const a = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `NullA ${Date.now().toString(36)}`, titleAr: `بلاأ ${Date.now().toString(36)}`, startTime: "09:00:00", endTime: "09:30:00", }); const b = await insertMeeting({ meetingDate: date, dailyNumber: 2, titleEn: `NullB ${Date.now().toString(36)}`, titleAr: `بلاب ${Date.now().toString(36)}`, startTime: "10:00:00", endTime: "10:30:00", }); const n = await insertMeeting({ meetingDate: date, dailyNumber: 3, titleEn: `NullN ${Date.now().toString(36)}`, titleAr: `بلان ${Date.now().toString(36)}`, startTime: null, endTime: null, }); 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); const rowA = page.getByTestId(`em-row-${a}`); const rowB = page.getByTestId(`em-row-${b}`); const rowN = page.getByTestId(`em-row-${n}`); await expect(rowA).toBeVisible({ timeout: 10_000 }); await expect(rowB).toBeVisible(); await expect(rowN).toBeVisible(); await ensureEditMode(page); const gripN = page.getByTestId(`em-row-grip-${n}`); await expect(gripN).toBeVisible(); await gripN.focus(); const savePromise = 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 }, ); const refetchPromise = waitForDayRefetch(page, date); await page.keyboard.press("Space"); // pick up N (last row) await page.waitForTimeout(150); await page.keyboard.press("ArrowUp"); await page.waitForTimeout(150); await page.keyboard.press("ArrowUp"); await page.waitForTimeout(150); await page.keyboard.press("Space"); // drop -> visible order [N, A, B] await Promise.all([savePromise, refetchPromise]); // Slots sorted chronologically (nulls last) were // slot[0] = (09:00, dn=A's), slot[1] = (10:00, dn=B's), slot[2] = (null, dn=N's). // Assignment N->slot[0], A->slot[1], B->slot[2] yields the row times below. const { rows: visibleAfter } = await pool.query( `SELECT id, daily_number, start_time, end_time FROM executive_meetings WHERE meeting_date = $1 ORDER BY daily_number ASC`, [date], ); const byId = Object.fromEntries(visibleAfter.map((r) => [r.id, r])); expect(String(byId[n].start_time)).toBe("09:00:00"); expect(String(byId[a].start_time)).toBe("10:00:00"); expect(byId[b].start_time).toBe(null); // dailyNumbers are a unique permutation 1..3 across the day. const dns = visibleAfter.map((r) => r.daily_number).slice().sort(); expect(dns).toEqual([1, 2, 3]); }); test("Schedule keyboard: typing an unparseable time + Enter shows a toast and does NOT save", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(14); const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `KbdBad ${Date.now().toString(36)}`, titleAr: `غير صالح ${Date.now().toString(36)}`, startTime: "08:00:00", endTime: "09:00:00", }); await gotoMeetingRow(page, meetingId, date); // Spy on PATCH — none should fire for an unparseable typed value. let patchSeen = false; page.on("response", (resp) => { try { const u = new URL(resp.url()); if ( u.pathname === `/api/executive-meetings/${meetingId}` && resp.request().method() === "PATCH" ) { patchSeen = true; } } catch { /* ignore */ } }); const timeCell = page.getByTestId(`em-time-${meetingId}`); await timeCell.evaluate((el) => el.focus()); await page.keyboard.press("Enter"); const startInput = page.getByTestId(`em-time-start-${meetingId}`); await expect(startInput).toBeFocused(); await startInput.click(); await page.keyboard.press("ControlOrMeta+a"); await page.keyboard.press("Delete"); await page.keyboard.type("25:99", { delay: 10 }); await page.keyboard.press("Enter"); // Editor must STAY open so the user can fix the typo. Toast surfaces // the parse error. await expect(startInput).toBeVisible(); await expect(startInput).toBeFocused(); // Match the toast title specifically. The toast also surfaces an // ARIA live-region copy for screen readers, so without a scope the // pattern resolves to two elements and trips strict mode. await expect( page .locator('[data-component-name="ToastTitle"]') .filter({ hasText: /Time format isn't valid|اكتب الوقت مثل/ }), ).toBeVisible({ timeout: 5_000 }); // No PATCH should have been sent for the bad value. await page.waitForTimeout(300); expect(patchSeen).toBe(false); // DB stayed at the seeded values. const { rows } = await pool.query( `SELECT start_time, end_time FROM executive_meetings WHERE id = $1`, [meetingId], ); expect(String(rows[0].start_time)).toBe("08:00:00"); expect(String(rows[0].end_time)).toBe("09:00:00"); }); test("Schedule keyboard: Tab walks start hour → start minute → start AM → start PM → end hour → end minute → end AM → end PM, preserves the typed start values, Enter on end saves both", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(15); const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `KbdTab ${Date.now().toString(36)}`, titleAr: `تابات ${Date.now().toString(36)}`, startTime: null, endTime: null, }); await gotoMeetingRow(page, meetingId, date); const timeCell = page.getByTestId(`em-time-${meetingId}`); await timeCell.evaluate((el) => el.focus()); await page.keyboard.press("Enter"); const startInput = page.getByTestId(`em-time-start-${meetingId}`); const endInput = page.getByTestId(`em-time-end-${meetingId}`); const startAm = page.getByTestId(`em-time-start-period-am-${meetingId}`); const startPm = page.getByTestId(`em-time-start-period-pm-${meetingId}`); const endAm = page.getByTestId(`em-time-end-period-am-${meetingId}`); const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`); await expect(startInput).toBeFocused(); // Per task #315 each side has THREE controls: hour input, minute // input, AM/PM toggle. Tab walks through start hour → start minute // → start AM → start PM → end hour → end minute → end AM → end PM. // "7" + "15" is a 1–12 hour with no marker, so the picker requires // an explicit AM/PM toggle pick before the value resolves. This // test exercises the contract that the typed values survive every // focus change along the way. const startMinute = page.getByTestId(`em-time-start-minute-${meetingId}`); const endMinute = page.getByTestId(`em-time-end-minute-${meetingId}`); await page.keyboard.type("7", { delay: 10 }); await page.keyboard.press("Tab"); await expect(startMinute).toBeFocused(); await page.keyboard.type("15", { delay: 10 }); await page.keyboard.press("Tab"); await expect(startAm).toBeFocused(); // Activate AM via Space — the canonical button-keyboard contract. await page.keyboard.press("Space"); await page.keyboard.press("Tab"); await expect(startPm).toBeFocused(); await page.keyboard.press("Tab"); await expect(endInput).toBeFocused(); // The typed start values must still be intact when focus reaches // the end input — that's the regression this test guards. await expect(startInput).toHaveValue("7"); await expect(startMinute).toHaveValue("15"); await page.keyboard.type("8", { delay: 10 }); await page.keyboard.press("Tab"); await expect(endMinute).toBeFocused(); await page.keyboard.type("45", { delay: 10 }); await page.keyboard.press("Tab"); await expect(endAm).toBeFocused(); await page.keyboard.press("Space"); // Move focus back to the end minute input so Enter triggers // save() rather than re-activating the AM button under focus. await page.keyboard.press("Tab"); await expect(endPm).toBeFocused(); await endMinute.focus(); 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 }, ); const refetchPromise = waitForDayRefetch(page, date); await page.keyboard.press("Enter"); await Promise.all([savePromise, refetchPromise]); const { rows } = await pool.query( `SELECT start_time, end_time FROM executive_meetings WHERE id = $1`, [meetingId], ); expect(String(rows[0].start_time)).toBe("07:15:00"); expect(String(rows[0].end_time)).toBe("08:45:00"); }); // ------------------------------------------------------------------- // Mobile viewport (iPhone 375x667): the inline 12h picker has 6 // sub-controls per range (start hour + minute + AM + PM, end hour + // minute + AM + PM, plus save/cancel buttons). On a narrow phone // viewport the row used to push the end picker out of view, hiding // the second time field from the user. The TimeRangeCell wrapper // now uses `flex-wrap` so the end picker drops onto a second line // when the cell can't fit both side-by-side. This test guards // against a regression that breaks that wrap or shrinks the touch // targets below the usable minimum on a phone-sized viewport. // ------------------------------------------------------------------- test.describe("Schedule mobile viewport (iPhone 375)", () => { test.use({ viewport: { width: 375, height: 667 } }); test("inline 12h picker: all 6 sub-controls are visible on iPhone, finger-tap sized, and a full hour+minute+AM/PM entry persists", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(20); const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `MobilePicker ${Date.now().toString(36)}`, titleAr: `محمول ${Date.now().toString(36)}`, startTime: null, endTime: null, }); await gotoMeetingRow(page, meetingId, date); const timeCell = page.getByTestId(`em-time-${meetingId}`); // Tap the cell to enter edit mode (mimics a finger tap on // mobile, exercising the click handler the read-only display // exposes alongside the keyboard handler). await timeCell.click(); const startHour = page.getByTestId(`em-time-start-${meetingId}`); const startMinute = page.getByTestId(`em-time-start-minute-${meetingId}`); const startAm = page.getByTestId(`em-time-start-period-am-${meetingId}`); const startPm = page.getByTestId(`em-time-start-period-pm-${meetingId}`); const endHour = page.getByTestId(`em-time-end-${meetingId}`); const endMinute = page.getByTestId(`em-time-end-minute-${meetingId}`); const endAm = page.getByTestId(`em-time-end-period-am-${meetingId}`); const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`); const saveBtn = page.getByTestId(`em-time-save-${meetingId}`); // All 8 picker sub-controls + save button must be reachable on // a 375px viewport. `toBeVisible` checks both the DOM presence // AND that the element isn't clipped behind 0-size / display: // none / off-screen — the regression we're guarding against. for (const control of [ startHour, startMinute, startAm, startPm, endHour, endMinute, endAm, endPm, saveBtn, ]) { await expect(control).toBeVisible(); } // Touch-target check: each interactive control must be at // least 28px tall so a finger tap lands cleanly. We use 28px // (not Apple HIG's 44px) because the inline editor lives in // a dense schedule table and h-8 (32px) is already a real // jump from the prior 22px implementation; h<28px would be // a regression worth catching. for (const control of [startHour, startMinute, startAm, endPm, saveBtn]) { const box = await control.boundingBox(); expect(box).not.toBeNull(); expect(box.height).toBeGreaterThanOrEqual(28); } // Drive a complete hour + minute + AM/PM entry through tap // interactions on both sides — this is the canonical mobile // flow the previous single-row layout broke. "9" + "30" PM // resolves to 21:30; "10" + "45" PM resolves to 22:45. await startHour.click(); await page.keyboard.press("ControlOrMeta+a"); await page.keyboard.press("Delete"); await page.keyboard.type("9", { delay: 10 }); await startMinute.click(); await page.keyboard.type("30", { delay: 10 }); await startPm.click(); await expect(startPm).toHaveAttribute("aria-pressed", "true"); await endHour.click(); await page.keyboard.press("ControlOrMeta+a"); await page.keyboard.press("Delete"); await page.keyboard.type("10", { delay: 10 }); await endMinute.click(); await page.keyboard.type("45", { delay: 10 }); await endPm.click(); await expect(endPm).toHaveAttribute("aria-pressed", "true"); 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 }, ); const refetchPromise = waitForDayRefetch(page, date); // Tap the save button — proves the save button is reachable // on a 375px viewport AND that the picker's commit() path // fires from a touch interaction. await saveBtn.click(); await Promise.all([savePromise, refetchPromise]); const { rows } = await pool.query( `SELECT start_time, end_time FROM executive_meetings WHERE id = $1`, [meetingId], ); expect(String(rows[0].start_time)).toBe("21:30:00"); expect(String(rows[0].end_time)).toBe("22:45:00"); }); }); // ------------------------------------------------------------------- // Task #316: stop the snap-back race + collapse the editor instantly. // Three guards: // 1. Save → immediate re-open of the editor must show the SAVED // values pre-filled, not blank fields. (The bug the user // reported as "the times disappeared, I have to re-fill them".) // 2. Tapping save must close the editor BEFORE the PATCH round-trip // resolves — otherwise the perceived save latency tracks // network latency instead of React render time. // 3. A failed PATCH must roll back the optimistic cache and // re-open the editor with the user's draft intact, so a transient // failure doesn't silently leave the row showing the optimistic // times that never made it to the server. // ------------------------------------------------------------------- test("Schedule time editor: save then immediately re-open shows the saved values pre-filled (no snap-back to blank)", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(316); const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `SnapBack ${Date.now().toString(36)}`, titleAr: `لا للارتداد ${Date.now().toString(36)}`, startTime: null, endTime: null, }); await gotoMeetingRow(page, meetingId, date); const timeCell = page.getByTestId(`em-time-${meetingId}`); await timeCell.click(); const startHour = page.getByTestId(`em-time-start-${meetingId}`); const startMinute = page.getByTestId(`em-time-start-minute-${meetingId}`); const startPm = page.getByTestId(`em-time-start-period-pm-${meetingId}`); const endHour = page.getByTestId(`em-time-end-${meetingId}`); const endMinute = page.getByTestId(`em-time-end-minute-${meetingId}`); const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`); // Use the keyboard.type + AM/PM-toggle pattern from the iPhone // entry test above — proven to reliably reach commit() across // viewports, and produces SPLIT picker state (hour/minute/period // tracked separately) so the rollback / re-open assertions can // check each sub-control's value independently. await startHour.click(); await page.keyboard.press("ControlOrMeta+a"); await page.keyboard.press("Delete"); await page.keyboard.type("9", { delay: 10 }); await startMinute.click(); await page.keyboard.type("30", { delay: 10 }); await startPm.click(); await expect(startPm).toHaveAttribute("aria-pressed", "true"); await endHour.click(); await page.keyboard.press("ControlOrMeta+a"); await page.keyboard.press("Delete"); await page.keyboard.type("10", { delay: 10 }); await endMinute.click(); await page.keyboard.type("45", { delay: 10 }); await endPm.click(); await expect(endPm).toHaveAttribute("aria-pressed", "true"); // Wait for the PATCH so we know the server-side change landed; // the test then immediately re-opens the cell to exercise the // snap-back path. Click the save button (the user's path that // triggered the original bug report). const patchPromise = 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-time-save-${meetingId}`).click(); await patchPromise; // The editor must have collapsed. await expect(page.locator(`[data-testid="em-time-${meetingId}"][data-editing="true"]`)).toHaveCount(0); // Re-open the editor BEFORE the GET refetch necessarily lands. await timeCell.click(); // The picker must be seeded with the values we just saved — not // blank, not the prior empty state, not a flash of either. The // user typed 9:30 PM / 10:45 PM via the toggle. await expect(page.getByTestId(`em-time-start-${meetingId}`)).toHaveValue("9"); await expect(page.getByTestId(`em-time-start-minute-${meetingId}`)).toHaveValue("30"); await expect(page.getByTestId(`em-time-start-period-pm-${meetingId}`)).toHaveAttribute("aria-pressed", "true"); await expect(page.getByTestId(`em-time-end-${meetingId}`)).toHaveValue("10"); await expect(page.getByTestId(`em-time-end-minute-${meetingId}`)).toHaveValue("45"); await expect(page.getByTestId(`em-time-end-period-pm-${meetingId}`)).toHaveAttribute("aria-pressed", "true"); }); test("Schedule time editor: tapping save closes the editor before the PATCH round-trip resolves (perceived-latency)", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(317); const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `FastClose ${Date.now().toString(36)}`, titleAr: `إغلاق سريع ${Date.now().toString(36)}`, startTime: null, endTime: null, }); await gotoMeetingRow(page, meetingId, date); // Delay the PATCH response by 1.5 s so the test can observe the // editor collapsing well before the network round-trip resolves. let releasePatch; const patchHeld = new Promise((resolve) => { releasePatch = resolve; }); await page.route( `**/api/executive-meetings/${meetingId}`, async (route) => { if (route.request().method() !== "PATCH") { await route.continue(); return; } await patchHeld; // pause until the test releases us await route.continue(); }, ); const timeCell = page.getByTestId(`em-time-${meetingId}`); await timeCell.click(); const startHour = page.getByTestId(`em-time-start-${meetingId}`); const startMinute = page.getByTestId(`em-time-start-minute-${meetingId}`); const startPm = page.getByTestId(`em-time-start-period-pm-${meetingId}`); const endHour = page.getByTestId(`em-time-end-${meetingId}`); const endMinute = page.getByTestId(`em-time-end-minute-${meetingId}`); const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`); // 6:00 PM – 7:15 PM via keyboard.type + PM toggle (matches the // iPhone-entry pattern). 18:00 / 19:15 in 24h. await startHour.click(); await page.keyboard.press("ControlOrMeta+a"); await page.keyboard.press("Delete"); await page.keyboard.type("6", { delay: 10 }); await startMinute.click(); await page.keyboard.type("00", { delay: 10 }); await startPm.click(); await expect(startPm).toHaveAttribute("aria-pressed", "true"); await endHour.click(); await page.keyboard.press("ControlOrMeta+a"); await page.keyboard.press("Delete"); await page.keyboard.type("7", { delay: 10 }); await endMinute.click(); await page.keyboard.type("15", { delay: 10 }); await endPm.click(); await expect(endPm).toHaveAttribute("aria-pressed", "true"); const tStart = Date.now(); await page.getByTestId(`em-time-save-${meetingId}`).click(); // The editor must collapse within React render time — well under // the 1.5 s held PATCH. We use 800 ms as a generous ceiling that // still proves the close is NOT gated on the network. await expect( page.locator(`[data-testid="em-time-${meetingId}"][data-editing="true"]`), ).toHaveCount(0, { timeout: 800 }); const elapsed = Date.now() - tStart; expect(elapsed).toBeLessThan(1000); // The read-only display should show the optimistic times even // though the PATCH hasn't returned yet. The schedule's compact // 12-h display drops the leading zero on the hour and emits no // AM/PM marker, so 18:00 → "6:00", 19:15 → "7:15". const displayText = await timeCell.innerText(); expect(displayText).toMatch(/6:00/); expect(displayText).toMatch(/7:15/); // Now release the PATCH and let the refetch settle so cleanup runs // against a stable state. releasePatch(); await 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.unroute(`**/api/executive-meetings/${meetingId}`); }); test("Schedule time editor: a failed PATCH rolls back the optimistic times and re-opens the editor with the draft intact", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(318); const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `Rollback ${Date.now().toString(36)}`, titleAr: `استعادة ${Date.now().toString(36)}`, startTime: "07:00:00", endTime: "08:00:00", }); await gotoMeetingRow(page, meetingId, date); // Stub PATCH to 500 so the optimistic write must roll back. await page.route( `**/api/executive-meetings/${meetingId}`, async (route) => { if (route.request().method() !== "PATCH") { await route.continue(); return; } await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ message: "Internal server error" }), }); }, ); const timeCell = page.getByTestId(`em-time-${meetingId}`); await timeCell.click(); // Overwrite the saved 07:00–08:00 with 11:00 – 12:30 (24h shortcut). // After the 500, the row's read-only display must snap BACK to the // original 7:00–8:00 (rollback succeeded), and the editor must // re-open with the user's draft (11:00 – 12:30) so they can retry // without retyping. const startHour = page.getByTestId(`em-time-start-${meetingId}`); const startMinute = page.getByTestId(`em-time-start-minute-${meetingId}`); const startAm = page.getByTestId(`em-time-start-period-am-${meetingId}`); const endHour = page.getByTestId(`em-time-end-${meetingId}`); const endMinute = page.getByTestId(`em-time-end-minute-${meetingId}`); const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`); await startHour.click(); await page.keyboard.press("ControlOrMeta+a"); await page.keyboard.press("Delete"); await page.keyboard.type("11", { delay: 10 }); await startMinute.click(); await page.keyboard.press("ControlOrMeta+a"); await page.keyboard.press("Delete"); await page.keyboard.type("00", { delay: 10 }); await startAm.click(); await expect(startAm).toHaveAttribute("aria-pressed", "true"); await endHour.click(); await page.keyboard.press("ControlOrMeta+a"); await page.keyboard.press("Delete"); await page.keyboard.type("12", { delay: 10 }); await endMinute.click(); await page.keyboard.press("ControlOrMeta+a"); await page.keyboard.press("Delete"); await page.keyboard.type("30", { delay: 10 }); await endPm.click(); await expect(endPm).toHaveAttribute("aria-pressed", "true"); await page.getByTestId(`em-time-save-${meetingId}`).click(); // Editor must re-open after the rollback fires. await expect( page.locator(`[data-testid="em-time-${meetingId}"][data-editing="true"]`), ).toHaveCount(1, { timeout: 5_000 }); // Draft is preserved so the user can retry without retyping. await expect(page.getByTestId(`em-time-start-${meetingId}`)).toHaveValue("11"); await expect(page.getByTestId(`em-time-start-minute-${meetingId}`)).toHaveValue("00"); await expect(page.getByTestId(`em-time-start-period-am-${meetingId}`)).toHaveAttribute("aria-pressed", "true"); await expect(page.getByTestId(`em-time-end-${meetingId}`)).toHaveValue("12"); await expect(page.getByTestId(`em-time-end-minute-${meetingId}`)).toHaveValue("30"); await expect(page.getByTestId(`em-time-end-period-pm-${meetingId}`)).toHaveAttribute("aria-pressed", "true"); // Cancel out and confirm the read-only display shows the ORIGINAL // 7:00–8:00 — proves the optimistic cache write was rolled back. await page.getByTestId(`em-time-cancel-${meetingId}`).click(); await expect( page.locator(`[data-testid="em-time-${meetingId}"][data-editing="true"]`), ).toHaveCount(0); const displayText = await timeCell.innerText(); expect(displayText).toMatch(/7:00/); expect(displayText).toMatch(/8:00/); // DB must be untouched. const { rows } = await pool.query( `SELECT start_time, end_time FROM executive_meetings WHERE id = $1`, [meetingId], ); expect(String(rows[0].start_time)).toBe("07:00:00"); expect(String(rows[0].end_time)).toBe("08:00:00"); await page.unroute(`**/api/executive-meetings/${meetingId}`); }); // ─── #317: optimistic-cache repaint for non-time inline editors ───── // // Task #316 made the time cell repaint instantly via an optimistic // React Query cache write. Task #317 generalises that pattern to the // title, attendee-name, and merge-text editors. The three tests below // mirror the time-cell tests for the title cell: // // 1. With the PATCH delayed 1500 ms, the cell shows the new title // within ~300 ms — proves the optimistic cache write repaints // the read-only display before the network round-trip resolves. // 2. With the PATCH stubbed to 500, the cell rolls back to the // original title and an error toast surfaces — proves the // rollback path actually restores cache state. // 3. Attendee-name save shows the renamed attendee instantly with // the PATCH delayed — proves the optimistic pattern covers the // attendees PUT endpoint too, not just the meeting PATCH. test("Schedule title editor: optimistic repaint shows the new title before the PATCH round-trip resolves", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(330); const uniq = Date.now().toString(36); const originalTitle = `Optimistic Original ${uniq}`; const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: originalTitle, titleAr: originalTitle, startTime: "09:00:00", endTime: "10:00:00", }); await gotoMeetingRow(page, meetingId, date); // Hold the PATCH for 1500 ms. The optimistic cache write must // repaint the cell well before the response lands. let patchHeld = false; await page.route( `**/api/executive-meetings/${meetingId}`, async (route) => { if (route.request().method() !== "PATCH") { await route.continue(); return; } patchHeld = true; await new Promise((r) => setTimeout(r, 1500)); await route.continue(); }, ); const titleCell = page.getByTestId(`em-edit-title-${meetingId}`); await expect(titleCell).toContainText(originalTitle); await titleCell.evaluate((el) => el.focus()); await page.keyboard.press("Enter"); const editorContent = titleCell.locator('[contenteditable="true"]'); await expect(editorContent).toBeFocused(); const appended = ` FAST-${uniq}`; await page.keyboard.type(appended); const t0 = Date.now(); await page.keyboard.press("Enter"); // The cell must show the new title within 300 ms of pressing Enter, // long before the held PATCH (1500 ms) resolves. This proves the // optimistic cache write is what's repainting the cell — not the // GET refetch that fires after the PATCH. await expect(titleCell).toContainText(originalTitle + appended, { timeout: 300, }); const elapsed = Date.now() - t0; expect(elapsed).toBeLessThan(800); expect(patchHeld).toBe(true); // Editor must have collapsed (toolbar gone) too. await expect(page.getByTestId("em-edit-toolbar")).toBeHidden(); // Wait for the held PATCH to actually land + the GET refetch to // complete so the test cleanup doesn't race the in-flight request. await 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 waitForDayRefetch(page, date); await page.unroute(`**/api/executive-meetings/${meetingId}`); }); test("Schedule title editor: a failed PATCH rolls back the optimistic title and the cell snaps back to the original", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(331); const uniq = Date.now().toString(36); const originalTitle = `Rollback Title ${uniq}`; const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: originalTitle, titleAr: originalTitle, startTime: "09:00:00", endTime: "10:00:00", }); await gotoMeetingRow(page, meetingId, date); // Stub PATCH to 500 so the optimistic write must roll back. await page.route( `**/api/executive-meetings/${meetingId}`, async (route) => { if (route.request().method() !== "PATCH") { await route.continue(); return; } await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ message: "Internal server error" }), }); }, ); const titleCell = page.getByTestId(`em-edit-title-${meetingId}`); await titleCell.evaluate((el) => el.focus()); await page.keyboard.press("Enter"); const editorContent = titleCell.locator('[contenteditable="true"]'); await expect(editorContent).toBeFocused(); const appended = ` BROKEN-${uniq}`; await page.keyboard.type(appended); await page.keyboard.press("Enter"); // Wait for the 500 to actually arrive so the rollback runs. await page.waitForResponse( (resp) => { const u = new URL(resp.url()); return ( u.pathname === `/api/executive-meetings/${meetingId}` && resp.request().method() === "PATCH" && resp.status() === 500 ); }, { timeout: 15_000 }, ); // Failure UX (mirrors #316 time-cell): the editor must re-open with // the user's typed draft intact so they can fix the error and // retry without retyping. The contenteditable still shows // "BROKEN-..." while the underlying read-only `value` (driving // titleEn in the cache) is rolled back to the original. await expect( titleCell.locator('[contenteditable="true"]'), ).toContainText(originalTitle + appended, { timeout: 3_000 }); await expect(titleCell.locator('[contenteditable="true"]')).toBeFocused(); // Cancel the draft → cell must collapse back to the rolled-back // ORIGINAL title with no trace of the appended fragment. await page.keyboard.press("Escape"); await expect( titleCell.locator('[contenteditable="true"]'), ).toHaveCount(0, { timeout: 3_000 }); await expect(titleCell).toContainText(originalTitle); await expect(titleCell).not.toContainText(appended); // DB must be untouched. const { rows } = await pool.query( `SELECT title_en, title_ar FROM executive_meetings WHERE id = $1`, [meetingId], ); expect(String(rows[0].title_en)).not.toContain(appended); expect(String(rows[0].title_ar)).not.toContain(appended); await page.unroute(`**/api/executive-meetings/${meetingId}`); }); test("Schedule title editor: rapid click-switch from cell A to cell B commits BOTH edits to the server (no dropped saves)", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(333); const uniq = Date.now().toString(36); const aOriginal = `RapidA ${uniq}`; const bOriginal = `RapidB ${uniq}`; const meetingA = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: aOriginal, titleAr: aOriginal, startTime: "09:00:00", endTime: "10:00:00", }); const meetingB = await insertMeeting({ meetingDate: date, dailyNumber: 2, titleEn: bOriginal, titleAr: bOriginal, startTime: "10:00:00", endTime: "11:00:00", }); await gotoMeetingRow(page, meetingA, date); // Verify both rows are visible before we start poking at them. await expect(page.getByTestId(`em-row-${meetingB}`)).toBeVisible(); // Record every PATCH that lands so we can assert both commits hit // the server even though we never blurred cell A explicitly — the // outside-pointerdown flush in EditableCell must commit it. const patches = []; page.on("request", (req) => { if (req.method() !== "PATCH") return; const u = new URL(req.url()); const m = u.pathname.match(/^\/api\/executive-meetings\/(\d+)$/); if (!m) return; patches.push({ id: Number(m[1]), body: req.postDataJSON() ?? null }); }); const cellA = page.getByTestId(`em-edit-title-${meetingA}`); const cellB = page.getByTestId(`em-edit-title-${meetingB}`); // Enter edit mode on A and type without committing. await cellA.evaluate((el) => el.focus()); await page.keyboard.press("Enter"); const editorA = cellA.locator('[contenteditable="true"]'); await expect(editorA).toBeFocused(); const aAppended = ` -A-${uniq}`; await page.keyboard.type(aAppended); // Now move focus to cell B WITHOUT pressing Enter on A. We // dispatch a pointerdown directly on cell B's element to trigger // EditableCell's outside-pointerdown flush in cell A. Doing this // via .evaluate() avoids Playwright's hit-test rejecting the // click when cell A's portalled floating toolbar happens to // overlap cell B. await cellB.evaluate((el) => { el.dispatchEvent( new PointerEvent("pointerdown", { bubbles: true, cancelable: true }), ); }); // Then enter edit mode on cell B the same way every other test // in this file does — focus + Enter. await cellB.evaluate((el) => el.focus()); await page.keyboard.press("Enter"); const editorB = cellB.locator('[contenteditable="true"]'); await expect(editorB).toBeFocused(); const bAppended = ` -B-${uniq}`; await page.keyboard.type(bAppended); await page.keyboard.press("Enter"); // Both PATCHes must have reached the server with the right bodies. await expect .poll(() => patches.filter((p) => p.id === meetingA).length, { timeout: 5_000, }) .toBeGreaterThanOrEqual(1); await expect .poll(() => patches.filter((p) => p.id === meetingB).length, { timeout: 5_000, }) .toBeGreaterThanOrEqual(1); // Body may use titleEn or titleAr depending on the i18n language // active at save-time (the page may default to Arabic) — assert // against whichever field the patch carries. const titleOf = (p) => String(p?.body?.titleEn ?? p?.body?.titleAr ?? ""); const aPatch = patches.find((p) => p.id === meetingA); const bPatch = patches.find((p) => p.id === meetingB); expect(titleOf(aPatch)).toContain(aAppended.trim()); expect(titleOf(bPatch)).toContain(bAppended.trim()); // And the DB must reflect both edits — proves nothing was silently // dropped by the rapid cell switch. await waitForDayRefetch(page, date); const { rows } = await pool.query( `SELECT id, title_en, title_ar FROM executive_meetings WHERE id = ANY($1::int[])`, [[meetingA, meetingB]], ); const byId = Object.fromEntries( rows.map((r) => [r.id, `${r.title_en ?? ""} ${r.title_ar ?? ""}`]), ); expect(byId[meetingA]).toContain(aAppended.trim()); expect(byId[meetingB]).toContain(bAppended.trim()); }); test("Schedule title editor: rapid Tab traversal from cell A to cell B commits BOTH edits exactly once", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(334); const uniq = Date.now().toString(36); const aOriginal = `TabA ${uniq}`; const bOriginal = `TabB ${uniq}`; const meetingA = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: aOriginal, titleAr: aOriginal, startTime: "09:00:00", endTime: "10:00:00", }); const meetingB = await insertMeeting({ meetingDate: date, dailyNumber: 2, titleEn: bOriginal, titleAr: bOriginal, startTime: "10:00:00", endTime: "11:00:00", }); await gotoMeetingRow(page, meetingA, date); await expect(page.getByTestId(`em-row-${meetingB}`)).toBeVisible(); // Count PATCHes per meeting so we can assert no duplicate commits // (savingRef guard) AND that the keyboard-only path still saves. const patches = []; page.on("request", (req) => { if (req.method() !== "PATCH") return; const u = new URL(req.url()); const m = u.pathname.match(/^\/api\/executive-meetings\/(\d+)$/); if (!m) return; patches.push({ id: Number(m[1]), body: req.postDataJSON() ?? null }); }); const cellA = page.getByTestId(`em-edit-title-${meetingA}`); const cellB = page.getByTestId(`em-edit-title-${meetingB}`); await cellA.evaluate((el) => el.focus()); await page.keyboard.press("Enter"); await expect(cellA.locator('[contenteditable="true"]')).toBeFocused(); const aAppended = ` -TabA-${uniq}`; await page.keyboard.type(aAppended); // Pure keyboard exit: Tab pulls focus out of cell A. Title cells // have no onTabNext, so the editor lets the browser handle Tab — // focusout fires and the 100 ms blur timer commits A's edit. await page.keyboard.press("Tab"); // Now activate B via keyboard and edit it. Pressing Enter twice // (once on cellB which is focusable, then Enter again to commit // the inner editor's content) is too brittle, so just focus + // Enter the way every other test does. await cellB.evaluate((el) => el.focus()); await page.keyboard.press("Enter"); await expect(cellB.locator('[contenteditable="true"]')).toBeFocused(); const bAppended = ` -TabB-${uniq}`; await page.keyboard.type(bAppended); await page.keyboard.press("Enter"); // Both PATCHes must have arrived. await expect .poll(() => patches.filter((p) => p.id === meetingA).length, { timeout: 5_000, }) .toBeGreaterThanOrEqual(1); await expect .poll(() => patches.filter((p) => p.id === meetingB).length, { timeout: 5_000, }) .toBeGreaterThanOrEqual(1); // …and EXACTLY once each — proves the savingRef re-entrancy guard // suppresses duplicate commits between the pointerdown flush, the // blur timer, and any onTabNext-style commit chains. // Wait briefly so any late blur-timer PATCH would have landed. await page.waitForTimeout(500); expect(patches.filter((p) => p.id === meetingA).length).toBe(1); expect(patches.filter((p) => p.id === meetingB).length).toBe(1); const titleOf = (p) => String(p?.body?.titleEn ?? p?.body?.titleAr ?? ""); expect(titleOf(patches.find((p) => p.id === meetingA))).toContain( aAppended.trim(), ); expect(titleOf(patches.find((p) => p.id === meetingB))).toContain( bAppended.trim(), ); }); test("Schedule attendees editor: optimistic repaint shows the renamed attendee before the PUT round-trip resolves", async ({ page, }) => { await setLangEn(page); const date = uniqueFutureDate(332); const uniq = Date.now().toString(36); const originalName = `OrigAtt ${uniq}`; const meetingId = await insertMeeting({ meetingDate: date, dailyNumber: 1, titleEn: `Att Optimistic ${uniq}`, titleAr: `حضور تفاؤلي ${uniq}`, startTime: "09:00:00", endTime: "10:00:00", }); await insertAttendee({ meetingId, name: originalName, attendanceType: "internal", sortOrder: 0, }); await gotoMeetingRow(page, meetingId, date); // Hold the attendees PUT for 1500 ms. let putHeld = false; await page.route( `**/api/executive-meetings/${meetingId}/attendees`, async (route) => { if (route.request().method() !== "PUT") { await route.continue(); return; } putHeld = true; await new Promise((r) => setTimeout(r, 1500)); await route.continue(); }, ); // The attendee's EditableCell wrapper (`em-edit-attendee-${i}`) is // index-keyed and the index is list-wide. Locate the cell that // shows the original name, then capture its concrete testid so the // post-rename assertions don't lose the locator (a hasText filter // would stop matching once the optimistic update repaints the // cell with the new name). const meetingRow = page.getByTestId(`em-row-${meetingId}`); const initialCell = meetingRow .locator('[data-testid^="em-edit-attendee-"]') .filter({ hasText: originalName }) .first(); await expect(initialCell).toBeVisible(); const cellTestId = await initialCell.getAttribute("data-testid"); if (!cellTestId) throw new Error("attendee cell testid missing"); const attendeeCell = page.getByTestId(cellTestId); await attendeeCell.click(); const editor = attendeeCell.locator('[contenteditable="true"]'); await expect(editor).toBeFocused(); const renamed = `RENAMED-${uniq}`; await page.keyboard.press("ControlOrMeta+a"); await page.keyboard.type(renamed); const t0 = Date.now(); await page.keyboard.press("Enter"); // Cell must show the renamed attendee within 300 ms — well before // the held 1500 ms PUT resolves. await expect(attendeeCell).toContainText(renamed, { timeout: 300 }); const elapsed = Date.now() - t0; expect(elapsed).toBeLessThan(800); expect(putHeld).toBe(true); // The original name must be gone from the optimistic display. await expect(attendeeCell).not.toContainText(originalName); // Wait for the held PUT to land + the GET refetch to complete so // the test cleanup doesn't race the in-flight request. await 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 waitForDayRefetch(page, date); await page.unroute(`**/api/executive-meetings/${meetingId}/attendees`); });