mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Summary Projects v3 makes repository work shareable, discussion-aware, and easier to scan in one coherent workspace. People can copy canonical links, reopen the exact workspace tab, understand issue and pull-request context at a glance, find related channel conversations, and assign or unassign issues across Desktop and CLI. - **Unified workspace** — top-level sections sit above repository controls in one rounded workspace, with navigation positioned close to the page heading. README and Files retain branch selection; every section has a labeled icon header, and Issues and Pull Requests expose creation from a consistent right-aligned action. - **Repository management** — the repository selector is always available, including single-repository projects. Its integrated add flow lets project owners create a repository manually or select an existing repository without a separate toolbar button. - **Readable work-item lists** — issue and pull-request rows use plain-language context instead of opaque metadata. Files, commits, issues, pull requests, channels, and contributors share consistent row density and right-aligned timestamps, while deterministic fallback-avatar colors keep participants distinct on light backgrounds. Inbox pull-request metadata wraps between complete phrases and truncates long channel names instead of compressing copy into narrow columns. - **Reliable entity links** — projects, repositories, issues, pull requests, and commits have canonical `buzz://` links, preview cards, OS deep-link routing, and tab-aware navigation. Reopening the same link re-applies its destination instead of leaving the user on a locally selected tab. - **Related conversations** — repository and work-item views surface channels discussing the current entity, including participants, channel navigation, message context, and an explicit notice when discovery reaches its 500-result cap. - **Reversible issue ownership** — trusted assignment and unassignment events work across Desktop, Tauri, `buzz-sdk`, and `buzz issues`. Assignees appear in project views and the assigned inbox, while authorized users can remove assignments directly from the assignee row. Assignment state is derived chronologically from labeled Nostr notes. Issue authors and repository owners may change any assignee; other users may only assign or unassign themselves. Shared golden fixtures keep entity-link grammar and validation aligned across TypeScript and Rust. The branch also updates `webbrowser` to the patched release for RUSTSEC-2026-0257. ### Related issue N/A. ### Testing - [x] `just ci` — formatting, lint, typechecking, unit tests, and builds passed - [x] Full pre-push suite — organization, branch-skew, Desktop checks, typechecking, and tests passed on the latest push - [x] `cargo test -p buzz-cli` and focused `buzz-sdk` assignment tests passed - [x] Focused Tauri recipient-note and 500-result search-limit tests passed - [x] Desktop entity-link and issue-assignment unit tests passed - [x] Playwright smoke coverage passed for assignment, repeated entity-link navigation, repository create/select flows, section headers and actions, timestamp alignment, timeline icons, sentence-style issue/PR metadata, header spacing, avatar contrast, and Inbox metadata at stacked and side-rail breakpoints - [ ] Manual staging pass: link round-trips, Channels tab, assignment flows, and inbox routing ### Screenshots Pull requests explain who opened the request, where it lives, and which branch it comes from; fallback avatars remain visually distinct.  Issues use the same sentence-style hierarchy while keeping status and recency easy to scan.  The wide Inbox detail keeps author, timestamp, and origin context readable beside its metadata rail.  [View the complete six-state Projects v3 screenshot set](https://github.com/block/buzz/pull/5624#issuecomment-5268039672) and [the compact/wide Inbox comparison](https://github.com/block/buzz/pull/5624#issuecomment-5268614585). --- > Supersedes #5624, whose head commit accumulated permanently-queued required check suites (block-dco-check et al.) that GitHub never dispatched. History flattened into a single signed-off commit on latest main; tree verified byte-identical (`git merge-tree`) to merging the original branch into main. --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com> Co-authored-by: Wintermute <3f1797424fd9ad6653a83665c660517777cd7f8c228c0d5907f49e01537f3ca5@buzz.block.builderlab.xyz>
916 lines
34 KiB
TypeScript
916 lines
34 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { waitForAnimations } from "../helpers/animations";
|
|
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
|
|
|
const SHOTS = "test-results/project-commit-detail";
|
|
const ALIGNMENT_TOLERANCE_PX = 2;
|
|
|
|
// The projects surface is a preview feature — opt in before the app mounts.
|
|
// Must run before installMockBridge so React reads the override on mount.
|
|
async function enableProjectsFeature(page: import("@playwright/test").Page) {
|
|
await page.addInitScript(() => {
|
|
window.localStorage.setItem(
|
|
"buzz-feature-overrides-v1",
|
|
JSON.stringify({ projects: true }),
|
|
);
|
|
});
|
|
}
|
|
|
|
test("top-level project lists align dates and overflow actions", async ({
|
|
page,
|
|
}) => {
|
|
await enableProjectsFeature(page);
|
|
await page.addInitScript(() => {
|
|
window.localStorage.setItem("buzz.projects.viewMode", "list");
|
|
});
|
|
await installMockBridge(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.getByTestId("open-projects-view").click();
|
|
await expect(
|
|
page.getByRole("heading", { level: 1, name: "Projects" }),
|
|
).toBeVisible();
|
|
|
|
async function trailingPositions(
|
|
row: import("@playwright/test").Locator,
|
|
{
|
|
actionName = /More options for/,
|
|
dateTestId = "projects-row-date",
|
|
summaryTestId,
|
|
}: {
|
|
actionName?: RegExp;
|
|
dateTestId?: string;
|
|
summaryTestId?: string;
|
|
} = {},
|
|
) {
|
|
await waitForAnimations(page);
|
|
const date = row.getByTestId(dateTestId);
|
|
const menu = row.getByRole("button", { name: actionName });
|
|
await expect(date).toBeVisible();
|
|
await expect(menu).toBeVisible();
|
|
const dateBox = await date.boundingBox();
|
|
const menuBox = await menu.boundingBox();
|
|
const rowBox = await row.boundingBox();
|
|
const summaryBox = summaryTestId
|
|
? await row.getByTestId(summaryTestId).boundingBox()
|
|
: null;
|
|
expect(dateBox).not.toBeNull();
|
|
expect(menuBox).not.toBeNull();
|
|
expect(rowBox).not.toBeNull();
|
|
if (summaryTestId) expect(summaryBox).not.toBeNull();
|
|
return {
|
|
dateX: dateBox?.x ?? 0,
|
|
menuX: menuBox?.x ?? 0,
|
|
rowHeight: rowBox?.height ?? 0,
|
|
summaryX: summaryBox?.x ?? null,
|
|
};
|
|
}
|
|
|
|
await page.getByTestId("projects-section-projects").click();
|
|
await page.getByRole("button", { name: "Filter projects" }).click();
|
|
await expect(
|
|
page.getByRole("menuitem", { name: "My Projects" }),
|
|
).toBeVisible();
|
|
await expect(page.getByRole("menuitem", { name: "Local" })).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
const projectRow = page.locator('[data-testid^="project-row-"]').first();
|
|
const projectPositions = await trailingPositions(projectRow, {
|
|
summaryTestId: "projects-row-summary",
|
|
});
|
|
// Project rows show the activity bar alone — counts stay in its tooltips.
|
|
await expect(
|
|
projectRow
|
|
.getByTestId("projects-row-summary")
|
|
.getByTestId("project-activity-bar"),
|
|
).toBeVisible();
|
|
await expect(
|
|
projectRow.getByTestId("projects-row-summary"),
|
|
).not.toContainText("commits");
|
|
|
|
await page.getByTestId("projects-section-repositories").click();
|
|
await page.getByRole("button", { name: "Filter repositories" }).click();
|
|
await expect(
|
|
page.getByRole("menuitem", { name: "My Repositories" }),
|
|
).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
await expect(page.getByTestId("repository-row-buzz")).toBeVisible();
|
|
await expect(page.getByTestId("repository-row-relay-tools")).toBeVisible();
|
|
const repositoryRow = page.getByTestId("repository-row-buzz");
|
|
await expect(
|
|
repositoryRow.getByTestId("repositories-row-summary"),
|
|
).toContainText("commits");
|
|
await expect(
|
|
repositoryRow.getByTestId("repositories-row-branch"),
|
|
).toContainText("main");
|
|
// Subtitle is the repository location (owner/repo for Buzz-hosted repos).
|
|
await expect(repositoryRow.locator("p")).toHaveText(/\/buzz$/);
|
|
const repositoryPositions = await trailingPositions(repositoryRow, {
|
|
actionName: /More options for/,
|
|
dateTestId: "repositories-row-date",
|
|
summaryTestId: "repositories-row-summary",
|
|
});
|
|
// No summaryX comparison: repository rows carry text stats next to the bar
|
|
// while project rows show the bar alone, so the columns differ in width by
|
|
// design. The right-anchored date and menu still align across the lists.
|
|
expect(
|
|
Math.abs(repositoryPositions.rowHeight - projectPositions.rowHeight),
|
|
).toBeLessThanOrEqual(ALIGNMENT_TOLERANCE_PX);
|
|
expect(
|
|
Math.abs(repositoryPositions.dateX - projectPositions.dateX),
|
|
).toBeLessThanOrEqual(ALIGNMENT_TOLERANCE_PX);
|
|
expect(
|
|
Math.abs(repositoryPositions.menuX - projectPositions.menuX),
|
|
).toBeLessThanOrEqual(ALIGNMENT_TOLERANCE_PX);
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
path: `${SHOTS}/05-project-repositories-list.png`,
|
|
});
|
|
await page
|
|
.getByTestId("repository-row-relay-tools")
|
|
.getByRole("button", { name: "More options for relay-tools" })
|
|
.click();
|
|
await expect(
|
|
page.getByRole("menuitem", { name: "Clone & open in Terminal" }),
|
|
).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
|
|
await page
|
|
.getByRole("button", { name: "Pull Requests", exact: true })
|
|
.click();
|
|
await page.getByRole("button", { name: "Filter pull requests" }).click();
|
|
await expect(
|
|
page.getByRole("menuitem", { name: "My Pull Requests" }),
|
|
).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
await page.getByTestId("projects-create-menu").hover();
|
|
await expect(page.getByRole("menuitem", { name: "Project" })).toBeVisible();
|
|
await expect(page.getByRole("menuitem", { name: "Issue" })).toBeVisible();
|
|
await page
|
|
.getByRole("menuitem", { name: "Pull Request", exact: true })
|
|
.click();
|
|
await expect(page.getByTestId("create-pull-request-dialog")).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("create-pull-request-repository"),
|
|
).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
await page.getByTestId("projects-create-menu").hover();
|
|
await page.getByRole("menuitem", { name: "Issue" }).click();
|
|
await expect(page.getByTestId("create-issue-repository")).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
const pullRequestRow = page
|
|
.locator('[data-testid^="projects-pr-row-"]')
|
|
.first();
|
|
const pullRequestPositions = await trailingPositions(pullRequestRow);
|
|
await pullRequestRow
|
|
.getByRole("button", { name: /More options for/ })
|
|
.click();
|
|
await expect(
|
|
page.getByRole("menuitem", { name: /Review PR|View (draft|merge|closed)/ }),
|
|
).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
|
|
await page.getByRole("button", { name: "Issues", exact: true }).click();
|
|
await page.getByRole("button", { name: "Filter issues" }).click();
|
|
await expect(page.getByRole("menuitem", { name: "My Issues" })).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
const issueRow = page.locator('[data-testid^="projects-issue-row-"]').first();
|
|
const issuePositions = await trailingPositions(issueRow);
|
|
|
|
expect(
|
|
Math.abs(pullRequestPositions.dateX - projectPositions.dateX),
|
|
).toBeLessThanOrEqual(ALIGNMENT_TOLERANCE_PX);
|
|
expect(
|
|
Math.abs(pullRequestPositions.menuX - projectPositions.menuX),
|
|
).toBeLessThanOrEqual(ALIGNMENT_TOLERANCE_PX);
|
|
expect(
|
|
Math.abs(issuePositions.dateX - projectPositions.dateX),
|
|
).toBeLessThanOrEqual(ALIGNMENT_TOLERANCE_PX);
|
|
expect(
|
|
Math.abs(issuePositions.menuX - projectPositions.menuX),
|
|
).toBeLessThanOrEqual(ALIGNMENT_TOLERANCE_PX);
|
|
|
|
await page.setViewportSize({ height: 720, width: 900 });
|
|
await page.getByTestId("projects-section-projects").click();
|
|
const responsiveRepositoryRow = page
|
|
.locator('[data-testid^="project-row-"]')
|
|
.first();
|
|
await expect(
|
|
responsiveRepositoryRow.getByTestId("projects-row-summary"),
|
|
).toBeHidden();
|
|
await expect(
|
|
responsiveRepositoryRow.getByTestId("projects-row-people"),
|
|
).toBeHidden();
|
|
await expect(
|
|
responsiveRepositoryRow.getByTestId("projects-row-date"),
|
|
).toBeVisible();
|
|
await expect(
|
|
responsiveRepositoryRow.getByRole("button", { name: /More options for/ }),
|
|
).toBeVisible();
|
|
expect(
|
|
await responsiveRepositoryRow.evaluate(
|
|
(row) => row.scrollWidth <= row.clientWidth,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("creating a project publishes its initial repository grouping", async ({
|
|
page,
|
|
}) => {
|
|
await enableProjectsFeature(page);
|
|
await installMockBridge(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.getByTestId("open-projects-view").click();
|
|
await page.getByTestId("projects-create-menu").hover();
|
|
await page.getByRole("menuitem", { name: "Project" }).click();
|
|
await page.getByTestId("create-project-name").fill("multi-repo-demo");
|
|
await page
|
|
.getByTestId("create-project-description")
|
|
.fill("A grouped project created through the desktop app.");
|
|
await page
|
|
.getByTestId("create-project-clone-url")
|
|
.fill("https://relay.example.com/git/owner/multi-repo-demo.git");
|
|
await page.getByTestId("create-project-submit").click();
|
|
|
|
await expect(page.getByTestId("create-project-dialog")).toBeHidden();
|
|
await expect(
|
|
page
|
|
.locator(
|
|
'[data-testid="project-card-multi-repo-demo"], [data-testid="project-row-multi-repo-demo"]',
|
|
)
|
|
.first(),
|
|
).toBeVisible();
|
|
|
|
const createdEvents = await page.evaluate(
|
|
() =>
|
|
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?.filter((event) =>
|
|
event.tags.some(
|
|
(tag) => tag[0] === "d" && tag[1] === "multi-repo-demo",
|
|
),
|
|
) ?? [],
|
|
);
|
|
expect(createdEvents.map((event) => event.kind).sort()).toEqual([
|
|
30617, 30621,
|
|
]);
|
|
const projectEvent = createdEvents.find((event) => event.kind === 30621);
|
|
expect(projectEvent?.tags).toContainEqual([
|
|
"a",
|
|
`30617:${"deadbeef".repeat(8)}:multi-repo-demo`,
|
|
]);
|
|
expect(projectEvent?.content).toBe("");
|
|
|
|
await page.getByTestId("projects-create-menu").hover();
|
|
await page.getByRole("menuitem", { name: "Project" }).click();
|
|
await page.getByTestId("create-project-name").fill("multi-repo-demo");
|
|
await page.getByTestId("create-project-submit").click();
|
|
await expect(page.getByTestId("create-project-dialog")).toBeVisible();
|
|
await expect(
|
|
page.getByText('You already have a project named "multi-repo-demo".'),
|
|
).toBeVisible();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() =>
|
|
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?.filter((event) =>
|
|
event.tags.some(
|
|
(tag) => tag[0] === "d" && tag[1] === "multi-repo-demo",
|
|
),
|
|
).length ?? 0,
|
|
),
|
|
)
|
|
.toBe(2);
|
|
});
|
|
|
|
test("unsupported relays keep the initial repository accessible", async ({
|
|
page,
|
|
}) => {
|
|
await enableProjectsFeature(page);
|
|
await page.addInitScript(() => {
|
|
window.__BUZZ_E2E_UNSUPPORTED_PROJECT_ANNOUNCEMENTS__ = true;
|
|
});
|
|
await installMockBridge(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.getByTestId("open-projects-view").click();
|
|
await page.getByTestId("projects-create-menu").hover();
|
|
await page.getByRole("menuitem", { name: "Project" }).click();
|
|
await page.getByTestId("create-project-name").fill("legacy-fallback");
|
|
await page.getByTestId("create-project-submit").click();
|
|
|
|
await expect(page.getByTestId("create-project-dialog")).toBeHidden();
|
|
await expect(page.getByText("Created as a standalone project")).toBeVisible();
|
|
await waitForAnimations(page);
|
|
const projectEntry = page
|
|
.locator(
|
|
'[data-testid="project-card-legacy-fallback"], [data-testid="project-row-legacy-fallback"]',
|
|
)
|
|
.first();
|
|
await expect(projectEntry).toBeVisible();
|
|
await projectEntry
|
|
.getByRole("button", { name: "View legacy-fallback" })
|
|
.click();
|
|
const repositoryPicker = page.getByTestId("project-repository-picker");
|
|
await expect(repositoryPicker).toBeVisible();
|
|
await expect(repositoryPicker).toContainText("legacy-fallback");
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
path: `${SHOTS}/06-single-repository-add.png`,
|
|
});
|
|
|
|
const acceptedKinds = await page.evaluate(
|
|
() =>
|
|
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__
|
|
?.filter((event) =>
|
|
event.tags.some(
|
|
(tag) => tag[0] === "d" && tag[1] === "legacy-fallback",
|
|
),
|
|
)
|
|
.map((event) => event.kind) ?? [],
|
|
);
|
|
expect(acceptedKinds).toEqual([30617]);
|
|
});
|
|
|
|
test("project creation can retry after its repository publication fails", async ({
|
|
page,
|
|
}) => {
|
|
await enableProjectsFeature(page);
|
|
await page.addInitScript(() => {
|
|
window.__BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__ = [30621];
|
|
});
|
|
await installMockBridge(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.getByTestId("open-projects-view").click();
|
|
await page.getByTestId("projects-create-menu").hover();
|
|
await page.getByRole("menuitem", { name: "Project" }).click();
|
|
await page.getByTestId("create-project-name").fill("retry-project");
|
|
await page.getByTestId("create-project-submit").click();
|
|
|
|
await expect(page.getByTestId("create-project-dialog")).toBeVisible();
|
|
await expect(page.getByText("mock project event rejection")).toBeVisible();
|
|
|
|
await page.getByTestId("create-project-submit").click();
|
|
await expect(page.getByTestId("create-project-dialog")).toBeHidden();
|
|
await expect(
|
|
page
|
|
.locator(
|
|
'[data-testid="project-card-retry-project"], [data-testid="project-row-retry-project"]',
|
|
)
|
|
.first(),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("project creation is idempotent after a lost publish acknowledgement", async ({
|
|
page,
|
|
}) => {
|
|
await enableProjectsFeature(page);
|
|
await page.addInitScript(() => {
|
|
window.__BUZZ_E2E_FAIL_PROJECT_EVENT_ACK_KINDS__ = [30621];
|
|
});
|
|
await installMockBridge(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.getByTestId("open-projects-view").click();
|
|
await page.getByTestId("projects-create-menu").hover();
|
|
await page.getByRole("menuitem", { name: "Project" }).click();
|
|
await page.getByTestId("create-project-name").fill("lost-ack-project");
|
|
await page.getByTestId("create-project-submit").click();
|
|
|
|
await expect(page.getByTestId("create-project-dialog")).toBeVisible();
|
|
await expect(
|
|
page.getByText("mock lost project acknowledgement"),
|
|
).toBeVisible();
|
|
|
|
await page.getByTestId("create-project-submit").click();
|
|
await expect(page.getByTestId("create-project-dialog")).toBeHidden();
|
|
await expect(
|
|
page
|
|
.locator(
|
|
'[data-testid="project-card-lost-ack-project"], [data-testid="project-row-lost-ack-project"]',
|
|
)
|
|
.first(),
|
|
).toBeVisible();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() =>
|
|
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?.filter((event) =>
|
|
event.tags.some(
|
|
(tag) => tag[0] === "d" && tag[1] === "lost-ack-project",
|
|
),
|
|
).length ?? 0,
|
|
),
|
|
)
|
|
.toBe(2);
|
|
});
|
|
|
|
test("multi-repository projects switch the active repository", async ({
|
|
page,
|
|
}) => {
|
|
await enableProjectsFeature(page);
|
|
await installMockBridge(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.getByTestId("open-projects-view").click();
|
|
await page.getByTestId("projects-section-projects").click();
|
|
await page
|
|
.locator(
|
|
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
|
|
)
|
|
.first()
|
|
.click();
|
|
|
|
const picker = page.getByTestId("project-repository-picker");
|
|
await expect(picker).toContainText("buzz");
|
|
await picker.click();
|
|
await expect(
|
|
page.getByTestId("project-repository-relay-tools"),
|
|
).toBeVisible();
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
path: `${SHOTS}/04-multi-repository-picker.png`,
|
|
});
|
|
|
|
await page.getByTestId("project-repository-relay-tools").click();
|
|
await expect(picker).toContainText("relay-tools");
|
|
await expect(page).toHaveURL(
|
|
new RegExp(`repositoryId=${TEST_IDENTITIES.alice.pubkey}%3Arelay-tools`),
|
|
);
|
|
|
|
await picker.click();
|
|
await page.getByTestId("add-project-repository").click();
|
|
await expect(page.getByTestId("create-project-repository")).toBeVisible();
|
|
await expect(page.getByTestId("attach-project-repository")).toBeVisible();
|
|
await page.getByTestId("create-project-repository").click();
|
|
await page.getByTestId("add-project-repository-name").fill("mobile-app");
|
|
await page.getByTestId("add-project-repository-submit").click();
|
|
await expect(page.getByTestId("add-project-repository-dialog")).toBeHidden();
|
|
await expect(picker).toContainText("mobile-app");
|
|
const addedEvents = await page.evaluate(
|
|
() =>
|
|
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?.filter(
|
|
(event) =>
|
|
event.tags.some((tag) => tag[0] === "d" && tag[1] === "mobile-app") ||
|
|
event.tags.some(
|
|
(tag) =>
|
|
tag[0] === "a" &&
|
|
tag[1]?.endsWith(":mobile-app") &&
|
|
event.kind === 30621,
|
|
),
|
|
) ?? [],
|
|
);
|
|
expect(addedEvents.map((event) => event.kind)).toEqual([30621, 30617]);
|
|
expect(
|
|
addedEvents.find((event) => event.kind === 30617)?.tags,
|
|
).toContainEqual(["buzz-channel", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"]);
|
|
|
|
await picker.click();
|
|
await page.getByTestId("add-project-repository").click();
|
|
await page.getByTestId("attach-project-repository").click();
|
|
await expect(
|
|
page.getByTestId("attach-project-repository-dialog"),
|
|
).toBeVisible();
|
|
await page.getByTestId("attach-existing-repository-design-system").click();
|
|
await expect(
|
|
page.getByTestId("attach-project-repository-dialog"),
|
|
).toBeHidden();
|
|
await expect(picker).toContainText("design-system");
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() =>
|
|
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?.some(
|
|
(event) =>
|
|
event.kind === 30621 &&
|
|
event.tags.some(
|
|
(tag) => tag[0] === "a" && tag[1]?.endsWith(":design-system"),
|
|
),
|
|
) ?? false,
|
|
),
|
|
)
|
|
.toBe(true);
|
|
});
|
|
|
|
test("commit detail opens from the commits feed with a diff", async ({
|
|
page,
|
|
}) => {
|
|
await enableProjectsFeature(page);
|
|
await installMockBridge(page);
|
|
// The preview server is a static file server without SPA fallback, so
|
|
// enter at "/" and navigate via the sidebar.
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.getByTestId("open-projects-view").click();
|
|
|
|
// The overview no longer lists repository cards — switch to the
|
|
// Projects filter reveals the complete project cards/rows list.
|
|
await page.getByTestId("projects-section-projects").click();
|
|
|
|
// Open the first mock project (dtag "buzz" from the e2e bridge fixture).
|
|
const projectEntry = page
|
|
.locator(
|
|
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
|
|
)
|
|
.first();
|
|
await expect(projectEntry).toBeVisible({ timeout: 10_000 });
|
|
await projectEntry.click();
|
|
|
|
await page.getByRole("tab", { name: "Commits" }).click();
|
|
const commitRows = page.getByTestId("project-activity-feed-item");
|
|
await expect(commitRows.first()).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Commits share the rounded list structure used by issues and pull requests.
|
|
await expect(
|
|
page.getByRole("heading", { name: "Commits", exact: true }),
|
|
).toBeVisible();
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
fullPage: false,
|
|
path: `${SHOTS}/02-commits-feed.png`,
|
|
});
|
|
|
|
// Open the newest commit via its subject button.
|
|
await commitRows
|
|
.first()
|
|
.getByRole("button", { name: /Add Trello board workflow details/ })
|
|
.click();
|
|
|
|
// Detail header: author line, subject, and hash.
|
|
await expect(page.getByText("Commit from")).toBeVisible();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Add Trello board workflow details" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("button", { name: "Copy commit hash" }),
|
|
).toBeVisible();
|
|
await expect(page.getByTestId("project-workspace-tab-menu")).toHaveCount(0);
|
|
await expect(
|
|
page.getByRole("link", { name: "project guide", exact: true }),
|
|
).toHaveAttribute("href", "https://example.com/project-guide");
|
|
await expect(
|
|
page.getByRole("button", { name: "Architecture" }),
|
|
).toBeVisible();
|
|
await expect(page.locator("video")).toHaveAttribute(
|
|
"src",
|
|
"https://example.com/project-demo.mp4",
|
|
);
|
|
|
|
// Diff from the mocked get_project_repo_diff renders changed files.
|
|
await expect(page.getByText("2 changed files")).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
await expect(
|
|
page.getByText("CommunityTabs({ selectedCommitHash })"),
|
|
).toBeVisible();
|
|
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
fullPage: false,
|
|
path: `${SHOTS}/01-commit-detail.png`,
|
|
});
|
|
|
|
// Breadcrumb category segment steps back to the commits feed.
|
|
await page
|
|
.getByRole("navigation", { name: "Project breadcrumb" })
|
|
.getByRole("button", { name: "Commits", exact: true })
|
|
.click();
|
|
await expect(commitRows.first()).toBeVisible();
|
|
await expect(page.getByTestId("project-workspace-tab-menu")).toBeVisible();
|
|
|
|
// The commits feed itself gets a grayed sub-tab crumb.
|
|
await expect(
|
|
page.getByRole("navigation", { name: "Project breadcrumb" }),
|
|
).toContainText("Commits");
|
|
|
|
// The project-name segment goes to the project home (Overview tab).
|
|
await commitRows
|
|
.first()
|
|
.getByRole("button", { name: /Add Trello board workflow details/ })
|
|
.click();
|
|
await expect(page.getByText("Commit from")).toBeVisible();
|
|
await page
|
|
.getByRole("navigation", { name: "Project breadcrumb" })
|
|
.getByRole("button", { name: "buzz", exact: true })
|
|
.click();
|
|
await expect(page.getByRole("tab", { name: "Overview" })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
|
|
// The Projects root segment leaves the project entirely.
|
|
await page
|
|
.getByRole("navigation", { name: "Project breadcrumb" })
|
|
.getByRole("button", { name: "Projects", exact: true })
|
|
.click();
|
|
await expect(projectEntry).toBeVisible();
|
|
});
|
|
|
|
test("pull request and issue feeds share the commit row structure", async ({
|
|
page,
|
|
}) => {
|
|
await enableProjectsFeature(page);
|
|
await installMockBridge(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.getByTestId("open-projects-view").click();
|
|
|
|
// The overview no longer lists repository cards — switch to the
|
|
// Projects filter reveals the complete project cards/rows list.
|
|
await page.getByTestId("projects-section-projects").click();
|
|
|
|
const projectEntry = page
|
|
.locator(
|
|
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
|
|
)
|
|
.first();
|
|
await expect(projectEntry).toBeVisible({ timeout: 10_000 });
|
|
await projectEntry.click();
|
|
|
|
// PR rows use the shared feed row: title button + #id cluster cell.
|
|
await page.getByRole("tab", { name: "Pull Request" }).click();
|
|
const prRows = page.getByTestId("project-pull-request-row");
|
|
await expect(prRows.first()).toBeVisible({ timeout: 10_000 });
|
|
await expect(
|
|
prRows.first().getByRole("button", { name: /^#/ }),
|
|
).toBeVisible();
|
|
await waitForAnimations(page);
|
|
await page.screenshot({ fullPage: false, path: `${SHOTS}/03-prs-feed.png` });
|
|
|
|
// The #id cell opens the PR detail, same as clicking the title.
|
|
await prRows.first().getByRole("button", { name: /^#/ }).click();
|
|
await expect(
|
|
page.getByRole("navigation", { name: "Project breadcrumb" }),
|
|
).toContainText("Pull Request");
|
|
|
|
// Step back to the feed so the community tabs are available again.
|
|
await page
|
|
.getByRole("navigation", { name: "Project breadcrumb" })
|
|
.getByRole("button", { name: "Pull Request", exact: true })
|
|
.click();
|
|
await expect(prRows.first()).toBeVisible();
|
|
|
|
// Issue rows share the same structure.
|
|
await page.getByRole("tab", { name: "Issues" }).click();
|
|
const issueRows = page.getByTestId("project-issue-row");
|
|
await expect(issueRows.first()).toBeVisible({ timeout: 10_000 });
|
|
await expect(
|
|
issueRows.first().getByRole("button", { name: /^#/ }),
|
|
).toBeVisible();
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
fullPage: false,
|
|
path: `${SHOTS}/04-issues-feed.png`,
|
|
});
|
|
});
|
|
|
|
test("adding a repository retries and reports an error when the 30617 publication is rejected", async ({
|
|
page,
|
|
}) => {
|
|
await enableProjectsFeature(page);
|
|
// Reject the repository-announcement event on every attempt so the mutation
|
|
// exhausts its retry and surfaces a partial-write error.
|
|
await page.addInitScript(() => {
|
|
// Reject kind 30617 twice (initial attempt + one retry).
|
|
window.__BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__ = [30617, 30617];
|
|
});
|
|
await installMockBridge(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.getByTestId("open-projects-view").click();
|
|
await page.getByTestId("projects-section-projects").click();
|
|
await page
|
|
.locator(
|
|
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
|
|
)
|
|
.first()
|
|
.click();
|
|
|
|
const picker = page.getByTestId("project-repository-picker");
|
|
await picker.click();
|
|
await page.getByTestId("add-project-repository").click();
|
|
await page.getByTestId("create-project-repository").click();
|
|
await page.getByTestId("add-project-repository-name").fill("rejected-repo");
|
|
await page.getByTestId("add-project-repository-submit").click();
|
|
|
|
// The project event (30621) is published; the repository event (30617) is
|
|
// rejected on both attempts. The dialog must surface the partial-write error.
|
|
await expect(page.getByTestId("add-project-repository-dialog")).toBeVisible();
|
|
await expect(
|
|
page.getByText(/repository could not be created/i),
|
|
).toBeVisible();
|
|
|
|
// The 30621 must have been published (project was updated).
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() =>
|
|
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?.some(
|
|
(event) =>
|
|
event.kind === 30621 &&
|
|
event.tags.some(
|
|
(tag) => tag[0] === "a" && tag[1]?.endsWith(":rejected-repo"),
|
|
),
|
|
) ?? false,
|
|
),
|
|
)
|
|
.toBe(true);
|
|
|
|
// The 30617 must NOT have been accepted (both attempts were rejected).
|
|
const acceptedRepo = await page.evaluate(
|
|
() =>
|
|
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?.some(
|
|
(event) =>
|
|
event.kind === 30617 &&
|
|
event.tags.some(
|
|
(tag) => tag[0] === "d" && tag[1] === "rejected-repo",
|
|
),
|
|
) ?? false,
|
|
);
|
|
expect(
|
|
acceptedRepo,
|
|
"30617 must not be accepted when the relay rejects both attempts",
|
|
).toBe(false);
|
|
});
|
|
|
|
test("adding a repository treats a lost 30617 acknowledgement as success", async ({
|
|
page,
|
|
}) => {
|
|
await enableProjectsFeature(page);
|
|
// The relay will accept the 30617 but fail to deliver the ACK, then on the
|
|
// retry query the event will be found — the mutation must succeed.
|
|
await page.addInitScript(() => {
|
|
window.__BUZZ_E2E_FAIL_PROJECT_EVENT_ACK_KINDS__ = [30617];
|
|
});
|
|
await installMockBridge(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.getByTestId("open-projects-view").click();
|
|
await page.getByTestId("projects-section-projects").click();
|
|
await page
|
|
.locator(
|
|
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
|
|
)
|
|
.first()
|
|
.click();
|
|
|
|
const picker = page.getByTestId("project-repository-picker");
|
|
|
|
await picker.click();
|
|
await page.getByTestId("add-project-repository").click();
|
|
await page.getByTestId("create-project-repository").click();
|
|
await page.getByTestId("add-project-repository-name").fill("lost-ack-repo");
|
|
await page.getByTestId("add-project-repository-submit").click();
|
|
|
|
// The dialog should close — the operation recovered from the lost ACK.
|
|
await expect(page.getByTestId("add-project-repository-dialog")).toBeHidden();
|
|
// The repository picker must reflect the newly added repository.
|
|
await expect(picker).toContainText("lost-ack-repo");
|
|
|
|
// Both events must have been accepted: the 30621 (project update) and the
|
|
// 30617 (repository — accepted by relay even though ACK was lost).
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() =>
|
|
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?.filter((event) =>
|
|
event.tags.some(
|
|
(tag) => tag[0] === "d" && tag[1] === "lost-ack-repo",
|
|
),
|
|
).length ?? 0,
|
|
),
|
|
)
|
|
.toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test("adding a repository blocks when a standalone 30617 already exists at that coordinate", async ({
|
|
page,
|
|
}) => {
|
|
await enableProjectsFeature(page);
|
|
// Seed a standalone 30617 (not a project member) owned by the mock identity.
|
|
// The add-repo mutation must block unconditionally when this coordinate exists,
|
|
// even though it is not yet in the "buzz" project's member list.
|
|
const MOCK_OWNER = "deadbeef".repeat(8);
|
|
const STANDALONE_DTAG = "existing-standalone";
|
|
await page.addInitScript(
|
|
({ owner, dtag }) => {
|
|
window.__BUZZ_E2E_EXTRA_PROJECT_EVENTS__ = [
|
|
{
|
|
id: "standalone00".padEnd(64, "0"),
|
|
kind: 30617,
|
|
pubkey: owner,
|
|
created_at: Math.floor(Date.now() / 1000) - 3600,
|
|
content: "A standalone repository that exists outside any project.",
|
|
tags: [
|
|
["d", dtag],
|
|
["name", "Existing Standalone"],
|
|
["clone", "https://git.example.com/standalone.git"],
|
|
],
|
|
},
|
|
];
|
|
},
|
|
{ owner: MOCK_OWNER, dtag: STANDALONE_DTAG },
|
|
);
|
|
await installMockBridge(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.getByTestId("open-projects-view").click();
|
|
await page.getByTestId("projects-section-projects").click();
|
|
await page
|
|
.locator(
|
|
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
|
|
)
|
|
.first()
|
|
.click();
|
|
|
|
const picker = page.getByTestId("project-repository-picker");
|
|
await picker.click();
|
|
await page.getByTestId("add-project-repository").click();
|
|
await page.getByTestId("create-project-repository").click();
|
|
// Use the same name — the dtag will match the seeded standalone 30617.
|
|
await page
|
|
.getByTestId("add-project-repository-name")
|
|
.fill("Existing Standalone");
|
|
await page.getByTestId("add-project-repository-submit").click();
|
|
|
|
// The dialog must remain open with a clobber error.
|
|
await expect(page.getByTestId("add-project-repository-dialog")).toBeVisible();
|
|
await expect(
|
|
page.getByText(/already exists.*standalone.*another project/i),
|
|
).toBeVisible();
|
|
|
|
// Neither a 30621 (project update) nor a 30617 (new repo) must have been published.
|
|
const publishedForStandalone = await page.evaluate(
|
|
({ dtag }) =>
|
|
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?.some(
|
|
(event) =>
|
|
event.tags.some((tag) => tag[0] === "d" && tag[1] === dtag) ||
|
|
event.tags.some(
|
|
(tag) => tag[0] === "a" && tag[1]?.endsWith(`:${dtag}`),
|
|
),
|
|
) ?? false,
|
|
{ dtag: STANDALONE_DTAG },
|
|
);
|
|
expect(
|
|
publishedForStandalone,
|
|
"neither project nor repository event must be published when clobber guard fires",
|
|
).toBe(false);
|
|
});
|
|
|
|
test("navigating via a 30617 entity-link route opens the correct non-primary repository and renders its PR", async ({
|
|
page,
|
|
}) => {
|
|
await enableProjectsFeature(page);
|
|
// Seed a known pull-request for relay-tools (the non-primary member of "buzz")
|
|
// with a deterministic id so the URL can be constructed before navigation.
|
|
const ALICE_PUBKEY =
|
|
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
|
|
const RELAY_TOOLS_ADDRESS = `30617:${ALICE_PUBKEY}:relay-tools`;
|
|
const KNOWN_PR_ID = "entity-link-pr-test".padEnd(64, "0");
|
|
|
|
await page.addInitScript(
|
|
({ repoAddress, prId, alicePubkey }) => {
|
|
window.__BUZZ_E2E_EXTRA_PROJECT_EVENTS__ = [
|
|
{
|
|
id: prId,
|
|
kind: 1618, // KIND_GIT_PULL_REQUEST
|
|
pubkey: alicePubkey,
|
|
created_at: Math.floor(Date.now() / 1000) - 60,
|
|
content: "Entity-link test PR from relay-tools",
|
|
tags: [
|
|
["a", repoAddress],
|
|
["subject", "Entity-link test PR from relay-tools"],
|
|
["c", "abc123".padEnd(40, "0")],
|
|
["h", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"],
|
|
["branch-name", "feature/entity-link-test"],
|
|
["clone", "https://github.com/block/relay-tools.git"],
|
|
],
|
|
},
|
|
];
|
|
},
|
|
{
|
|
repoAddress: RELAY_TOOLS_ADDRESS,
|
|
prId: KNOWN_PR_ID,
|
|
alicePubkey: ALICE_PUBKEY,
|
|
},
|
|
);
|
|
await installMockBridge(page);
|
|
|
|
// Navigate via the entity-link route using the hash router URL format.
|
|
// Python's http.server (the e2e web server) serves only index.html at `/`;
|
|
// a direct page.goto to `/projects/...` returns 404 because there is no
|
|
// SPA fallback. The app uses createHashHistory(), so the correct URL is
|
|
// `/#/projects/<id>?...` — the server always sees just `/` and the hash
|
|
// fragment is resolved entirely client-side by TanStack Router. Colons are
|
|
// valid in hash-fragment path segments and must NOT be percent-encoded:
|
|
// TanStack Router's param extractor receives the raw decoded segment, and
|
|
// %3A would be passed through literally (as the string "30617%3A…") rather
|
|
// than decoded to "30617:…", causing the project lookup to fail.
|
|
await page.goto(
|
|
`/#/projects/${RELAY_TOOLS_ADDRESS}?pullRequestId=${KNOWN_PR_ID}`,
|
|
{ waitUntil: "domcontentloaded" },
|
|
);
|
|
|
|
// Repository controls intentionally render only on README and Files. The
|
|
// seeded PR and branch prove that this detail route resolved relay-tools
|
|
// rather than falling back to the project's primary repository.
|
|
await expect(page.getByTestId("project-repository-picker")).toHaveCount(0);
|
|
// Use `first()` to avoid Playwright strict-mode violations: the text appears
|
|
// in both the breadcrumb and the PR title heading once the detail panel opens.
|
|
await expect(
|
|
page.getByText("Entity-link test PR from relay-tools").first(),
|
|
).toBeVisible({ timeout: 10_000 });
|
|
await expect(
|
|
page.getByText("feature/entity-link-test").first(),
|
|
).toBeVisible();
|
|
});
|