e2e/inventory-workspace.spec.ts
Rama main · réplica de lectura del repositorio original.
import { navigateWorkspace } from "./workspace-controls";
import { expect, test, type Page } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
async function login(page: Page) {
await page.goto("/");
await page.getByLabel("Workspace password").fill("demo-password-please-change");
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page.getByRole("heading", { name: "Projects", exact: true })).toBeVisible();
}
const records = Array.from({ length: 32 }, (_, i) => ({ id: `inspector-part-${i}`, name: `Inspector part ${String(i).padStart(2,"0")}`, kind: "electronic", quantity: 10, availableQuantity: i < 28 ? 0 : 8, allocatedQuantity: i < 28 ? 0 : 2, unit: "each", location: i % 2 === 0 ? "Drawer A" : "Drawer B", manufacturer: "Example Components", model: `EX-${i}`, tags: ["test fixture"], links: [], evidence: { state: i < 28 ? "delivered_uncounted" : "physically_counted", source: "synthetic fixture", observedAt: "2026-09-19T08:00:00Z" }, createdAt: "2026-09-19T08:00:00Z", updatedAt: "2026-09-19T08:00:00Z", version: 1 }));
async function fixtureInventory(page: Page) {
await page.route("**/api/v1/inventory?**", async (route) => {
const url = new URL(route.request().url());
const query = url.searchParams;
let rows = records.filter((row) => (!query.get("q") || row.name.toLowerCase().includes(query.get("q")!.toLowerCase())) && (!query.get("location") || row.location === query.get("location")) && (query.get("stockView") === "available" || query.get("stockView") === "reserved" ? row.availableQuantity > 0 : query.get("stockView") === "check" ? row.availableQuantity === 0 : query.get("stockView") === "depleted" ? false : true));
rows = rows.sort((a,b) => (query.get("sort") === "name_desc" ? -1 : 1) * a.name.localeCompare(b.name));
const offset = Number(query.get("cursor") ?? "0"), limit = Number(query.get("limit") ?? "25");
const data = rows.slice(offset, offset + limit);
await route.fulfill({ json: { data, limit, total: rows.length, ...(offset + data.length < rows.length ? { nextCursor: String(offset + data.length) } : {}) } });
});
}
test("inventory inspector supports keyboard review, explicit editing and clipboard fallback", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.addInitScript(() => Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }));
await login(page); await fixtureInventory(page);
await navigateWorkspace(page, "Inventory");
const inspector = page.getByRole("complementary", { name: "Inventory inspector" });
await expect(inspector).toHaveCount(0);
await page.locator(".inventory-table .table-item").filter({ hasText: "Inspector part 00" }).click();
await expect(inspector.getByRole("heading", { name: "Inspector part 00", exact: true })).toBeVisible();
await expect(inspector).toContainText("not been physically confirmed");
const first = page.locator(".inventory-table tbody tr").first();
await first.focus(); await page.keyboard.press("ArrowDown");
await expect(inspector.getByRole("heading", { name: "Inspector part 01", exact: true })).toBeVisible();
await expect(page.getByRole("dialog")).toHaveCount(0);
await page.keyboard.press("Enter");
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
await inspector.getByRole("button", { name: "Use with an AI assistant" }).click();
await inspector.getByRole("button", { name: "Copy for AI", exact: true }).click();
const brief = JSON.parse(await inspector.getByRole("textbox", { name: "Inventory brief" }).inputValue());
expect(brief.items).toHaveLength(1); expect(brief.items[0].id).toBe("inspector-part-1");
expect(brief.items[0].availableQuantity).toBe(0); expect(brief.scope).toContain("not a complete inventory");
await inspector.getByRole("button", { name: "Hide item inspector" }).click();
await expect(inspector).toHaveCount(0);
await page.locator(".inventory-table .table-item").filter({ hasText: "Inspector part 02" }).click();
await expect(inspector.getByRole("heading", { name: "Inspector part 02", exact: true })).toBeVisible();
});
test("stock views sort before paging, clear bulk selections, and persist saved filters through reload", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await login(page); await fixtureInventory(page);
await navigateWorkspace(page, "Inventory");
await expect(page.locator(".inventory-page-status")).toHaveText("Showing 25 of 32 items");
await page.getByRole("button", { name: "Load more", exact: true }).click();
await expect(page.locator(".inventory-page-status")).toHaveText("Showing 32 of 32 items");
await page.getByRole("checkbox", { name: /^Select Inspector part 00/u }).check();
await page.getByRole("navigation", { name: "Inventory stock views", exact: true }).getByRole("button", { name: "Available", exact: true }).click();
await expect(page.locator(".inventory-page-status")).toHaveText("Showing 4 of 4 items");
await expect(page.getByRole("button", { name: "Bulk edit", exact: true })).toHaveCount(0);
await expect(page.getByText("Selection cleared because the search or filters changed.")).toBeVisible();
await page.getByRole("button", { name: "View options", exact: true }).click();
await page.getByLabel("Sort inventory", { exact: true }).selectOption("name_desc");
await expect(page.locator(".inventory-table tbody tr").first()).toContainText("Inspector part 31");
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Filters", exact: true }).click();
await page.getByLabel("Filter inventory by exact location").fill("Drawer B");
await expect(page.locator(".inventory-page-status")).toHaveText("Showing 2 of 2 items");
await page.getByRole("button", { name: "View options", exact: true }).click();
await page.getByRole("button", { name: "Save view…", exact: true }).click();
await page.getByLabel("Inventory view name").fill("Available boards");
await page.getByRole("button", { name: "Save view", exact: true }).click();
await expect(page.getByLabel("Saved inventory view")).toHaveValue("Available boards");
await page.getByRole("button", { name: "Reset filters", exact: true }).click();
await expect(page.locator(".inventory-page-status")).toHaveText("Showing 25 of 32 items");
await page.getByLabel("Saved inventory view").selectOption("Available boards");
await expect(page.locator(".inventory-page-status")).toHaveText("Showing 2 of 2 items");
await page.reload();
await page.getByRole("button", { name: "View options", exact: true }).click();
await expect(page.getByLabel("Saved inventory view")).toHaveValue("Available boards");
await expect(page.locator(".inventory-table tbody tr").first()).toContainText("Inspector part 31");
await expect(page.getByLabel("Sort inventory", { exact: true })).toHaveValue("name_desc");
await page.getByRole("button", { name: "Remove saved view", exact: true }).click();
await expect(page.getByLabel("Saved inventory view")).toHaveValue("");
});
test("inventory register and inspector remain accessible in light, dark and narrow layouts", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.setViewportSize({ width: 1440, height: 1000 });
await login(page); await fixtureInventory(page);
await navigateWorkspace(page, "Inventory");
await expect(page.locator(".inventory-page-status")).toHaveText("Showing 25 of 32 items");
// These evidence columns remain available on demand in the compact register.
// Enable them explicitly so the narrow-layout checks cover every stock field.
await page.getByRole("button", { name: "View options", exact: true }).click();
const columns = page.getByRole("group", { name: "Additional columns" });
await columns.getByRole("checkbox", { name: "Recorded", exact: true }).check();
await columns.getByRole("checkbox", { name: "Status", exact: true }).check();
await page.keyboard.press("Escape");
await page.locator(".inventory-table .table-item").filter({ hasText: "Inspector part 00" }).click();
await expect(page.getByRole("complementary", { name: "Inventory inspector" })).toBeVisible();
for (const theme of ["light", "dark"]) {
await page.evaluate((value) => document.documentElement.dataset.theme = value, theme);
const results = await new AxeBuilder({ page }).include(".inventory-workstation").withTags(["wcag2a", "wcag2aa", "wcag21aa"]).analyze();
expect(results.violations).toEqual([]);
}
await page.getByRole("button", { name: "Hide item inspector", exact: true }).click();
for (const width of [768, 390, 320]) {
await page.setViewportSize({ width, height: 900 });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
const firstRow = page.locator(".inventory-table tbody tr").first();
for (const cell of [".inventory-name-cell", ".inventory-recorded-cell", ".inventory-available-cell", ".inventory-status-cell"]) {
// Responsive panel changes can remount the table; retry the full
// measurement while keeping every stock/evidence cell within the viewport.
await expect(async () => {
await firstRow.locator(cell).scrollIntoViewIfNeeded();
const bounds = await firstRow.locator(cell).boundingBox();
expect(bounds).not.toBeNull();
expect(bounds!.x).toBeGreaterThanOrEqual(0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width);
}).toPass({ timeout: 5_000 });
}
await page.getByRole("columnheader", { name: "Item", exact: true }).getByRole("button").focus();
await page.keyboard.press("Tab");
const locationHeader = page.getByRole("columnheader", { name: "Location", exact: true });
await expect(locationHeader.getByRole("button")).toBeFocused();
expect(await locationHeader.evaluate((element) => getComputedStyle(element).clipPath)).toBe("none");
expect((await locationHeader.boundingBox())!.width).toBeGreaterThan(20);
const inspect = page.locator(".inventory-table .table-item").filter({ hasText: "Inspector part 01" });
await inspect.click();
await expect(page.getByRole("dialog").getByRole("heading", { name: "Inspector part 01", exact: true })).toBeVisible();
await expect(page.getByLabel("Counted quantity")).toBeVisible();
await page.getByRole("button", { name: "Close item details" }).click();
}
});
test("inventory keeps categories in local filters and restores configurable desktop layout", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await login(page); await fixtureInventory(page);
await navigateWorkspace(page, "Inventory");
await expect(page.getByRole("region", { name: "Inventory navigator" })).toHaveCount(0);
await expect(page.getByRole("region", { name: "Project navigator" })).toHaveCount(0);
await expect(page.getByRole("textbox", { name: "Search inventory", exact: true })).toHaveCount(1);
await expect(page.getByRole("navigation", { name: "Inventory stock views", exact: true }).getByRole("button")).toHaveText(["All", "Available", "Needs checking", "Reserved"]);
await page.getByRole("button", { name: "Filters", exact: true }).click();
await page.getByLabel("Filter inventory by category").selectOption("category-electronics");
await expect(page.getByLabel("Filter inventory by category")).toHaveValue("category-electronics");
await expect(page).toHaveURL(/categoryNodeId=category-electronics/);
await expect(page.locator(".inventory-active-filters")).toContainText("Electronics");
await page.getByLabel("Filter inventory by category").selectOption("");
await expect(page.getByLabel("Filter inventory by category")).toHaveValue("");
await expect(page).not.toHaveURL(/categoryNodeId=/);
await page.getByRole("button", { name: "Filters", exact: true }).click();
const item = page.locator(".inventory-table .table-item").filter({ hasText: "Inspector part 02" });
await item.click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByRole("complementary", { name: "Inventory inspector" })).toContainText("Inspector part 02");
await item.dblclick(); await expect(page.getByRole("dialog")).toBeVisible(); await page.keyboard.press("Escape");
await item.focus(); await page.keyboard.press("F2"); await expect(page.getByRole("dialog")).toBeVisible(); await page.keyboard.press("Escape");
await page.getByRole("columnheader", { name: "Item", exact: true }).getByRole("button").click();
await expect(page.locator(".inventory-table tbody tr").first()).toContainText("Inspector part 31");
await page.getByRole("button", { name: "View options", exact: true }).click();
await expect(page.getByLabel("Sort inventory", { exact: true })).toHaveValue("name_desc");
const columns = page.getByRole("group", { name: "Additional columns" });
await columns.getByRole("checkbox", { name: "SKU / part number" }).check();
await columns.getByRole("checkbox", { name: "Location", exact: true }).uncheck();
await expect(page.getByRole("columnheader", { name: "SKU / part number" })).toBeVisible();
await expect(page.getByRole("columnheader", { name: "Location", exact: true })).toHaveCount(0);
const splitter = page.getByRole("separator", { name: "Resize item inspector" });
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Inventory view options" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "View options", exact: true })).toBeFocused();
const panel = page.locator('[data-slot="resizable-panel"]').last();
const before = (await panel.boundingBox())!.width;
await splitter.focus(); await page.keyboard.press("ArrowLeft");
await expect.poll(async () => (await panel.boundingBox())!.width).toBeGreaterThan(before);
const firstKeyboardWidth = (await panel.boundingBox())!.width;
await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("benchledger.inventory.layout.v1.workspace") ?? "{}").inspectorWidth)).toBe(Math.round(firstKeyboardWidth));
await page.keyboard.press("ArrowRight");
await expect.poll(async () => (await panel.boundingBox())!.width).toBeLessThan(firstKeyboardWidth);
const resized = (await panel.boundingBox())!.width;
await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("benchledger.inventory.layout.v1.workspace") ?? "{}").inspectorWidth)).toBe(Math.round(resized));
const bounds = await splitter.boundingBox();
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + 60);
await page.mouse.down(); await page.mouse.move(bounds!.x + bounds!.width / 2 - 30, bounds!.y + 60); await page.mouse.up();
await expect.poll(async () => (await panel.boundingBox())!.width).toBeGreaterThan(resized);
const savedWidth = (await panel.boundingBox())!.width;
await page.reload();
await expect.poll(async () => Math.abs((await panel.boundingBox())!.width - savedWidth)).toBeLessThan(2);
await expect(page.getByRole("columnheader", { name: "SKU / part number" })).toBeVisible();
await page.getByRole("button", { name: "Hide item inspector" }).click(); await page.reload();
await expect(page.getByRole("complementary", { name: "Inventory inspector" })).toHaveCount(0);
await page.getByRole("button", { name: "View options", exact: true }).click(); await page.getByRole("button", { name: "Reset layout" }).click();
await page.keyboard.press("Escape");
await expect(page.getByRole("complementary", { name: "Inventory inspector" })).toHaveCount(0);
await page.getByRole("button", { name: "View options", exact: true }).click();
await page.getByRole("button", { name: "Inspector", exact: true }).click();
await page.keyboard.press("Escape");
await expect.poll(async () => Math.abs((await panel.boundingBox())!.width - 420)).toBeLessThan(2);
await expect(page.getByRole("columnheader", { name: "Location", exact: true })).toBeVisible();
await navigateWorkspace(page, "Projects");
await expect(page.getByRole("heading", { name: "Projects", exact: true })).toBeVisible();
await expect(page.getByRole("navigation", { name: "Project views", exact: true })).toBeVisible();
});
test("filters disclose secondary controls and keep active conditions clear when closed", async ({ page }) => {
await login(page); await fixtureInventory(page);
await navigateWorkspace(page, "Inventory");
await expect(page.locator(".inventory-page-status")).toHaveText("Showing 25 of 32 items");
const filters = page.getByRole("button", { name: "Filters", exact: true });
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 900 });
await expect(filters).toHaveAttribute("aria-expanded", "false");
await expect(page.getByLabel("Filter inventory by category")).toBeHidden();
await expect(page.getByLabel("Saved inventory view")).toBeHidden();
await filters.click();
await expect(page.getByLabel("Filter inventory by category")).toBeVisible();
await expect(page.getByLabel("Saved inventory view")).toBeHidden();
await page.getByRole("button", { name: "View options", exact: true }).click();
await expect(page.getByLabel("Saved inventory view")).toBeVisible();
await page.keyboard.press("Escape");
await page.getByLabel("Filter inventory by availability").selectOption("available");
await filters.click();
await expect(page.getByLabel("Filter inventory by availability")).toBeHidden();
await expect(page.locator(".inventory-active-filters")).toContainText("Available for reuse");
await page.locator(".inventory-active-filters").getByRole("button", { name: "Clear filters" }).click();
await expect(page.locator(".inventory-active-filters")).toHaveCount(0);
}
});
test("empty inventory offers its first item while an empty search offers filter recovery", async ({ page }) => {
await login(page);
await page.route("**/api/v1/inventory?**", route => route.fulfill({ json: { data: [], total: 0, limit: 25 } }));
await navigateWorkspace(page, "Inventory");
await expect(page.getByRole("heading", { name: "Start with what you have", exact: true })).toBeVisible();
await expect(page.getByLabel("Search inventory", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "Add first item", exact: true }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
await page.goto("/?q=missing+component#/inventory");
await expect(page.getByLabel("Search inventory", { exact: true })).toHaveValue("missing component");
await expect(page.getByRole("heading", { name: "No matching items" })).toBeVisible();
await expect(page.getByRole("button", { name: "Add first item", exact: true })).toHaveCount(0);
await page.locator(".inventory-active-filters").getByRole("button", { name: "Clear filters" }).click();
await expect(page.getByRole("heading", { name: "Start with what you have", exact: true })).toBeVisible();
});