diff --git a/apps/e2e-playwright/tests/shared/collection/bulk-editor.spec.ts b/apps/e2e-playwright/tests/shared/collection/bulk-editor.spec.ts new file mode 100644 index 00000000..e7afacdf --- /dev/null +++ b/apps/e2e-playwright/tests/shared/collection/bulk-editor.spec.ts @@ -0,0 +1,228 @@ +import { test, expect } from "@playwright/test"; +import { + ensureInAppOnHome, + openLibraryAndTab, + LibraryTab, + selectFirstTwoViaContextMenu, + createTwoCollections, + getBulkEditBar +} from "../../utils/helpers"; + +const runtimeEnv = ( + globalThis as { process?: { env?: Record } } +).process?.env; + +test.skip( + runtimeEnv?.SKIP_E2E === "1", + "E2E suite disabled by environment" +); + +test.describe("collection - bulk editor @regression", () => { + test.beforeEach(async ({ page }) => { + await page.route("**/*", (route) => { + const reqUrl = route.request().url(); + if (/accounts\.google\.com/i.test(reqUrl)) route.abort(); + else route.continue(); + }); + }); + + test("select multiple collections via context menu - bulk edit bar appears and shows count", async ({ + page + }) => { + test.setTimeout(90_000); + await ensureInAppOnHome(page); + await createTwoCollections(page); + await openLibraryAndTab(page, LibraryTab.Collections); + + await selectFirstTwoViaContextMenu(page, "records-container"); + + await expect( + page.getByText(/Selected: 2 collections?/i) + ).toBeVisible({ timeout: 10_000 }); + const bar = getBulkEditBar(page); + await expect(bar).toBeVisible({ timeout: 5_000 }); + await expect(bar.getByRole("button", { name: /^Star$/i })).toBeVisible(); + await expect(bar.getByRole("button", { name: /^Archive$/i })).toBeVisible(); + await expect(bar.getByRole("button", { name: /^Delete$/i })).toBeVisible(); + }); + + test("select multiple collections - clear selection hides bulk edit bar", async ({ + page + }) => { + test.setTimeout(90_000); + await ensureInAppOnHome(page); + await createTwoCollections(page); + await openLibraryAndTab(page, LibraryTab.Collections); + + await selectFirstTwoViaContextMenu(page, "records-container"); + await expect( + page.getByText(/Selected: 2 collections?/i) + ).toBeVisible({ timeout: 10_000 }); + + await getBulkEditBar(page) + .getByRole("button", { name: /Clear selection/i }) + .click({ timeout: 5_000 }); + await expect( + page.getByText(/Selected: 2 collections?/i) + ).toBeHidden({ timeout: 5_000 }); + }); + + test("select multiple collections - Star shows success toast, clears selection, and collections are starred", async ({ + page + }) => { + test.setTimeout(90_000); + await ensureInAppOnHome(page); + await createTwoCollections(page); + await openLibraryAndTab(page, LibraryTab.Collections); + + const urlStarred = new URL(page.url()); + urlStarred.searchParams.set("starred", "1"); + await page.goto(urlStarred.toString(), { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(1_500); + const starredThumbnailsBefore = page.locator( + "#records-container div[id^='thumbnail-']" + ); + await page.locator("#records-container").waitFor({ state: "visible", timeout: 10_000 }); + const starredCountBefore = await starredThumbnailsBefore.count(); + const urlCollections = new URL(page.url()); + urlCollections.searchParams.delete("starred"); + await page.goto(urlCollections.toString(), { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(1_500); + + await selectFirstTwoViaContextMenu(page, "records-container"); + await expect( + page.getByText(/Selected: 2 collections?/i) + ).toBeVisible({ timeout: 10_000 }); + + await getBulkEditBar(page) + .getByRole("button", { name: /^Star$/i }) + .click({ timeout: 5_000 }); + await expect( + page.getByText(/Starred 2 collections? successfully/i) + ).toBeVisible({ timeout: 10_000 }); + await expect( + page.getByText(/Selected: 2 collections?/i) + ).toBeHidden({ timeout: 5_000 }); + + await page.goto(urlStarred.toString(), { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(1_500); + const thumbnails = page.locator("#records-container div[id^='thumbnail-']"); + await expect(thumbnails.first()).toBeVisible({ timeout: 10_000 }); + await expect(thumbnails).toHaveCount(starredCountBefore + 2, { + timeout: 5_000 + }); + + const starIconsInStarredView = page.locator( + "#records-container div[id^='thumbnail-'] .text-yellow-400" + ); + await expect(starIconsInStarredView).toHaveCount(starredCountBefore + 2, { + timeout: 5_000 + }); + }); + + test("select multiple collections - Select all keeps bar visible with count", async ({ + page + }) => { + test.setTimeout(90_000); + await ensureInAppOnHome(page); + await createTwoCollections(page); + await openLibraryAndTab(page, LibraryTab.Collections); + + const container = page.locator("#records-container"); + const thumbnails = container.locator('div[id^="thumbnail-"]'); + await expect(thumbnails.first()).toBeVisible({ timeout: 10_000 }); + const totalCount = await thumbnails.count(); + + await selectFirstTwoViaContextMenu(page, "records-container"); + await expect( + page.getByText(/Selected: 2 collections?/i) + ).toBeVisible({ timeout: 10_000 }); + + await getBulkEditBar(page) + .getByRole("button", { name: /Select all/i }) + .click({ timeout: 5_000 }); + await expect( + page.getByText(new RegExp(`Selected: ${totalCount} collections?`, "i")) + ).toBeVisible({ timeout: 5_000 }); + }); + + test("select multiple collections - Archive shows success toast, clears selection, and collections are archived", async ({ + page + }) => { + test.setTimeout(90_000); + await ensureInAppOnHome(page); + await createTwoCollections(page); + await openLibraryAndTab(page, LibraryTab.Collections); + + const urlArchived = new URL(page.url()); + urlArchived.searchParams.set("archived", "1"); + await page.goto(urlArchived.toString(), { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(1_500); + const archivedThumbnailsBefore = page.locator( + "#records-container div[id^='thumbnail-']" + ); + await page.locator("#records-container").waitFor({ state: "visible", timeout: 10_000 }); + const archivedCountBefore = await archivedThumbnailsBefore.count(); + const urlCollections = new URL(page.url()); + urlCollections.searchParams.delete("archived"); + await page.goto(urlCollections.toString(), { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(1_500); + + await selectFirstTwoViaContextMenu(page, "records-container"); + await expect( + page.getByText(/Selected: 2 collections?/i) + ).toBeVisible({ timeout: 10_000 }); + + await getBulkEditBar(page) + .getByRole("button", { name: /^Archive$/i }) + .click({ timeout: 5_000 }); + await expect( + page.getByText(/Archived 2 collections? successfully/i) + ).toBeVisible({ timeout: 10_000 }); + await expect( + page.getByText(/Selected: 2 collections?/i) + ).toBeHidden({ timeout: 5_000 }); + + await page.goto(urlArchived.toString(), { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(1_500); + const thumbnails = page.locator("#records-container div[id^='thumbnail-']"); + await expect(thumbnails.first()).toBeVisible({ timeout: 10_000 }); + await expect(thumbnails).toHaveCount(archivedCountBefore + 2, { + timeout: 5_000 + }); + }); + + test("select multiple collections - Delete shows success toast and clears selection", async ({ + page + }) => { + test.setTimeout(90_000); + await ensureInAppOnHome(page); + await createTwoCollections(page); + await openLibraryAndTab(page, LibraryTab.Collections); + + const recordsContainer = page.locator("#records-container"); + await recordsContainer.waitFor({ state: "visible", timeout: 10_000 }); + const thumbnailsBefore = recordsContainer.locator("div[id^='thumbnail-']"); + await expect(thumbnailsBefore.first()).toBeVisible({ timeout: 10_000 }); + const countBefore = await thumbnailsBefore.count(); + + await selectFirstTwoViaContextMenu(page, "records-container"); + await expect( + page.getByText(/Selected: 2 collections?/i) + ).toBeVisible({ timeout: 10_000 }); + + await getBulkEditBar(page) + .getByRole("button", { name: /^Delete$/i }) + .click({ timeout: 5_000 }); + await expect( + page.getByText(/Deleted 2 collections? successfully/i) + ).toBeVisible({ timeout: 10_000 }); + await expect( + page.getByText(/Selected: 2 collections?/i) + ).toBeHidden({ timeout: 5_000 }); + + await page.waitForTimeout(1_500); + const thumbnailsAfter = recordsContainer.locator("div[id^='thumbnail-']"); + await expect(thumbnailsAfter).toHaveCount(countBefore - 2, { timeout: 10_000 }); + }); +}); diff --git a/apps/e2e-playwright/tests/shared/focus/goal/bulk-editor.spec.ts b/apps/e2e-playwright/tests/shared/focus/goal/bulk-editor.spec.ts index ec886b61..71dfb4cc 100644 --- a/apps/e2e-playwright/tests/shared/focus/goal/bulk-editor.spec.ts +++ b/apps/e2e-playwright/tests/shared/focus/goal/bulk-editor.spec.ts @@ -1,5 +1,12 @@ -import { test } from "@playwright/test"; -import { ensureInAppOnHome } from "../../../utils/helpers"; +import { test, expect } from "@playwright/test"; +import { + ensureInAppOnHome, + openLibraryAndTab, + LibraryTab, + selectFirstTwoViaContextMenu, + createTwoGoals, + getBulkEditBar +} from "../../../utils/helpers"; const runtimeEnv = ( globalThis as { process?: { env?: Record } } @@ -19,8 +26,89 @@ test.describe("goal - bulk editor @regression", () => { }); }); - test.skip("bulk edit goals (select multiple, apply action)", async ({ page }) => { + test("select multiple goals via context menu - bulk edit bar appears and shows count", async ({ + page + }) => { + test.setTimeout(90_000); await ensureInAppOnHome(page); - // TODO: select multiple goals in library, open bulk editor, apply action and assert + await createTwoGoals(page); + await openLibraryAndTab(page, LibraryTab.Goals); + + await selectFirstTwoViaContextMenu(page, "records-container"); + + await expect( + page.getByText(/Selected: 2 goals?/i) + ).toBeVisible({ timeout: 10_000 }); + }); + + test("select multiple goals - clear selection hides bulk edit bar", async ({ + page + }) => { + test.setTimeout(90_000); + await ensureInAppOnHome(page); + await createTwoGoals(page); + await openLibraryAndTab(page, LibraryTab.Goals); + + await selectFirstTwoViaContextMenu(page, "records-container"); + await expect( + page.getByText(/Selected: 2 goals?/i) + ).toBeVisible({ timeout: 10_000 }); + + await getBulkEditBar(page) + .getByRole("button", { name: /Clear selection/i }) + .click({ timeout: 5_000 }); + await expect(page.getByText(/Selected: 2 goals?/i)).toBeHidden({ + timeout: 5_000 + }); + }); + + test("select multiple goals - Star shows success toast and clears selection", async ({ + page + }) => { + test.setTimeout(90_000); + await ensureInAppOnHome(page); + await createTwoGoals(page); + await openLibraryAndTab(page, LibraryTab.Goals); + + await selectFirstTwoViaContextMenu(page, "records-container"); + await expect( + page.getByText(/Selected: 2 goals?/i) + ).toBeVisible({ timeout: 10_000 }); + + await getBulkEditBar(page) + .getByRole("button", { name: /^Star$/i }) + .click({ timeout: 5_000 }); + await expect( + page.getByText(/Starred 2 goals? successfully/i) + ).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(/Selected: 2 goals?/i)).toBeHidden({ + timeout: 5_000 + }); + }); + + test("select multiple goals - Select all keeps bar visible with count", async ({ + page + }) => { + test.setTimeout(90_000); + await ensureInAppOnHome(page); + await createTwoGoals(page); + await openLibraryAndTab(page, LibraryTab.Goals); + + const container = page.locator("#records-container"); + const thumbnails = container.locator('div[id^="thumbnail-"]'); + await expect(thumbnails.first()).toBeVisible({ timeout: 10_000 }); + const totalCount = await thumbnails.count(); + + await selectFirstTwoViaContextMenu(page, "records-container"); + await expect( + page.getByText(/Selected: 2 goals?/i) + ).toBeVisible({ timeout: 10_000 }); + + await getBulkEditBar(page) + .getByRole("button", { name: /Select all/i }) + .click({ timeout: 5_000 }); + await expect( + page.getByText(new RegExp(`Selected: ${totalCount} goals?`, "i")) + ).toBeVisible({ timeout: 5_000 }); }); }); diff --git a/apps/e2e-playwright/tests/shared/focus/task/bulk-editor.spec.ts b/apps/e2e-playwright/tests/shared/focus/task/bulk-editor.spec.ts index 60813330..64ab91d2 100644 --- a/apps/e2e-playwright/tests/shared/focus/task/bulk-editor.spec.ts +++ b/apps/e2e-playwright/tests/shared/focus/task/bulk-editor.spec.ts @@ -1,5 +1,12 @@ -import { test } from "@playwright/test"; -import { ensureInAppOnHome } from "../../../utils/helpers"; +import { test, expect } from "@playwright/test"; +import { + ensureInAppOnHome, + openLibraryAndTab, + LibraryTab, + selectFirstTwoViaContextMenu, + createTwoTasks, + getBulkEditBar +} from "../../../utils/helpers"; const runtimeEnv = ( globalThis as { process?: { env?: Record } } @@ -19,8 +26,89 @@ test.describe("task - bulk editor @regression", () => { }); }); - test.skip("bulk edit tasks (select multiple, apply action)", async ({ page }) => { + test("select multiple tasks via context menu - bulk edit bar appears and shows count", async ({ + page + }) => { + test.setTimeout(90_000); await ensureInAppOnHome(page); - // TODO + await createTwoTasks(page); + await openLibraryAndTab(page, LibraryTab.Tasks); + + await selectFirstTwoViaContextMenu(page, "task-library"); + + await expect( + page.getByText(/Selected: 2 tasks?/i) + ).toBeVisible({ timeout: 10_000 }); + }); + + test("select multiple tasks - clear selection hides bulk edit bar", async ({ + page + }) => { + test.setTimeout(90_000); + await ensureInAppOnHome(page); + await createTwoTasks(page); + await openLibraryAndTab(page, LibraryTab.Tasks); + + await selectFirstTwoViaContextMenu(page, "task-library"); + await expect( + page.getByText(/Selected: 2 tasks?/i) + ).toBeVisible({ timeout: 10_000 }); + + await getBulkEditBar(page) + .getByRole("button", { name: /Clear selection/i }) + .click({ timeout: 5_000 }); + await expect(page.getByText(/Selected: 2 tasks?/i)).toBeHidden({ + timeout: 5_000 + }); + }); + + test("select multiple tasks - Mark as completed shows success toast and clears selection", async ({ + page + }) => { + test.setTimeout(90_000); + await ensureInAppOnHome(page); + await createTwoTasks(page); + await openLibraryAndTab(page, LibraryTab.Tasks); + + await selectFirstTwoViaContextMenu(page, "task-library"); + await expect( + page.getByText(/Selected: 2 tasks?/i) + ).toBeVisible({ timeout: 10_000 }); + + await getBulkEditBar(page) + .getByRole("button", { name: /Mark as completed/i }) + .click({ timeout: 5_000 }); + await expect( + page.getByText(/2 tasks? successfully updated/i) + ).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(/Selected: 2 tasks?/i)).toBeHidden({ + timeout: 5_000 + }); + }); + + test("select multiple tasks - Select all keeps bar visible with count", async ({ + page + }) => { + test.setTimeout(90_000); + await ensureInAppOnHome(page); + await createTwoTasks(page); + await openLibraryAndTab(page, LibraryTab.Tasks); + + const container = page.locator("#task-library"); + const thumbnails = container.locator('div[id^="thumbnail-"]'); + await expect(thumbnails.first()).toBeVisible({ timeout: 10_000 }); + const totalCount = await thumbnails.count(); + + await selectFirstTwoViaContextMenu(page, "task-library"); + await expect( + page.getByText(/Selected: 2 tasks?/i) + ).toBeVisible({ timeout: 10_000 }); + + await getBulkEditBar(page) + .getByRole("button", { name: /Select all/i }) + .click({ timeout: 5_000 }); + await expect( + page.getByText(new RegExp(`Selected: ${totalCount} tasks?`, "i")) + ).toBeVisible({ timeout: 5_000 }); }); }); diff --git a/apps/e2e-playwright/tests/shared/memory/node/bulk-editor.spec.ts b/apps/e2e-playwright/tests/shared/memory/node/bulk-editor.spec.ts index 3e24de5c..9094254e 100644 --- a/apps/e2e-playwright/tests/shared/memory/node/bulk-editor.spec.ts +++ b/apps/e2e-playwright/tests/shared/memory/node/bulk-editor.spec.ts @@ -1,5 +1,12 @@ -import { test } from "@playwright/test"; -import { ensureInAppOnHome } from "../../../utils/helpers"; +import { test, expect } from "@playwright/test"; +import { + ensureInAppOnHome, + openLibraryAndTab, + LibraryTab, + selectFirstTwoViaContextMenu, + createTwoNodesViaCapture, + getBulkEditBar +} from "../../../utils/helpers"; const runtimeEnv = ( globalThis as { process?: { env?: Record } } @@ -19,8 +26,89 @@ test.describe("node - bulk editor @regression", () => { }); }); - test.skip("bulk edit nodes", async ({ page }) => { + test("select multiple nodes via context menu - bulk edit bar appears and shows count", async ({ + page + }) => { + test.setTimeout(90_000); await ensureInAppOnHome(page); - // TODO + await createTwoNodesViaCapture(page); + await openLibraryAndTab(page, LibraryTab.Nodes); + + await selectFirstTwoViaContextMenu(page, "records-container"); + + await expect( + page.getByText(/Selected: 2 nodes?/i) + ).toBeVisible({ timeout: 10_000 }); + }); + + test("select multiple nodes - clear selection hides bulk edit bar", async ({ + page + }) => { + test.setTimeout(90_000); + await ensureInAppOnHome(page); + await createTwoNodesViaCapture(page); + await openLibraryAndTab(page, LibraryTab.Nodes); + + await selectFirstTwoViaContextMenu(page, "records-container"); + await expect( + page.getByText(/Selected: 2 nodes?/i) + ).toBeVisible({ timeout: 10_000 }); + + await getBulkEditBar(page) + .getByRole("button", { name: /Clear selection/i }) + .click({ timeout: 5_000 }); + await expect(page.getByText(/Selected: 2 nodes?/i)).toBeHidden({ + timeout: 5_000 + }); + }); + + test("select multiple nodes - Star shows success toast and clears selection", async ({ + page + }) => { + test.setTimeout(90_000); + await ensureInAppOnHome(page); + await createTwoNodesViaCapture(page); + await openLibraryAndTab(page, LibraryTab.Nodes); + + await selectFirstTwoViaContextMenu(page, "records-container"); + await expect( + page.getByText(/Selected: 2 nodes?/i) + ).toBeVisible({ timeout: 10_000 }); + + await getBulkEditBar(page) + .getByRole("button", { name: /^Star$/i }) + .click({ timeout: 5_000 }); + await expect( + page.getByText(/Starred 2 nodes? successfully/i) + ).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(/Selected: 2 nodes?/i)).toBeHidden({ + timeout: 5_000 + }); + }); + + test("select multiple nodes - Select all updates count to all items", async ({ + page + }) => { + test.setTimeout(90_000); + await ensureInAppOnHome(page); + await createTwoNodesViaCapture(page); + await openLibraryAndTab(page, LibraryTab.Nodes); + + const container = page.locator("#records-container"); + const thumbnails = container.locator('div[id^="thumbnail-"]'); + await expect(thumbnails.first()).toBeVisible({ timeout: 10_000 }); + const totalCount = await thumbnails.count(); + + await selectFirstTwoViaContextMenu(page, "records-container"); + await expect( + page.getByText(/Selected: 2 nodes?/i) + ).toBeVisible({ timeout: 10_000 }); + + await getBulkEditBar(page) + .getByRole("button", { name: /Select all/i }) + .click({ timeout: 5_000 }); + await expect( + page.getByText(new RegExp(`Selected: ${totalCount} nodes?`, "i")) + ).toBeVisible({ timeout: 5_000 }); }); }); diff --git a/apps/e2e-playwright/tests/utils/helpers.ts b/apps/e2e-playwright/tests/utils/helpers.ts index c12df25a..d7c8e53b 100644 --- a/apps/e2e-playwright/tests/utils/helpers.ts +++ b/apps/e2e-playwright/tests/utils/helpers.ts @@ -134,3 +134,209 @@ export async function runQuickFocusCommand(page: Page) { await page.waitForTimeout(100); await page.keyboard.press("Enter"); } + +/** Library tab label for bulk editor tests (matches button name with optional count). */ +export const LibraryTab = { + Nodes: /^Nodes(\s+\d+)?$/i, + Collections: /^Collections(\s+\d+)?$/i, + Goals: /^(Goals|Objectives)(\s+\d+)?$/i, + Tasks: /^Tasks(\s+\d+)?$/i +} as const; + +/** + * Open Library and switch to the given tab (Nodes, Collections, Goals, or Tasks). + * Assumes we're already in the app (e.g. after ensureInAppOnHome). + */ +export async function openLibraryAndTab( + page: Page, + tabName: RegExp +): Promise { + await page.getByRole("button", { name: /^Library$/i }).click({ timeout: 5_000 }); + await page.waitForURL( + (u) => /^\/library(\/.*)?$/.test(new URL(u).pathname), + { timeout: 10_000 } + ); + const tabButton = page.getByRole("button", { name: tabName }).first(); + await tabButton.waitFor({ state: "visible", timeout: 10_000 }); + await tabButton.click({ timeout: 5_000 }); + await page.waitForTimeout(1_500); +} + +/** + * Create two collections via command bar so bulk editor tests have data to select. + * Assumes ensureInAppOnHome was already called. + */ +export async function createTwoCollections(page: Page): Promise { + const createCollectionTitle = page.getByText("Create collection", { + exact: true + }); + for (let i = 1; i <= 2; i++) { + await runCommand(page, "Create a new collection"); + const titleInput = page.getByPlaceholder("Name of the collection"); + await titleInput.waitFor({ state: "visible", timeout: 15_000 }); + await titleInput.fill(`E2E bulk collection ${i} ${Date.now()}`); + await page.waitForTimeout(300); + const modal = page.locator("#collection_create"); + const saveBtn = modal.getByRole("button", { name: /Save.*Enter/i }); + await saveBtn.click({ timeout: 5_000 }); + await createCollectionTitle.waitFor({ state: "hidden", timeout: 10_000 }); + await page.waitForTimeout(500); + } + await page.waitForTimeout(500); +} + +/** + * Create two goals via command bar so bulk editor tests have data to select. + * Assumes ensureInAppOnHome was already called. + */ +export async function createTwoGoals(page: Page): Promise { + const goalNameInput = page.getByTestId("goal-name-input"); + for (let i = 1; i <= 2; i++) { + await runCommand(page, "Create a new goal"); + await goalNameInput.waitFor({ state: "visible", timeout: 15_000 }); + await goalNameInput.fill(`E2E bulk goal ${i} ${Date.now()}`); + await page.keyboard.press("Enter"); + await goalNameInput.waitFor({ state: "hidden", timeout: 10_000 }).catch(() => null); + await page.keyboard.press("Escape"); + await page.keyboard.press("Escape"); + await page.waitForTimeout(400); + } +} + +/** + * Create two tasks via command bar so bulk editor tests have data to select. + * Assumes ensureInAppOnHome was already called. + */ +export async function createTwoTasks(page: Page): Promise { + const taskNameInput = page.getByTestId("task-name-input"); + for (let i = 1; i <= 2; i++) { + await runCommand(page, "Create a new task"); + await taskNameInput.waitFor({ state: "visible", timeout: 15_000 }); + await taskNameInput.fill(`E2E bulk task ${i} ${Date.now()}`); + await page.keyboard.press("Enter"); + await taskNameInput.waitFor({ state: "hidden", timeout: 10_000 }).catch(() => null); + await page.waitForTimeout(500); + } +} + +/** + * Create two nodes via Capture so bulk editor tests have data to select. + * Opens Capture, types in the editor, clicks save, then closes Capture to return to calendar. + * Assumes ensureInAppOnHome was already called. + */ +export async function createTwoNodesViaCapture(page: Page): Promise { + for (let i = 1; i <= 2; i++) { + await runCommand(page, "Capture"); + const editor = page.getByTestId("capture-editor"); + await editor.waitFor({ state: "visible", timeout: 15_000 }); + await editor.click(); + await page.keyboard.type(`E2E bulk node ${i} ${Date.now()}`, { delay: 30 }); + await page.waitForTimeout(300); + const saveBtn = page + .getByTestId("capture-save-button") + .or(page.getByRole("button", { name: /^Save$/i })); + await saveBtn.first().click({ timeout: 10_000 }); + await page.waitForTimeout(1_500); + const closeBtn = page.getByRole("button", { name: "Close" }); + await closeBtn.click({ timeout: 5_000 }); + await page.waitForTimeout(800); + } + await page.waitForTimeout(500); +} + +/** + * Perform drag selection so that at least the first two thumbnails in the container + * are selected. Uses the same logic as the app: mousedown in empty space, drag to + * cover elements with id^='thumbnail-', mouseup. + * @param page - Playwright page + * @param containerId - id of the container (e.g. 'records-container' or 'task-library') + */ +export async function dragSelectFirstTwoThumbnails( + page: Page, + containerId: string +): Promise { + const container = page.locator(`#${containerId}`); + await container.waitFor({ state: "visible", timeout: 15_000 }); + const thumbnails = container.locator('div[id^="thumbnail-"]'); + await expect(thumbnails.first()).toBeVisible({ timeout: 20_000 }); + const count = await thumbnails.count(); + if (count < 2) { + throw new Error( + `Bulk editor test needs at least 2 thumbnails in #${containerId}, found ${count}` + ); + } + await thumbnails.nth(0).scrollIntoViewIfNeeded(); + await thumbnails.nth(1).scrollIntoViewIfNeeded(); + await page.waitForTimeout(200); + const containerBox = await container.boundingBox(); + const box1 = await thumbnails.nth(0).boundingBox(); + const box2 = await thumbnails.nth(1).boundingBox(); + if (!containerBox || !box1 || !box2) { + throw new Error("Could not get bounding boxes for container or thumbnails"); + } + const minX = Math.min(box1.x, box2.x); + const minY = Math.min(box1.y, box2.y); + const maxRight = Math.max(box1.x + box1.width, box2.x + box2.width); + const maxBottom = Math.max(box1.y + box1.height, box2.y + box2.height); + const startX = minX - 20; + const startY = minY - 10; + if ( + startX < containerBox.x + 1 || + startY < containerBox.y + 1 + ) { + throw new Error( + `No empty gutter before the first thumbnails in #${containerId}; cannot start marquee selection outside a card` + ); + } + const endX = maxRight + 20; + const endY = maxBottom + 15; + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(endX, endY, { steps: 20 }); + await page.mouse.up(); + await page.waitForTimeout(500); +} + +/** + * Select the first two items via the 3-dots context menu - "Select" on each, + * so the bulk edit bar (down bar with Star, Archive, Delete, etc.) appears. + * Uses the outer element with data-testid="thumbnail-context-menu-trigger"; + * the inner button (ContextMenuAction) opens the menu. + * @param page - Playwright page + * @param containerId - id of the container (e.g. 'records-container' or 'task-library') + */ +export async function selectFirstTwoViaContextMenu( + page: Page, + containerId: string +): Promise { + const container = page.locator(`#${containerId}`); + await container.waitFor({ state: "visible", timeout: 15_000 }); + const thumbnails = container.locator('div[id^="thumbnail-"]'); + await expect(thumbnails.first()).toBeVisible({ timeout: 20_000 }); + const count = await thumbnails.count(); + if (count < 2) { + throw new Error( + `Bulk editor test needs at least 2 thumbnails in #${containerId}, found ${count}` + ); + } + for (const index of [0, 1]) { + const thumb = thumbnails.nth(index); + await thumb.scrollIntoViewIfNeeded(); + await thumb.hover(); + await page.waitForTimeout(250); + const triggerWrapper = thumb.getByTestId("thumbnail-context-menu-trigger"); + await triggerWrapper.locator("button").first().click({ timeout: 5_000 }); + await page.getByRole("button", { name: /^Select$/i }).click({ timeout: 5_000 }); + await page.waitForTimeout(300); + } + await page.waitForTimeout(500); +} + +/** + * Locator for the bulk edit bar (top nav bar showing "Selected: N ..." and action buttons). + * Use this to scope button clicks so we hit the bar's Star/Archive/Delete/etc., not similar + * buttons on cards elsewhere on the page. + */ +export function getBulkEditBar(page: Page) { + return page.getByTestId("bulk-edit-bar"); +} diff --git a/apps/nucleus/vite.config.ts b/apps/nucleus/vite.config.ts index 724a4357..e7738d9c 100644 --- a/apps/nucleus/vite.config.ts +++ b/apps/nucleus/vite.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ format: "es" }, server: { - allowedHosts: ["local.nucleum.app"] + allowedHosts: ["local.nucleum.app", "local.nucleus.to"] }, build: { target: "esnext", diff --git a/client/components/record/BulkEditBar.svelte b/client/components/record/BulkEditBar.svelte index 452f8099..4daae85e 100644 --- a/client/components/record/BulkEditBar.svelte +++ b/client/components/record/BulkEditBar.svelte @@ -176,6 +176,7 @@
@@ -203,6 +204,7 @@ {:else}