mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
## Summary - inject bounded project-home identity and repository context into managed agent sessions - add project-aware CLI flows for creating projects, repositories, issues, and related channels - match project homes through the existing relay query surface, then filter channel metadata client-side This is Part 1 of the channel-first Projects stack. Part 2 contains project creation and model foundations. ## Testing - `cargo fmt --all -- --check` - `cargo clippy -p buzz-cli -p buzz-acp --all-targets -- -D warnings` - `cargo test -p buzz-cli -p buzz-acp` — 1,184 tests passed, 1 doc test ignored - full pre-push gate passed ## Post-Deploy Monitoring & Validation - validate project-home agent context and project-aware CLI commands against a staging relay - healthy signals: project context matches the active channel, explicit repo coordinates remain stable, and normal channels receive no project block - failure signals: cross-channel project context, duplicate project creation, or commands targeting an unrelated repository; mitigate by reverting this PR --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com> Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz>
194 lines
7.5 KiB
TypeScript
194 lines
7.5 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
|
|
|
/**
|
|
* End-to-end guard for the false-empty thread-load bug on the SECOND producer
|
|
* of the shared thread panel: the Projects "channel conversation" panel
|
|
* (`ProjectConversationPanel`). PR #6447 wired the query failure state through
|
|
* `ChannelScreen`, but the Projects surface calls the same `useThreadReplies`
|
|
* and used to hard-code `threadRepliesPending={false}` with no error/retry — so
|
|
* a terminal `/query` failure there painted "No replies in this branch yet"
|
|
* with no recovery, the exact defect the PR fixed one surface over.
|
|
*
|
|
* This drives the REAL Projects panel wiring through the mock bridge: open the
|
|
* project's Channels tab, open a conversation, fail every `get_thread_replies`
|
|
* fetch at the IPC boundary, and assert the error/Retry card renders (never the
|
|
* false-empty). Any regression at the Projects call site (e.g. dropping
|
|
* `threadRepliesError` again, or a static `isError={false}`) ships a visible
|
|
* false-empty and turns this case red.
|
|
*/
|
|
|
|
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
|
|
const ROOT_CONTENT = `Projects conversation root ${DEFAULT_MOCK_PUBKEY} buzz`;
|
|
const REPLY_CONTENT = "Projects conversation reply body";
|
|
|
|
// The projects surface is a preview feature — opt in before the app mounts.
|
|
async function enableProjectsFeature(page: Page) {
|
|
await page.addInitScript(() => {
|
|
window.localStorage.setItem(
|
|
"buzz-feature-overrides-v1",
|
|
JSON.stringify({ projects: true }),
|
|
);
|
|
});
|
|
}
|
|
|
|
// Fail every get_thread_replies fetch at the IPC boundary while the flag is on,
|
|
// then let the real mock handler answer once it is cleared. Wrapping
|
|
// __TAURI_INTERNALS__.invoke exercises the whole Projects thread-load path —
|
|
// the panel's useThreadReplies query, its retry, and the shared region — with
|
|
// no source seam to bypass.
|
|
async function installThreadFailureSwitch(page: Page) {
|
|
await page.evaluate(() => {
|
|
const w = window as typeof window & {
|
|
__FAIL_THREAD_REPLIES__?: boolean;
|
|
__TAURI_INTERNALS__: {
|
|
invoke: (
|
|
command: string,
|
|
payload: unknown,
|
|
options: unknown,
|
|
) => Promise<unknown>;
|
|
};
|
|
};
|
|
w.__FAIL_THREAD_REPLIES__ = false;
|
|
const original = w.__TAURI_INTERNALS__.invoke.bind(w.__TAURI_INTERNALS__);
|
|
w.__TAURI_INTERNALS__.invoke = async (command, payload, options) => {
|
|
if (command === "get_thread_replies" && w.__FAIL_THREAD_REPLIES__) {
|
|
throw new Error("relay unreachable: request timed out");
|
|
}
|
|
return original(command, payload, options);
|
|
};
|
|
});
|
|
}
|
|
|
|
async function setThreadRepliesFailing(page: Page, failing: boolean) {
|
|
await page.evaluate((failing) => {
|
|
(
|
|
window as typeof window & { __FAIL_THREAD_REPLIES__?: boolean }
|
|
).__FAIL_THREAD_REPLIES__ = failing;
|
|
}, failing);
|
|
}
|
|
|
|
test.describe("project conversation load failure", () => {
|
|
test("terminal fetch failure shows error card, never false-empty; Retry recovers", async ({
|
|
page,
|
|
}) => {
|
|
await enableProjectsFeature(page);
|
|
await installMockBridge(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
|
|
// Seed the conversation BEFORE opening general — i.e. before its live
|
|
// subscription exists — so the reply lands in the mock store (searchable,
|
|
// and returnable by a successful get_thread_replies) but is never live
|
|
// pushed into the thread-replies cache. If general were open first, the
|
|
// live handler would seed that cache and the panel would render the list
|
|
// branch, masking the error card. The root carries the repository discovery
|
|
// token so it surfaces as the channel's latest discussion hit (what the
|
|
// Channels-tab row opens); its reply omits the token so it never competes
|
|
// to be the opened hit.
|
|
//
|
|
// Wait for the emitter first: emitting before the app boots is a silent
|
|
// no-op (the helper is undefined), which would leave the store empty.
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() =>
|
|
typeof (
|
|
window as typeof window & {
|
|
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: unknown;
|
|
}
|
|
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
),
|
|
)
|
|
.toBe(true);
|
|
const rootId = await page.evaluate(
|
|
({ author, rootContent, replyContent }) => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const emit = (
|
|
window as typeof window & {
|
|
__BUZZ_E2E_EMIT_MOCK_MESSAGE__: (input: {
|
|
channelName: string;
|
|
content: string;
|
|
parentEventId?: string;
|
|
pubkey?: string;
|
|
createdAt?: number;
|
|
}) => { id: string };
|
|
}
|
|
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
const root = emit({
|
|
channelName: "general",
|
|
content: rootContent,
|
|
pubkey: author,
|
|
createdAt: now,
|
|
});
|
|
emit({
|
|
channelName: "general",
|
|
content: replyContent,
|
|
parentEventId: root.id,
|
|
pubkey: author,
|
|
createdAt: now + 1,
|
|
});
|
|
return root.id;
|
|
},
|
|
{
|
|
author: TEST_IDENTITIES.alice.pubkey,
|
|
rootContent: ROOT_CONTENT,
|
|
replyContent: REPLY_CONTENT,
|
|
},
|
|
);
|
|
expect(rootId).toBeTruthy();
|
|
|
|
// Navigate to the project's Channels tab and open the general conversation.
|
|
await page.getByTestId("open-projects-view").click();
|
|
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();
|
|
await page.getByTestId("project-home-context-repo-buzz").click();
|
|
await page.getByRole("tab", { name: "Channels", exact: true }).click();
|
|
const channelRow = page
|
|
.getByTestId("project-channel-row")
|
|
.filter({ hasText: "#general" })
|
|
.first();
|
|
await expect(channelRow).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Fail every thread-replies fetch, THEN open the conversation: the panel's
|
|
// query and its retry both fail, driving the terminal error state with an
|
|
// empty reply cache and nothing to fall back to.
|
|
await installThreadFailureSwitch(page);
|
|
await setThreadRepliesFailing(page, true);
|
|
await channelRow.click();
|
|
|
|
const panel = page.getByTestId("project-conversation-panel");
|
|
await expect(panel).toBeVisible();
|
|
|
|
// The load-bearing assertion: a terminal failure paints the error/Retry
|
|
// card and NEVER the false-empty "No replies in this branch yet" state.
|
|
await expect(
|
|
page.getByTestId("message-thread-replies-error"),
|
|
).toContainText("Couldn't load replies", { timeout: 15_000 });
|
|
await expect(page.getByTestId("message-thread-replies-retry")).toHaveText(
|
|
"Retry",
|
|
);
|
|
await expect(page.getByText("No replies in this branch yet")).toHaveCount(
|
|
0,
|
|
);
|
|
|
|
// Retry with fetches succeeding: the reply loads and renders — the error
|
|
// card is gone and no false-empty appears.
|
|
await setThreadRepliesFailing(page, false);
|
|
await page.getByTestId("message-thread-replies-retry").click();
|
|
await expect(page.getByTestId("message-thread-replies-error")).toHaveCount(
|
|
0,
|
|
);
|
|
await expect(page.getByText(REPLY_CONTENT)).toBeVisible();
|
|
await expect(page.getByText("No replies in this branch yet")).toHaveCount(
|
|
0,
|
|
);
|
|
});
|
|
});
|