97f251bab6
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
330 lines
12 KiB
JavaScript
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]);
|
|
});
|