Files
beardrive/internal/webapp/frontend/e2e/home.spec.ts
T
411809a785 fix(hub): the history kind is a badge, not a fake disclosure toggle (BEA-17) (#67)
* fix(hub): the history kind is a badge, not a fake disclosure toggle (BEA-17)

The +/x in a history row is the kind glyph (added/edited/deleted), but
sitting leftmost inside a role="button" row it read as a tree disclosure
control — clicked, it navigated away instead of expanding. Merge the glyph
and its word into one text badge and vacate the toggle slot: the kind is
now text (no icon shape, no colour-only meaning), and the row's only real
expander stays the note, which keeps its own control and aria-expanded
and now turns its chevron when open.

* fix(hub): fit DELETED in the kind badge and align the row's meta under the path

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 07:21:38 +09:00

406 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from "@playwright/test";
import { login, wikiId, MEMBER, READER, expectToast } from "./helpers";
// Phase 3: project home (connect guide + embedded dashboard), the dedicated
// dashboard route, and the history views. Ports the original parity checks
// from the pre-migration smoke suite.
test("landing is the project home (guide), not a dashboard redirect", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.waitForURL("/" + pid);
await expect(page.locator(".guide")).toBeVisible();
await expect(page.locator("#crumb")).toHaveText("wiki");
});
test("guide: one paste for every agent, one line of prose, details collapsed", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.waitForSelector(".guide");
// No agent tabs anymore — the prompt is identical for every agent.
await expect(page.locator(".gd-tab")).toHaveCount(0);
const prompt = await page.$$eval(".gd-body > .gd-code code", (els) =>
els.map((e) => e.textContent).join("\n"),
);
// The prompt points at the canonical instructions with the project id and
// hub URL filled in; the agent fetches the doc for the actual steps.
expect(prompt).toContain(
"Follow https://raw.githubusercontent.com/runbear-io/beardrive/main/INSTALL_FOR_AGENTS.md",
);
expect(prompt).toContain(`project ${pid} on http://localhost:8993`);
expect(prompt).not.toContain("brew install");
// Detail lives behind the two collapsed sections.
await expect(page.locator(".gd-manual > summary")).toHaveText([
"What exactly happens",
"Or run it yourself",
]);
await expect(page.locator(".gd-manual[open]")).toHaveCount(0);
});
test("guide: manual fallback has the full command list and the docs link", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.waitForSelector(".guide");
const manual = await page.$$eval(".gd-manual .gd-code code", (els) =>
els.map((e) => e.textContent).join("\n"),
);
expect(manual).toContain("brew install runbear-io/tap/beardrive");
expect(manual).toContain("bdrive login http://localhost:8993");
expect(manual).toContain(`bdrive init --project ${pid}`);
expect(manual).toContain("bdrive hooks install");
await expect(page.locator('.gd-manual a[href="https://docs.beardrive.ai/manual/install/"]')).toHaveCount(1);
});
test("home embeds the dashboard below the guide, for members too", async ({ page, browser }) => {
await login(page);
await page.waitForSelector(".guide");
await expect(page.locator(".home-insights .insights")).toBeVisible();
// Guide renders above the embedded dashboard
const order = await page.evaluate(() => {
const g = document.querySelector(".guide");
const i = document.querySelector(".home-insights");
return g && i ? (g.compareDocumentPosition(i) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0 : false;
});
expect(order).toBe(true);
await expect(page.locator(".in-treemap")).toBeVisible();
await expect(page.locator(".in-hotpath .in-hp-row").first()).toBeVisible();
const ctx = await browser.newContext();
const p2 = await ctx.newPage();
await login(p2, MEMBER);
await p2.waitForSelector(".guide");
await expect(p2.locator(".home-insights .insights")).toBeVisible();
await ctx.close();
});
test("dedicated dashboard route still works and survives reload", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/dashboard`);
await expect(page.locator("#crumb")).toHaveText("Dashboard — wiki");
await expect(page.locator(".in-treemap")).toBeVisible();
await page.reload();
await expect(page.locator(".in-treemap")).toBeVisible();
});
test("a plain member gets the Dashboard, not a refusal", async ({ page }) => {
await login(page, MEMBER);
const pid = await wikiId(page);
await page.click("#nav-dashboard");
await page.waitForURL(`/${pid}/dashboard`);
await expect(page.locator("#nav-dashboard")).toHaveClass(/active/);
await expect(page.locator("#crumb")).toHaveText("Dashboard — wiki");
await expect(page.locator(".in-treemap")).toBeVisible();
await expect(page.locator("body")).not.toContainText("hub admins and org owners");
// …and a scoped deep link resolves on a cold page, back/forward included.
await page.goto(`/${pid}/dashboard/notes`);
await expect(page.locator(".in-title .in-scope")).toContainText("notes");
await page.goBack();
await page.waitForURL(`/${pid}/dashboard`);
});
test("the retired /insights URL still lands on the Dashboard", async ({ page }) => {
await login(page, MEMBER);
const pid = await wikiId(page);
await page.goto(`/${pid}/insights`);
await page.waitForURL(`/${pid}/dashboard`); // normalized, one live URL per page
await expect(page.locator(".in-treemap")).toBeVisible();
await page.goto(`/${pid}/insights/notes`);
await page.waitForURL(`/${pid}/dashboard/notes`);
await expect(page.locator(".in-title .in-scope")).toContainText("notes");
});
test("hot path row opens the file", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/dashboard`);
await page.click(".in-hp-row:first-child");
await page.waitForURL(/\/(index|guide)\.md$/);
await expect(page.locator("#content h1")).toBeVisible();
});
test("vault name returns to the project home", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/index.md`);
await page.click("#vault-name");
await page.waitForURL("/" + pid);
await expect(page.locator(".guide")).toBeVisible();
});
test("back/forward walks home → file → dashboard", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.waitForURL("/" + pid);
await page.click('#tree .row[data-path="index.md"]');
await page.waitForURL(`/${pid}/index.md`);
await page.goto(`/${pid}/dashboard`);
await page.goBack();
await expect(page.locator("#content h1")).toHaveText("Wiki");
await page.goBack();
await expect(page.locator(".guide")).toBeVisible();
await page.goForward();
await expect(page.locator("#content h1")).toHaveText("Wiki");
});
test("history: whole project, newest first, and per-file versions", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.click("#history-btn"); // from home: whole project
await page.waitForURL(`/${pid}/history`);
await expect(page.locator("#crumb")).toContainText("History — all changes");
await expect(page.locator(".history .hentry").first()).toBeVisible();
expect(await page.locator(".history .hentry").count()).toBeGreaterThanOrEqual(6); // all seeded ops
// guide.md has two versions
await page.goto(`/${pid}/history/guide.md`);
await expect(page.locator("#crumb")).toContainText("History — guide.md");
await expect(page.locator(".history .hentry")).toHaveCount(2);
await expect(page.locator(".history .hentry").first()).toContainText("edited");
// clicking an entry opens THAT version of the file (BEA-7); aim at the
// path cell — the row's center can land on its expandable note
await page.click(".history .hentry.clickable >> nth=0 >> .hpath");
await page.waitForURL(new RegExp(`/${pid}/guide\\.md\\?v=[0-9a-f]{64}$`));
});
test("per-file history: a version expands to show what changed", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/history/guide.md`);
const rows = page.locator(".history .hentry");
await expect(rows).toHaveCount(2);
// Nothing is fetched until a row is expanded.
const blobReqs: string[] = [];
page.on("request", (r) => {
if (r.url().includes("/blob?sha=")) blobReqs.push(r.url());
});
await expect(page.locator(".dv")).toHaveCount(0);
// Newest version diffs against the one before it.
await rows.nth(0).locator(".hdiff-btn").click();
const dv = page.locator(".dv");
await expect(dv).toBeVisible();
await expect(dv.locator(".dv-rm")).toContainText("First version of the guide.");
await expect(dv.locator(".dv-ins")).toContainText("Second version of the guide, with more detail.");
await expect(dv.locator(".dv-add")).toHaveText("+1");
await expect(dv.locator(".dv-del")).toHaveText("1");
expect(blobReqs.length).toBe(2); // exactly the two versions, once each
// Collapsing and re-expanding is free: the blobs are cached by sha.
await rows.nth(0).locator(".hdiff-btn").click();
await expect(page.locator(".dv")).toHaveCount(0);
await rows.nth(0).locator(".hdiff-btn").click();
await expect(page.locator(".dv")).toBeVisible();
expect(blobReqs.length).toBe(2);
// The first version has nothing behind it, and says so.
await expect(rows.nth(1).locator(".hdiff-btn")).toHaveCount(0);
await expect(rows.nth(1).locator(".hdiff-none")).toContainText("nothing to compare against");
// Expanding never navigates away.
await expect(page).toHaveURL(`/${pid}/history/guide.md`);
});
test("per-file history: a binary version says so instead of diffing", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/history/assets/logo.png`);
await page.locator(".history .hentry").nth(0).locator(".hdiff-btn").click();
const dv = page.locator(".dv");
await expect(dv).toContainText("Binary file — no diff available");
// Both versions stay reachable by download.
await expect(dv.locator("a", { hasText: "download previous" })).toHaveAttribute(
"href",
/blob\?sha=[0-9a-f]{64}&name=logo\.png&download=1/,
);
await expect(dv.locator("a", { hasText: "download this version" })).toBeVisible();
});
test("the whole-project feed carries no diff controls", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/history`);
await expect(page.locator(".history .hentry").first()).toBeVisible();
await expect(page.locator(".history .hdiff-btn")).toHaveCount(0);
});
test("folder listing's Full history goes to the subtree feed", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/notes`);
await page.click(".dl-more");
await page.waitForURL(`/${pid}/history/notes`);
await expect(page.locator("#crumb")).toContainText("History — notes/ (folder)");
const paths = await page.$$eval(".history .hpath", (els) => els.map((e) => e.textContent));
for (const p of paths) expect(p).toContain("notes/");
});
test("the dashboard scopes to the selected folder via the ⋯ menu", async ({ page }) => {
await login(page, MEMBER); // members get the scoped entry too
const pid = await wikiId(page);
await page.goto(`/${pid}/notes`);
await page.click("#more-btn");
await page.click("#more-menu .more-item:has-text('Dashboard')");
await page.waitForURL(`/${pid}/dashboard/notes`);
await expect(page.locator(".in-title .in-scope")).toContainText("notes");
// Scope note in the subtitle is the stable assertion.
await expect(page.locator(".insights .dl-sub")).toContainText("notes and everything in it");
});
test("project menu pages each own a URL: Dashboard, Installation, Settings", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.click("#nav-dashboard");
await page.waitForURL(`/${pid}/dashboard`);
await expect(page.locator(".insights .in-title")).toContainText("Knowledge insights");
await expect(page.locator("#nav-dashboard")).toHaveClass(/active/);
await page.click("#nav-install");
await page.waitForURL(`/${pid}/install`);
await expect(page.locator("#crumb")).toHaveText("Installation");
await expect(page.locator("#nav-install")).toHaveClass(/active/);
await page.click("#nav-history");
await page.waitForURL(`/${pid}/history`);
await expect(page.locator("#nav-history")).toHaveClass(/active/);
await page.click("#nav-settings");
await page.waitForURL(`/${pid}/settings`);
await expect(page.locator("#crumb")).toHaveText("Project settings");
await expect(page.locator(".project-settings h2")).toHaveText("wiki");
await page.click("#nav-dashboard");
await page.waitForURL(`/${pid}/dashboard`);
await expect(page.locator("#nav-dashboard")).toHaveClass(/active/);
// Deep link + reload land on the page, like any URL.
await page.goto(`/${pid}/settings`);
await expect(page.locator(".project-settings h2")).toHaveText("wiki");
await expect(page.locator("#nav-settings")).toHaveClass(/active/);
});
test("project settings: danger zone is owner-only", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/settings`);
await expect(page.locator(".ps-danger")).toBeVisible();
await expect(page.locator(".ps-danger .danger-btn")).toHaveText("Delete project");
});
test("project settings: a member sees no danger zone and cannot edit", async ({ page }) => {
await login(page, MEMBER);
const pid = await wikiId(page);
await page.goto(`/${pid}/settings`);
await expect(page.locator(".project-settings h2")).toHaveText("wiki"); // page rendered
await expect(page.locator(".ps-danger")).toHaveCount(0);
// The General card is shown, disabled — not hidden, and with no way to submit.
await expect(page.locator("#ps-name")).toBeDisabled();
await expect(page.locator("#ps-desc")).toBeDisabled();
await expect(page.locator("#ps-icon-btn")).toBeDisabled();
await expect(page.locator("#ps-save")).toHaveCount(0);
});
test("project settings: icon + description save, and show in nav and dashboard", async ({
page,
}) => {
await login(page);
const made = await (await page.request.post("/api/projects", { data: { name: "dressed" } })).json();
const pid = made.project.id;
await page.goto(`/${pid}/settings`);
// Nothing dirty yet → nothing to save.
await expect(page.locator("#ps-save")).toBeDisabled();
// Placeholder until an icon is picked.
await expect(page.locator(".ps-icon-row .proj-mark svg")).toHaveCount(1);
await page.click("#ps-icon-btn");
await page.click('.ps-icon-grid [aria-label="book-open"]');
await page.fill("#ps-desc", "everything support needs");
await expect(page.locator(".ps-count")).toHaveText("24 / 280");
await expect(page.locator("#ps-save")).toBeEnabled();
await page.click("#ps-save");
await expectToast(page, "Saved");
await expect(page.locator("#ps-save")).toBeDisabled(); // clean again
// Both surfaces pick it up without a reload: the nav mark right here, and
// the project header on the next SPA navigation (same header component the
// project home renders).
await expect(page.locator("#projects .proj-trigger .proj-mark svg")).toHaveCount(1);
await page.click("#nav-install");
await expect(page.locator(".in-desc")).toHaveText("everything support needs");
await expect(page.locator(".gd-head .proj-mark svg")).toHaveCount(1);
// …and it survives a reload, i.e. it really was persisted — on the project
// home header, and back in the form.
await page.goto(`/${pid}`);
await expect(page.locator(".in-desc")).toHaveText("everything support needs");
await expect(page.locator(".gd-head .proj-mark svg")).toHaveCount(1);
await page.goto(`/${pid}/settings`);
await expect(page.locator("#ps-desc")).toHaveValue("everything support needs");
await page.request.delete("/api/projects/" + pid); // clean up
});
test("project settings: an over-long description is refused inline", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/settings`);
await page.fill("#ps-desc", "x".repeat(281));
await page.click("#ps-save");
await expect(page.locator("#ps-desc-err")).toBeVisible();
await expect(page.locator("#ps-desc")).toHaveValue("x".repeat(281)); // form not cleared
});
test("project settings: delete needs the exact name typed, then navigates away", async ({ page }) => {
await login(page);
await page.request.post("/api/projects", { data: { name: "condemned" } });
await page.reload();
const out = await (await page.request.get("/api/projects")).json();
const pid = out.projects.find((p: { name: string }) => p.name === "condemned").id;
await page.goto(`/${pid}/settings`);
await page.click(".ps-danger .danger-btn");
// Confirm stays disabled until the typed text matches exactly.
await expect(page.locator(".modal .danger-btn")).toBeDisabled();
await page.fill(".modal-input", "condemne");
await expect(page.locator(".modal .danger-btn")).toBeDisabled();
await page.fill(".modal-input", "condemned");
await expect(page.locator(".modal .danger-btn")).toBeEnabled();
await page.click(".modal .danger-btn");
await expectToast(page, "Deleted");
await expect(page).not.toHaveURL(new RegExp(pid));
await expect(page.locator("#projects .row .label", { hasText: "condemned" })).toHaveCount(0);
});
test("project settings: People shows the default level and the grants", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/settings`);
const people = page.locator(".ps-people");
await expect(people).toBeVisible();
// An admin gets live controls...
await expect(people.locator('select[aria-label="Default access for workspace members"]')).toBeEnabled();
await expect(people.locator("button", { hasText: "+ Add" })).toBeVisible();
// ...the seeded read-only member is listed as an exception...
await expect(people.locator(`select[aria-label="Access for ${READER}"]`)).toHaveValue("read");
// ...and the workspace owner is shown as permanently admin, not editable.
await expect(people.locator(".admin-item", { hasText: "Workspace owner" })).toBeVisible();
});
test("a read-only member: no Share, no danger zone, People is read-only", async ({ page }) => {
await login(page, READER);
const pid = await wikiId(page);
// The project is fully visible and browsable.
await page.goto(`/${pid}/index.md`);
await expect(page.locator("#content h1")).toHaveText("Wiki");
// ...but nothing that writes is offered.
await expect(page.locator("#share-btn")).toHaveCount(0);
await page.goto(`/${pid}/settings`);
await expect(page.locator(".project-settings h2")).toContainText("wiki");
await expect(page.locator(".ps-chip")).toHaveText("Read-only");
await expect(page.locator(".ps-danger")).toHaveCount(0);
// The People table is shown, disabled — same layout, no controls.
await expect(page.locator(".ps-people")).toBeVisible();
await expect(
page.locator('.ps-people select[aria-label="Default access for workspace members"]'),
).toBeDisabled();
await expect(page.locator(".ps-people button", { hasText: "+ Add" })).toHaveCount(0);
});