import { test, expect, type Page } from "@playwright/test"; // --------------------------------------------------------------------------- // Seed helpers // --------------------------------------------------------------------------- interface SeedItem { id: string; title: string; type: "mr_review" | "issue" | "mr_authored" | "manual"; project: string; url: string; iid: number; updatedAt: string | null; contextQuote: string | null; requestedBy: string | null; snoozedUntil?: string | null; } function makeItem(overrides: Partial & { id: string; title: string }): SeedItem { return { type: "mr_review", project: "platform/core", url: "https://gitlab.com/platform/core/-/merge_requests/1", iid: 1, updatedAt: new Date().toISOString(), contextQuote: null, requestedBy: null, snoozedUntil: null, ...overrides, }; } /** * Seed the focus store with a current item and a queue. * daysOld controls `updatedAt` for staleness tests. */ async function seedStore( page: Page, opts: { current?: SeedItem | null; queue?: SeedItem[]; } = {} ): Promise { await page.evaluate((o) => { const w = window as Record; const focusStore = w.__MC_FOCUS_STORE__ as { setState: (state: Record) => void; } | undefined; if (!focusStore) return; focusStore.setState({ current: o.current ?? null, queue: o.queue ?? [], isLoading: false, error: null, }); }, opts); } async function exposeStores(page: Page): Promise { await page.evaluate(() => { return new Promise((resolve) => { const check = (): void => { const w = window as Record; if (w.__MC_FOCUS_STORE__) { resolve(); } else { setTimeout(check, 50); } }; check(); }); }); } /** daysAgo returns an ISO timestamp N days in the past */ function daysAgo(n: number): string { return new Date(Date.now() - n * 24 * 60 * 60 * 1000).toISOString(); } /** Navigate to Queue view and wait for it to render */ async function goToQueue(page: Page): Promise { await page.getByRole("button", { name: "Queue" }).click(); } /** Navigate to Focus view */ async function goToFocus(page: Page): Promise { await page.getByRole("button", { name: "Focus" }).click(); } // --------------------------------------------------------------------------- // Shared setup // --------------------------------------------------------------------------- test.describe("PLAN-FOLLOWUP Acceptance Criteria", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); await page.waitForSelector("nav"); }); // ------------------------------------------------------------------------- // AC-F1: Drag Reorder // ------------------------------------------------------------------------- test.describe("AC-F1: Drag Reorder", () => { test("F1.1 — item gets dragging style after 150ms hold", async ({ page }) => { try { await exposeStores(page); } catch { test.skip(); return; } const itemA = makeItem({ id: "issue:p/c:1", title: "Item Alpha", type: "issue", iid: 1 }); const itemB = makeItem({ id: "issue:p/c:2", title: "Item Beta", type: "issue", iid: 2 }); await seedStore(page, { queue: [itemA, itemB] }); await goToQueue(page); // Wait for items to render await expect(page.getByText("Item Alpha")).toBeVisible(); const draggable = page.locator('[data-sortable-id="issue:p/c:1"]'); const box = await draggable.boundingBox(); if (!box) { test.skip(); return; } // Initiate a pointer down and hold to trigger drag await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); // Hold for > 150ms (activation delay) await page.waitForTimeout(200); // The item should have data-dragging=true OR reduced opacity (opacity: 0.5 style) // dnd-kit sets opacity via style, so we check style or data-dragging attribute on inner QueueItem button const itemButton = draggable.locator("button"); const dataDragging = await itemButton.getAttribute("data-dragging"); // Either attribute is set or the wrapper has reduced opacity const opacity = await draggable.evaluate((el) => { return (el as HTMLElement).style.opacity; }); await page.mouse.up(); // At least one indicator of dragging should be present expect(dataDragging === "true" || opacity === "0.5").toBeTruthy(); }); test("F1.4 — queue re-renders with new order after drop", async ({ page }) => { try { await exposeStores(page); } catch { test.skip(); return; } const itemA = makeItem({ id: "issue:p/c:1", title: "First Item", type: "issue", iid: 1 }); const itemB = makeItem({ id: "issue:p/c:2", title: "Second Item", type: "issue", iid: 2 }); await seedStore(page, { queue: [itemA, itemB] }); await goToQueue(page); const sourceEl = page.locator('[data-sortable-id="issue:p/c:1"]'); const targetEl = page.locator('[data-sortable-id="issue:p/c:2"]'); await expect(sourceEl).toBeVisible(); await expect(targetEl).toBeVisible(); const sourceBox = await sourceEl.boundingBox(); const targetBox = await targetEl.boundingBox(); if (!sourceBox || !targetBox) { test.skip(); return; } // Simulate drag: hold 200ms then move to target and release await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2); await page.mouse.down(); await page.waitForTimeout(200); await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2); await page.mouse.up(); // ReasonPrompt or new order: either outcome is visible. // ReasonPrompt appears first (AC-F1.5), and after confirming the order changes. // Check the ReasonPrompt is visible (proves state was updated). const reasonDialog = page.getByRole("dialog"); const isReasonVisible = await reasonDialog.isVisible().catch(() => false); if (isReasonVisible) { // Skip reason to confirm the reorder without typing anything await page.getByRole("button", { name: "Skip reason" }).click(); } // After the reorder cycle, both items should still be visible in the queue // Use sortable-id selectors to avoid matching the ReasonPrompt heading await expect(page.locator('[data-sortable-id="issue:p/c:1"]')).toBeVisible(); await expect(page.locator('[data-sortable-id="issue:p/c:2"]')).toBeVisible(); }); test("F1.5 — ReasonPrompt appears after drag reorder", async ({ page }) => { try { await exposeStores(page); } catch { test.skip(); return; } const itemA = makeItem({ id: "issue:p/c:1", title: "Alpha Issue", type: "issue", iid: 1 }); const itemB = makeItem({ id: "issue:p/c:2", title: "Beta Issue", type: "issue", iid: 2 }); await seedStore(page, { queue: [itemA, itemB] }); await goToQueue(page); await expect(page.getByText("Alpha Issue")).toBeVisible(); const sourceEl = page.locator('[data-sortable-id="issue:p/c:1"]'); const targetEl = page.locator('[data-sortable-id="issue:p/c:2"]'); const sourceBox = await sourceEl.boundingBox(); const targetBox = await targetEl.boundingBox(); if (!sourceBox || !targetBox) { test.skip(); return; } await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2); await page.mouse.down(); await page.waitForTimeout(200); await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2); await page.mouse.up(); // ReasonPrompt should appear with "Reordering: Alpha Issue" const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible({ timeout: 2000 }); await expect(dialog).toContainText("Reordering"); // Clean up await page.keyboard.press("Escape"); }); test("F1.7 — Cmd+Up/Down keyboard shortcuts trigger ReasonPrompt", async ({ page }) => { try { await exposeStores(page); } catch { test.skip(); return; } const itemA = makeItem({ id: "issue:p/c:1", title: "Keyboard Item A", type: "issue", iid: 1 }); const itemB = makeItem({ id: "issue:p/c:2", title: "Keyboard Item B", type: "issue", iid: 2 }); await seedStore(page, { queue: [itemA, itemB] }); await goToQueue(page); await expect(page.getByText("Keyboard Item B")).toBeVisible(); // Focus the second item's sortable wrapper so keyboard shortcut applies const itemEl = page.locator('[data-sortable-id="issue:p/c:2"]'); await itemEl.focus(); // Press Cmd+Up to move it up await page.keyboard.press("Meta+ArrowUp"); // ReasonPrompt should appear const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible({ timeout: 2000 }); await expect(dialog).toContainText("Reordering"); await page.keyboard.press("Escape"); }); }); // ------------------------------------------------------------------------- // AC-F2: ReasonPrompt Integration // ------------------------------------------------------------------------- test.describe("AC-F2: ReasonPrompt Integration", () => { const currentItem = makeItem({ id: "mr_review:platform/core:847", title: "Fix auth token refresh", type: "mr_review", iid: 847, }); async function seedWithCurrent(page: Page): Promise { await exposeStores(page); await seedStore(page, { current: currentItem, queue: [] }); } test("F2.1 — Skip shows ReasonPrompt with 'Skipping: [title]'", async ({ page }) => { try { await seedWithCurrent(page); } catch { test.skip(); return; } await goToFocus(page); await expect(page.getByText("Fix auth token refresh")).toBeVisible(); await page.getByRole("button", { name: "Skip" }).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); await expect(dialog).toContainText("Skipping: Fix auth token refresh"); }); test("F2.2 — Defer '1 hour' shows ReasonPrompt with 'Deferring: [title]'", async ({ page }) => { try { await seedWithCurrent(page); } catch { test.skip(); return; } await goToFocus(page); await expect(page.getByText("Fix auth token refresh")).toBeVisible(); await page.getByRole("button", { name: "1 hour" }).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); await expect(dialog).toContainText("Deferring: Fix auth token refresh"); await page.keyboard.press("Escape"); }); test("F2.2 — Defer 'Tomorrow' shows ReasonPrompt with 'Deferring: [title]'", async ({ page }) => { try { await seedWithCurrent(page); } catch { test.skip(); return; } await goToFocus(page); await expect(page.getByText("Fix auth token refresh")).toBeVisible(); await page.getByRole("button", { name: "Tomorrow" }).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); await expect(dialog).toContainText("Deferring: Fix auth token refresh"); await page.keyboard.press("Escape"); }); test("F2.5 — 'Skip reason' proceeds without reason", async ({ page }) => { try { await seedWithCurrent(page); } catch { test.skip(); return; } await goToFocus(page); await page.getByRole("button", { name: "Skip" }).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); // Click Skip reason — dialog should close await page.getByRole("button", { name: "Skip reason" }).click(); await expect(dialog).not.toBeVisible(); }); test("F2.6 — tag toggle works (visual + state)", async ({ page }) => { try { await seedWithCurrent(page); } catch { test.skip(); return; } await goToFocus(page); await page.getByRole("button", { name: "Skip" }).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); // Find the "Urgent" tag button const urgentTag = dialog.getByRole("button", { name: "Urgent" }); await expect(urgentTag).toBeVisible(); // Before clicking: should have non-selected styling (bg-zinc-800) const classBeforeClick = await urgentTag.getAttribute("class"); expect(classBeforeClick).toContain("bg-zinc-800"); // Click to select await urgentTag.click(); // After clicking: should have selected styling (bg-zinc-600) const classAfterClick = await urgentTag.getAttribute("class"); expect(classAfterClick).toContain("bg-zinc-600"); // Click again to deselect await urgentTag.click(); const classAfterDeselect = await urgentTag.getAttribute("class"); expect(classAfterDeselect).toContain("bg-zinc-800"); await page.keyboard.press("Escape"); }); test("F2.7 — Escape cancels prompt without acting", async ({ page }) => { try { await seedWithCurrent(page); } catch { test.skip(); return; } await goToFocus(page); await expect(page.getByText("Fix auth token refresh")).toBeVisible(); await page.getByRole("button", { name: "Skip" }).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); await page.keyboard.press("Escape"); // Dialog dismissed await expect(dialog).not.toBeVisible(); // Focus item still present (action was cancelled) await expect(page.getByText("Fix auth token refresh")).toBeVisible(); }); test("F2.8 — Confirm with reason+tags closes prompt", async ({ page }) => { try { await seedWithCurrent(page); } catch { test.skip(); return; } await goToFocus(page); await page.getByRole("button", { name: "Skip" }).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); // Fill reason await dialog.locator("#reason-input").fill("Waiting on Sarah's feedback"); // Select a tag await dialog.getByRole("button", { name: "Blocking" }).click(); // Confirm await page.getByRole("button", { name: "Confirm" }).click(); // Dialog should close await expect(dialog).not.toBeVisible(); }); }); // ------------------------------------------------------------------------- // AC-F5: Staleness Visualization // ------------------------------------------------------------------------- test.describe("AC-F5: Staleness Visualization", () => { test("F5.1 — fresh item (<1 day) shows green indicator", async ({ page }) => { try { await exposeStores(page); } catch { test.skip(); return; } const freshItem = makeItem({ id: "issue:p/c:1", title: "Fresh Issue", type: "issue", iid: 1, updatedAt: daysAgo(0), // just now }); await seedStore(page, { queue: [freshItem] }); await goToQueue(page); await expect(page.getByText("Fresh Issue")).toBeVisible(); // The staleness dot should have data-staleness="fresh" on the button const itemButton = page.locator('[data-staleness="fresh"]'); await expect(itemButton).toBeVisible(); // The dot element should have green class const dot = itemButton.locator('[aria-label="Updated recently"]'); await expect(dot).toBeVisible(); const dotClass = await dot.getAttribute("class"); expect(dotClass).toContain("bg-mc-fresh"); }); test("F5.3 — amber item (3-6 days) shows amber indicator", async ({ page }) => { try { await exposeStores(page); } catch { test.skip(); return; } const amberItem = makeItem({ id: "issue:p/c:2", title: "Amber Issue", type: "issue", iid: 2, updatedAt: daysAgo(4), // 4 days old }); await seedStore(page, { queue: [amberItem] }); await goToQueue(page); await expect(page.getByText("Amber Issue")).toBeVisible(); const itemButton = page.locator('[data-staleness="amber"]'); await expect(itemButton).toBeVisible(); const dot = itemButton.locator('[aria-label="Updated 3-6 days ago"]'); await expect(dot).toBeVisible(); const dotClass = await dot.getAttribute("class"); expect(dotClass).toContain("bg-mc-amber"); }); test("F5.4 — very stale item (7+ days) shows red pulsing indicator", async ({ page }) => { try { await exposeStores(page); } catch { test.skip(); return; } const urgentItem = makeItem({ id: "issue:p/c:3", title: "Urgent Old Issue", type: "issue", iid: 3, updatedAt: daysAgo(10), // 10 days old }); await seedStore(page, { queue: [urgentItem] }); await goToQueue(page); await expect(page.getByText("Urgent Old Issue")).toBeVisible(); const itemButton = page.locator('[data-staleness="urgent"]'); await expect(itemButton).toBeVisible(); const dot = itemButton.locator('[aria-label="Needs attention - over a week old"]'); await expect(dot).toBeVisible(); // Should have red color and pulse animation const dotClass = await dot.getAttribute("class"); expect(dotClass).toContain("bg-mc-urgent"); expect(dotClass).toContain("animate-pulse"); }); }); // ------------------------------------------------------------------------- // AC-F6: Batch Mode Activation // ------------------------------------------------------------------------- test.describe("AC-F6: Batch Mode", () => { test("F6.1 — Batch button visible when section has 2+ items", async ({ page }) => { try { await exposeStores(page); } catch { test.skip(); return; } const review1 = makeItem({ id: "mr_review:p/c:1", title: "Review Alpha", type: "mr_review", iid: 1, }); const review2 = makeItem({ id: "mr_review:p/c:2", title: "Review Beta", type: "mr_review", iid: 2, }); await seedStore(page, { queue: [review1, review2] }); await goToQueue(page); await expect(page.getByText("REVIEWS (2)")).toBeVisible(); // Batch button should appear in the section header const batchButton = page.getByRole("button", { name: "Batch" }); await expect(batchButton).toBeVisible(); }); test("F6.1 — Batch button NOT visible for single-item sections", async ({ page }) => { try { await exposeStores(page); } catch { test.skip(); return; } const singleReview = makeItem({ id: "mr_review:p/c:1", title: "Solo Review", type: "mr_review", iid: 1, }); await seedStore(page, { queue: [singleReview] }); await goToQueue(page); await expect(page.getByText("REVIEWS (1)")).toBeVisible(); const batchButton = page.getByRole("button", { name: "Batch" }); await expect(batchButton).not.toBeVisible(); }); }); // ------------------------------------------------------------------------- // AC-F7: SyncStatus Visibility // ------------------------------------------------------------------------- test.describe("AC-F7: SyncStatus", () => { test("F7.1 — SyncStatus indicator is visible in the nav area", async ({ page }) => { // SyncStatus renders in the nav bar via data-testid="sync-status" const syncStatus = page.getByTestId("sync-status"); await expect(syncStatus).toBeVisible(); }); test("F7.1 — SyncStatus shows either a dot indicator or spinner", async ({ page }) => { const syncStatus = page.getByTestId("sync-status"); await expect(syncStatus).toBeVisible(); // Should have either a spinner or a colored dot const hasSpinner = await page.getByTestId("sync-spinner").isVisible().catch(() => false); const hasDot = await page.getByTestId("sync-indicator").isVisible().catch(() => false); expect(hasSpinner || hasDot).toBeTruthy(); }); }); // ------------------------------------------------------------------------- // ReasonPrompt component isolation tests // (verifies the component's own behavior independent of wiring) // ------------------------------------------------------------------------- test.describe("ReasonPrompt component behavior", () => { test("dialog has correct aria attributes", async ({ page }) => { try { await exposeStores(page); } catch { test.skip(); return; } const currentItem = makeItem({ id: "mr_review:p/c:847", title: "Aria Test Item", type: "mr_review", iid: 847, }); await seedStore(page, { current: currentItem }); await goToFocus(page); await page.getByRole("button", { name: "Skip" }).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); await expect(dialog).toHaveAttribute("aria-modal", "true"); await expect(dialog).toHaveAttribute("aria-labelledby", "reason-prompt-title"); }); test("clicking backdrop cancels the prompt", async ({ page }) => { try { await exposeStores(page); } catch { test.skip(); return; } const currentItem = makeItem({ id: "mr_review:p/c:847", title: "Backdrop Test Item", type: "mr_review", iid: 847, }); await seedStore(page, { current: currentItem }); await goToFocus(page); await page.getByRole("button", { name: "Skip" }).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); // Click the backdrop (the fixed overlay behind the dialog) await page.mouse.click(10, 10); // top-left corner — outside the modal card await expect(dialog).not.toBeVisible(); }); test("all five quick tags are shown", async ({ page }) => { try { await exposeStores(page); } catch { test.skip(); return; } const currentItem = makeItem({ id: "mr_review:p/c:847", title: "Tags Test Item", type: "mr_review", iid: 847, }); await seedStore(page, { current: currentItem }); await goToFocus(page); await page.getByRole("button", { name: "Skip" }).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); // All five quick tags must be present for (const tag of ["Blocking", "Urgent", "Context switch", "Energy", "Flow"]) { await expect(dialog.getByRole("button", { name: tag })).toBeVisible(); } await page.keyboard.press("Escape"); }); }); });