2026-04-30 09:30:38 +00:00
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 <div role="button" tabindex="0">.
// 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.
2026-05-02 11:18:52 +00:00
// 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.
2026-04-30 09:30:38 +00:00
await expect ( startInput ) . toHaveCount ( 0 ) ;
await expect ( endInput ) . toHaveCount ( 0 ) ;
2026-05-02 11:18:52 +00:00
await expect ( timeCell ) . toHaveText ( /1:30\s*[\u2013\-]\s*2:45/ , {
2026-04-30 09:30:38 +00:00
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 } ` ) ;
2026-05-02 11:18:52 +00:00
// 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/ ) ;
2026-04-30 09:30:38 +00:00
// 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" ) ;
2026-05-02 11:18:52 +00:00
// Esc cancels — editor closes, original values restored. Display
// cell uses the same compact 12h, no AM/PM, no-leading-zero shape
// formatTime() produces.
2026-04-30 09:30:38 +00:00
await page . keyboard . press ( "Escape" ) ;
await expect ( startInput ) . toHaveCount ( 0 ) ;
await expect ( endInput ) . toHaveCount ( 0 ) ;
2026-05-02 11:18:52 +00:00
await expect ( timeCell ) . toHaveText ( /8:15\s*[\u2013\-]\s*9:45/ ) ;
2026-04-30 09:30:38 +00:00
// 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 ( ) ;
} ) ;
2026-05-02 08:39:00 +00:00
// -------------------------------------------------------------------
2026-05-14 06:23:49 +00:00
// typing on the keyboard + Enter must save.
2026-05-02 08:39:00 +00:00
//
// The historical bug: the time cell used <input type="time">, 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,
2026-05-02 11:18:52 +00:00
// 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.
//
2026-05-14 06:23:49 +00:00
// the time cell is now a 12-hour picker — typing a
2026-05-02 11:18:52 +00:00
// 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 ,
) {
2026-05-02 08:39:00 +00:00
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 } ) ;
2026-05-02 11:18:52 +00:00
if ( startPeriod ) {
await page
. getByTestId ( ` em-time-start-period- ${ startPeriod } - ${ meetingId } ` )
. click ( ) ;
}
2026-05-02 08:39:00 +00:00
await endInput . click ( ) ;
await page . keyboard . press ( "ControlOrMeta+a" ) ;
await page . keyboard . press ( "Delete" ) ;
await page . keyboard . type ( endText , { delay : 10 } ) ;
2026-05-02 11:18:52 +00:00
if ( endPeriod ) {
await page
. getByTestId ( ` em-time-end-period- ${ endPeriod } - ${ meetingId } ` )
. click ( ) ;
}
2026-05-02 08:39:00 +00:00
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 ) ;
2026-05-02 11:18:52 +00:00
// Re-focus the end input so Enter triggers save() rather than
// re-activating the AM/PM toggle that was last clicked.
await endInput . focus ( ) ;
2026-05-02 08:39:00 +00:00
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.
2026-05-02 11:18:52 +00:00
//
2026-05-14 06:23:49 +00:00
// `startPeriod` / `endPeriod` mirror's "the picker has no
2026-05-02 11:18:52 +00:00
// 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.
2026-05-02 08:39:00 +00:00
const TYPING _SHAPES = [
2026-05-02 11:18:52 +00:00
{ 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" } ,
2026-05-02 08:39:00 +00: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 ) ;
2026-05-02 11:18:52 +00:00
await typeTimesAndSave (
page ,
meetingId ,
date ,
shape . startText ,
shape . endText ,
shape . startPeriod ,
shape . endPeriod ,
) ;
2026-05-02 08:39:00 +00:00
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 ) ;
2026-05-02 11:18:52 +00:00
// ٠٩:٣٠ -> 09:30, ١٠:٤٥ -> 10:45. Both are 1– 12 hours so the
2026-05-14 06:23:49 +00:00
// picker requires an explicit AM/PM choice — pass the
2026-05-02 11:18:52 +00:00
// Arabic morning marker via the toggle so the helper clicks ص.
await typeTimesAndSave ( page , meetingId , date , "٠٩:٣٠" , "١٠:٤٥" , "am" , "am" ) ;
2026-05-02 08:39:00 +00:00
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" ) ;
} ) ;
2026-05-02 09:15:53 +00:00
// -------------------------------------------------------------------
2026-05-14 06:23:49 +00:00
// dragging a visible meeting must produce a chronologically-
2026-05-02 09:15:53 +00:00
// 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 ) ;
2026-05-02 09:22:34 +00:00
// 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 ( ) ;
2026-05-02 09:15:53 +00:00
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 ) ;
2026-05-02 09:22:34 +00:00
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]
2026-05-02 09:15:53 +00:00
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 ) ;
} ) ;
2026-05-02 09:25:37 +00:00
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 ] ) ;
} ) ;
2026-05-02 08:39:00 +00:00
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" ) ;
} ) ;
2026-05-02 11:31:37 +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 ( {
2026-05-02 08:39:00 +00:00
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 } ` ) ;
2026-05-02 11:18:52 +00:00
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 } ` ) ;
2026-05-02 08:39:00 +00:00
await expect ( startInput ) . toBeFocused ( ) ;
2026-05-14 06:23:49 +00:00
// each side has THREE controls: hour input, minute
2026-05-02 11:31:37 +00:00
// 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 } ) ;
2026-05-02 08:39:00 +00:00
await page . keyboard . press ( "Tab" ) ;
2026-05-02 11:18:52 +00:00
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" ) ;
2026-05-02 08:39:00 +00:00
await expect ( endInput ) . toBeFocused ( ) ;
2026-05-02 11:31:37 +00:00
// The typed start values must still be intact when focus reaches
2026-05-02 11:18:52 +00:00
// the end input — that's the regression this test guards.
2026-05-02 11:31:37 +00:00
await expect ( startInput ) . toHaveValue ( "7" ) ;
await expect ( startMinute ) . toHaveValue ( "15" ) ;
2026-05-02 11:18:52 +00:00
2026-05-02 11:31:37 +00:00
await page . keyboard . type ( "8" , { delay : 10 } ) ;
await page . keyboard . press ( "Tab" ) ;
await expect ( endMinute ) . toBeFocused ( ) ;
await page . keyboard . type ( "45" , { delay : 10 } ) ;
2026-05-02 11:18:52 +00:00
await page . keyboard . press ( "Tab" ) ;
await expect ( endAm ) . toBeFocused ( ) ;
await page . keyboard . press ( "Space" ) ;
2026-05-02 11:31:37 +00:00
// Move focus back to the end minute input so Enter triggers
// save() rather than re-activating the AM button under focus.
2026-05-02 11:18:52 +00:00
await page . keyboard . press ( "Tab" ) ;
await expect ( endPm ) . toBeFocused ( ) ;
2026-05-02 11:31:37 +00:00
await endMinute . focus ( ) ;
2026-05-02 08:39:00 +00:00
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" ) ;
} ) ;
2026-05-03 06:22:54 +00: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" ) ;
} ) ;
} ) ;
2026-05-03 07:18:11 +00:00
// -------------------------------------------------------------------
2026-05-14 06:23:49 +00:00
// stop the snap-back race + collapse the editor instantly.
2026-05-03 07:18:11 +00:00
// 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 } ` ) ;
} ) ;
2026-05-03 07:31:28 +00:00
// ─── #317: optimistic-cache repaint for non-time inline editors ─────
//
2026-05-14 06:23:49 +00:00
// made the time cell repaint instantly via an optimistic
// React Query cache write. generalises that pattern to the
2026-05-03 07:31:28 +00:00
// 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 } ,
) ;
2026-05-03 07:41:28 +00:00
// 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 ) ;
2026-05-03 07:31:28 +00:00
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 } ` ) ;
} ) ;
2026-05-03 07:41:28 +00:00
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 ( ) ) ;
} ) ;
2026-05-03 07:45:30 +00:00
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 ( ) ,
) ;
} ) ;
2026-05-03 07:31:28 +00:00
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 ` ) ;
} ) ;