Files
TX/artifacts/tx-os/tests/executive-meetings-touch-reorder.spec.mjs
T
riyadhafraa 97f251bab6 Task #149: Add automated mobile/touch test for reordering meetings on iPad
Adds a new Playwright spec
`artifacts/tx-os/tests/executive-meetings-touch-reorder.spec.mjs` that
exercises the iPad/touch path of drag-to-reorder on the Executive
Meetings daily schedule — the path fixed by Task #141 (TouchSensor +
`touch-action: none` on the row grip handle) but never covered by an
end-to-end test.

What the test does
- Configures the spec's browser context with viewport 768x1024,
  hasTouch=true, isMobile=true so Chromium emulates an iPad and emits
  real TouchEvents that dnd-kit's TouchSensor can pick up.
- Seeds two adjacent meetings on a unique far-future date via direct
  DB inserts (mirrors the strategy in
  executive-meetings-schedule-features.spec.mjs) and cleans them up in
  afterAll.
- Logs in as admin via the UI, navigates to /executive-meetings, jumps
  to the seeded date, and turns on edit mode so the row grip handle
  renders.
- Drives the touch gesture through CDP `Input.dispatchTouchEvent`
  (Playwright's high-level `page.touchscreen` only exposes `tap()`):
  touchStart on the first row's grip, hold still for 350ms (≥ task's
  250ms / dnd-kit's 200ms activation delay), drag down past the second
  row in 12 sub-steps, touchEnd.
- Asserts: (1) a POST /api/executive-meetings/reorder fires AND
  succeeds, (2) the request body's `orderedIds` reflects the swap,
  (3) the DB row daily_numbers + start/end times swap as the reorder
  endpoint specifies, (4) the visible DOM row order swaps, and (5)
  after a reload the new order persists.

Wiring
- The spec lives in `artifacts/tx-os/tests/` and is auto-discovered by
  the existing playwright config (`testMatch: /.*\\.spec\\.mjs$/`), so
  it runs as part of `pnpm --filter @workspace/tx-os test:e2e` with no
  config changes.

Verification
- `pnpm --filter @workspace/tx-os test:e2e -- executive-meetings-touch-reorder.spec.mjs` → 1 passed.
- Re-ran the existing `executive-meetings-schedule-features.spec.mjs`
  (4 tests) to confirm no regression — all 4 still pass.

Follow-ups
- Proposed #214: Add iPad/touch test for reordering schedule columns
  (the file has a second DndContext for column-header drag that uses
  the same sensor stack and is currently untested on touch).

Code review feedback
- Reordered scrollIntoViewIfNeeded() to run BEFORE the boundingBox
  reads so any auto-scroll from bringing the grip on screen can't
  invalidate the touch coordinates we then dispatch via CDP. (Other
  comment about an unrelated opengraph.jpg change is platform-side,
  not part of this task's edits.)

No deviations from the task spec.

Replit-Task-Id: 3de9a478-e4a6-41db-8ed6-4feddccb3fd4
2026-04-30 12:20:50 +00:00

330 lines
12 KiB
JavaScript

import { test, expect } from "@playwright/test";
import pg from "pg";
// E2E coverage for the iPad / touch path of drag-to-reorder on the
// Executive Meetings daily schedule. Task #141 fixed the touch path by
// adding dnd-kit's TouchSensor with a long-press activation
// (delay: 200ms, tolerance: 8px) plus `touch-action: none` on the row
// grip handle. The desktop reorder test in
// `executive-meetings-schedule-features.spec.mjs` only exercises the
// PointerSensor (mouse) path, so a regression that breaks touch
// activation would be silent on CI but break every iPad user. This
// spec runs in a touch-enabled mobile viewport and dispatches real
// CDP touch events so the TouchSensor is the sensor that actually
// activates — exactly mirroring an iPad user's gesture.
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set to run the executive-meetings touch-reorder UI test",
);
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
// Configure this whole spec to use an iPad-shaped, touch-enabled
// browser context. `isMobile` enables Chromium's mobile emulation
// (which makes the browser actually emit TouchEvents in addition to
// pointer/mouse), and `hasTouch` enables the touch input pipeline so
// CDP `Input.dispatchTouchEvent` calls are honored. Both are required
// for dnd-kit's TouchSensor to receive `touchstart`/`touchmove`/
// `touchend` and activate on long-press.
test.use({
viewport: { width: 768, height: 1024 },
hasTouch: true,
isMobile: true,
});
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 any persisted schedule UI prefs so this test starts from the
// same baseline regardless of what previous runs (or other specs that
// share the demo `admin` user) left behind. Must be called after
// login because the edit-mode key is namespaced by user id.
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;
}
// Pick a meeting date that won't collide with real schedule data OR
// with stale rows leftover from a previously-failed run. Mirrors the
// jittering strategy used by the desktop schedule spec.
const RANDOM_DATE_BASE_DAYS =
365 + Math.floor(Math.random() * 1000) + ((Date.now() / 1000) | 0) % 500;
function uniqueFutureDate(offsetDays) {
const d = new Date();
d.setUTCDate(d.getUTCDate() + RANDOM_DATE_BASE_DAYS + offsetDays * 7);
return d.toISOString().slice(0, 10);
}
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("Touch: long-press on the row grip and drag down past the next row reorders via the TouchSensor (iPad)", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(1);
const uniq = `${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 6)}`;
const idA = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Touch Drag A ${uniq}`,
titleAr: `سحب لمسي أ ${uniq}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
const idB = await insertMeeting({
meetingDate: date,
dailyNumber: 2,
titleEn: `Touch Drag B ${uniq}`,
titleAr: `سحب لمسي ب ${uniq}`,
startTime: "11:00:00",
endTime: "12:00:00",
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await resetSchedulePrefs(page);
await page.reload();
// Jump to the seeded date.
await page.locator('input[type="date"]').first().fill(date);
await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId(`em-row-${idB}`)).toBeVisible();
// Edit mode is required for the grip handle to render.
const toggle = page.getByTestId("em-edit-mode-toggle");
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
const gripA = page.getByTestId(`em-row-grip-${idA}`);
const rowB = page.getByTestId(`em-row-${idB}`);
await expect(gripA).toBeVisible();
await expect(rowB).toBeVisible();
// Scroll the grip into view BEFORE measuring bounding boxes — on the
// narrow iPad viewport the grip can sit above the fold, and any
// post-measurement scroll would invalidate the coordinates we use to
// dispatch touch events.
await gripA.scrollIntoViewIfNeeded();
const gripBox = await gripA.boundingBox();
const targetBox = await rowB.boundingBox();
expect(gripBox).not.toBeNull();
expect(targetBox).not.toBeNull();
const startX = gripBox.x + gripBox.width / 2;
const startY = gripBox.y + gripBox.height / 2;
// Aim BELOW the second row's vertical center so dnd-kit places A
// after B and the resulting order becomes [B, A].
const endX = targetBox.x + targetBox.width / 2;
const endY = targetBox.y + targetBox.height - 4;
// We drive touches through CDP because Playwright's high-level
// `page.touchscreen` API only exposes `tap()` — no long-press +
// drag primitive — and dnd-kit's TouchSensor specifically listens
// for native touchstart/touchmove/touchend events.
const cdp = await page.context().newCDPSession(page);
const reorderRequestPromise = page.waitForRequest(
(req) => {
const u = new URL(req.url());
return (
u.pathname === "/api/executive-meetings/reorder" &&
req.method() === "POST"
);
},
{ timeout: 15_000 },
);
const reorderResponsePromise = 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 },
);
// 1. Press down on the grip and HOLD without moving so the
// TouchSensor's 200ms activation delay can elapse. We deliberately
// wait > 250ms (the task's spec) and well above dnd-kit's 200ms
// threshold to avoid timing flakiness on slow CI machines.
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: startX, y: startY, id: 1 }],
});
// Hold still — staying inside the 8px tolerance window is what tells
// the TouchSensor "this is a long-press, not a scroll".
await page.waitForTimeout(350);
// 2. Now drag down toward the target row in several intermediate
// steps so dnd-kit sees enough touchmove events to compute the
// overlap with the row below and emit a sortable change.
const steps = 12;
for (let i = 1; i <= steps; i++) {
const x = startX + ((endX - startX) * i) / steps;
const y = startY + ((endY - startY) * i) / steps;
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x, y, id: 1 }],
});
// A short wait between moves lets React/dnd-kit run their
// collision-detection ticks between samples.
await page.waitForTimeout(20);
}
// 3. Release.
await cdp.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
});
// The reorder POST must fire AND succeed.
const reorderRequest = await reorderRequestPromise;
await reorderResponsePromise;
// Inspect the request body to confirm the client sent the new
// order (B before A) — this is what proves the TouchSensor
// actually activated and dnd-kit re-emitted onDragEnd. A no-op
// drag would either send no request or send the original order.
const requestPayload = reorderRequest.postDataJSON();
expect(requestPayload).toMatchObject({ meetingDate: date });
expect(Array.isArray(requestPayload.orderedIds)).toBe(true);
expect(requestPayload.orderedIds).toEqual([idB, idA]);
// The DB must reflect the swap: B owns daily_number 1 and the
// earlier 09:00-10:00 slot, A drops to 2 and the 11:00-12:00 slot.
// This mirrors the assertion in the desktop reorder test and
// catches any client-side-only "looks reordered" regression.
const { rows } = await pool.query(
`SELECT id, daily_number, start_time, end_time
FROM executive_meetings WHERE id = ANY($1::int[]) ORDER BY id`,
[[idA, idB]],
);
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
expect(byId[idB].daily_number).toBe(1);
expect(String(byId[idB].start_time)).toBe("09:00:00");
expect(String(byId[idB].end_time)).toBe("10:00:00");
expect(byId[idA].daily_number).toBe(2);
expect(String(byId[idA].start_time)).toBe("11:00:00");
expect(String(byId[idA].end_time)).toBe("12:00:00");
// The DOM order in the visible table must show the swap as well —
// this is the assertion that fails if the touch path silently
// regressed (e.g. someone removed TouchSensor or the touch-action
// CSS, so the gesture turns into a vertical scroll instead of a
// drag).
const orderedIdsAfterDrag = await page
.locator('tr[data-testid^="em-row-"]')
.evaluateAll((els) =>
els
.map((el) => el.getAttribute("data-testid"))
.map((tid) => Number(tid?.replace("em-row-", ""))),
);
const swappedOrder = orderedIdsAfterDrag.filter(
(n) => n === idA || n === idB,
);
expect(swappedOrder).toEqual([idB, idA]);
// Reload — the persisted order must come back the same way, which
// proves the server actually accepted and stored the new sequence
// (not just a transient optimistic update on the client).
await page.reload();
await page.locator('input[type="date"]').first().fill(date);
await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
timeout: 10_000,
});
const orderedIdsAfterReload = await page
.locator('tr[data-testid^="em-row-"]')
.evaluateAll((els) =>
els
.map((el) => el.getAttribute("data-testid"))
.map((tid) => Number(tid?.replace("em-row-", ""))),
);
const persistedOrder = orderedIdsAfterReload.filter(
(n) => n === idA || n === idB,
);
expect(persistedOrder).toEqual([idB, idA]);
});