feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,142 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// Mock agent pubkeys (distinct from the relay agents seeded by default).
const AGENT_PAUL = "aa".repeat(32);
const AGENT_DUNCAN = "bb".repeat(32);
// Mock channel IDs from the e2e bridge.
const CHANNEL_GENERAL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const CHANNEL_ENGINEERING = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9";
// A fixed epoch so the mocked clock is deterministic across runs.
const T0 = new Date("2026-06-18T12:00:00.000Z");
// Past both thresholds: FRAME_GAP_PAUSE_MS (20s) and REMOVE_AFTER_MS (25s).
// Several 5s prune ticks fire across this span, so shouldPausePrune is what
// keeps the badges alive — not the absence of a prune tick.
const FRAME_GAP_MS = 30_000;
type SeedInput = {
agentPubkey: string;
channelId: string;
turnId: string;
};
async function waitForBridge(page: import("@playwright/test").Page) {
await page.waitForFunction(
() =>
typeof (window as Window & { __BUZZ_E2E_SEED_ACTIVE_TURNS__?: unknown })
.__BUZZ_E2E_SEED_ACTIVE_TURNS__ === "function",
null,
{ timeout: 10_000 },
);
}
async function openAgentsView(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForBridge(page);
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId("unified-agents-groups")).toBeVisible({
timeout: 10_000,
});
}
async function seedTurns(
page: import("@playwright/test").Page,
turns: SeedInput[],
) {
await page.evaluate((seeds) => {
const win = window as Window & {
__BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: {
agentPubkey: string;
channelId: string;
turnId: string;
}) => void;
};
for (const seed of seeds) win.__BUZZ_E2E_SEED_ACTIVE_TURNS__?.(seed);
}, turns);
}
async function openAgentProfile(
page: import("@playwright/test").Page,
pubkey: string,
) {
await page.getByTestId(`managed-agent-${pubkey}`).click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible({ timeout: 5_000 });
return panel;
}
test.describe("active turn badge resilience", () => {
test.use({ viewport: { width: 1280, height: 720 } });
test("badges persist through an all-at-once liveness gap", async ({
page,
}) => {
// Install the mocked clock BEFORE navigation so the store's Date.now() /
// setInterval and the badge's useNow(1000) all run on the mocked clock from
// module init. Seeded turns then stamp lastActivityAt at T0.
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PAUL,
name: "Paul",
status: "running",
channelNames: ["general", "engineering"],
},
{
pubkey: AGENT_DUNCAN,
name: "Duncan",
status: "running",
channelNames: ["general"],
},
],
});
await page.clock.install({ time: T0 });
await openAgentsView(page);
// Both agents working across channels — the healthy multi-agent state.
await seedTurns(page, [
{
agentPubkey: AGENT_PAUL,
channelId: CHANNEL_GENERAL,
turnId: "t-paul-g",
},
{
agentPubkey: AGENT_PAUL,
channelId: CHANNEL_ENGINEERING,
turnId: "t-paul-e",
},
{
agentPubkey: AGENT_DUNCAN,
channelId: CHANNEL_GENERAL,
turnId: "t-duncan-g",
},
]);
const paulPanel = await openAgentProfile(page, AGENT_PAUL);
await expect(paulPanel).toBeVisible();
// The profile panel surfaces active turns via the live-activity embed only
// where an agent session can open (channel surfaces). In the Agents view
// the store-driven working state shows as sidebar channel badges — the
// same activeAgentTurnsStore this test exercises.
const generalBadge = page.getByTestId("channel-working-general");
const engineeringBadge = page.getByTestId("channel-working-engineering");
await expect(generalBadge).toBeVisible({ timeout: 5_000 });
await expect(engineeringBadge).toBeVisible();
// Simulate the all-at-once relay drop: no further frames, advance the clock
// past both thresholds. This fires several real prune ticks; shouldPausePrune
// sees every turn's lastActivityAt stuck at T0 (gap > 20s) and pauses the
// prune, so the active-turn-driven working badges survive. Under the
// pre-fix code every badge would be gone after the first tick past 25s.
await page.clock.fastForward(FRAME_GAP_MS);
await expect(generalBadge).toBeVisible();
await expect(engineeringBadge).toBeVisible();
});
});
@@ -0,0 +1,163 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/activity-scope-label";
const AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
const LONG_AGENT_NAME =
"Observer Agent With An Exceptionally Long Display Name";
const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; // #agents
// Open the activity pane via profile → "View activity" (same ingress the
// observer-feed screenshot spec uses).
async function openActivityFromChannel(
page: import("@playwright/test").Page,
channelTestId: string,
channelTitle: string,
) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId(channelTestId).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelTitle);
const messageRow = page
.getByTestId("message-row")
.filter({ has: page.getByText("Observer Agent", { exact: false }) });
await expect(messageRow.first()).toBeVisible({ timeout: 8_000 });
await messageRow.first().getByRole("button").first().click();
const profilePanel = page.getByTestId("user-profile-panel");
await expect(profilePanel).toBeVisible({ timeout: 10_000 });
const activityBtn = page.getByTestId(
`user-profile-view-activity-${AGENT_PUBKEY}`,
);
await expect(activityBtn).toBeVisible({ timeout: 5_000 });
await activityBtn.click();
const panel = page.getByTestId("agent-session-thread-panel");
await expect(panel).toBeVisible({ timeout: 10_000 });
return panel;
}
test.describe("activity panel scope label", () => {
test("channel-targeted pane shows the channel name", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: "Observer Agent",
status: "running" as const,
channelNames: ["agents"],
},
],
});
const panel = await openActivityFromChannel(
page,
"channel-agents",
"agents",
);
await expect(page.getByTestId("agent-session-agent-name")).toHaveText(
"Observer Agent",
);
await expect(page.getByTestId("agent-session-agent-avatar")).toBeVisible();
const scope = page.getByTestId("agent-session-scope-label");
await expect(scope).toHaveText("Activity · #agents");
const recency = page.getByTestId("agent-session-recency-label");
await expect(recency).toHaveText("No updates yet");
await expect(recency).toBeVisible();
// Simulate long-scope pressure deterministically: scope owns truncation
// while the independently shrinking recency stays visible.
await scope.evaluate((element) => {
element.style.width = "5rem";
element.style.flex = "none";
});
expect(
await scope.evaluate((element) => element.scrollWidth),
).toBeGreaterThan(await scope.evaluate((element) => element.clientWidth));
await expect(recency).toBeVisible();
await page.getByTestId("agent-session-settings-menu-trigger").click();
await page.getByTestId("agent-session-toggle-raw-feed").click();
await expect(scope).toHaveText("Raw ACP activity · #agents");
expect(
await scope.evaluate((element) => element.scrollWidth),
).toBeGreaterThan(await scope.evaluate((element) => element.clientWidth));
await expect(recency).toHaveText("No updates yet");
await expect(recency).toBeVisible();
await page.keyboard.press("Escape");
await waitForAnimations(page);
await panel.screenshot({ path: `${SHOTS}/01-channel-scoped.png` });
});
test("unscoped pane shows All channels", async ({ page }) => {
// The agent lives in #random only. Restoring an agentSession URL on
// #agents (where the agent is not in the activity list) puts the pane in
// all-channels scope — the state that looked silently broken before the
// scope label existed. The app uses a hash router, so the deep link goes
// in the hash.
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: LONG_AGENT_NAME,
status: "running" as const,
channelNames: ["random"],
},
],
searchProfiles: [
{
pubkey: AGENT_PUBKEY,
displayName: LONG_AGENT_NAME,
isAgent: true,
},
],
});
await page.goto(
`/#/channels/${AGENTS_CHANNEL_ID}?agentSession=${AGENT_PUBKEY}`,
{ waitUntil: "domcontentloaded" },
);
await expect(page.getByTestId("chat-title")).toHaveText("agents");
const panel = page.getByTestId("agent-session-thread-panel");
await expect(panel).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("agent-session-agent-name")).toHaveText(
LONG_AGENT_NAME,
);
const agentName = page.getByTestId("agent-session-agent-name");
expect(
await agentName.evaluate((element) => element.scrollWidth),
).toBeGreaterThan(
await agentName.evaluate((element) => element.clientWidth),
);
const scope = page.getByTestId("agent-session-scope-label");
await expect(scope).toHaveText("Activity · All channels");
const recency = page.getByTestId("agent-session-recency-label");
await expect(recency).toHaveText("No updates yet");
await expect(recency).toBeVisible();
await page.setViewportSize({ width: 720, height: 700 });
await expect(scope).toBeVisible();
await expect(recency).toBeVisible();
const recencyWidth = await recency.evaluate(
(element) => element.clientWidth,
);
await page.getByTestId("agent-session-settings-menu-trigger").click();
await page.getByTestId("agent-session-toggle-raw-feed").click();
await expect(scope).toHaveText("Raw ACP activity · All channels");
await expect(recency).toBeVisible();
expect(await recency.evaluate((element) => element.clientWidth)).toBe(
recencyWidth,
);
await page.keyboard.press("Escape");
await waitForAnimations(page);
await panel.screenshot({ path: `${SHOTS}/02-all-channels.png` });
});
});
@@ -0,0 +1,73 @@
import { test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const OUTDIR = "test-results/add-community";
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
const COMMUNITIES = [
{
id: "ws-a",
name: "Alpha",
relayUrl: "ws://localhost:3000",
addedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "ws-b",
name: "Bravo",
relayUrl: "ws://localhost:3001",
addedAt: "2026-01-02T00:00:00.000Z",
},
];
test.beforeEach(async ({ page }) => {
await page.addInitScript((communities) => {
window.localStorage.setItem(
"buzz-communities",
JSON.stringify(communities),
);
window.localStorage.setItem("buzz-active-community-id", communities[0].id);
}, COMMUNITIES);
await installMockBridge(
page,
{
builderlabAuth: {
email: "owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: { pubkey_hex: DEFAULT_MOCK_PUBKEY },
},
{
skipCommunitySeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-rail-add").click();
});
test("capture: add-community choices", async ({ page }) => {
const dialog = page.getByTestId("add-community-dialog");
await dialog.waitFor();
await waitForAnimations(page);
await dialog.screenshot({ path: `${OUTDIR}/01-choices.png` });
});
test("capture: join an existing community", async ({ page }) => {
await page.getByTestId("add-community-join").click();
const dialog = page.getByTestId("add-community-dialog");
const communityUrl = page.getByLabel("Community URL or invite link");
await communityUrl.fill("community.example.com");
await page.getByTestId("community-api-token-reveal").waitFor({
state: "detached",
});
await waitForAnimations(page);
await dialog.screenshot({ path: `${OUTDIR}/02-join.png` });
});
test("capture: create a new community", async ({ page }) => {
await page.getByTestId("add-community-create").click();
const dialog = page.getByTestId("add-community-dialog");
await page.getByLabel("Community address").waitFor();
await waitForAnimations(page);
await dialog.screenshot({ path: `${OUTDIR}/03-create.png` });
});
@@ -0,0 +1,205 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/agent-access-warning";
async function choosePersonaAccess(
page: import("@playwright/test").Page,
optionName: string,
) {
await page.locator("#agent-respond-to").click();
await page.getByRole("menuitemradio", { name: optionName }).click();
}
async function openAgentAccessDialog(
page: import("@playwright/test").Page,
agentPubkey: string,
) {
if (!(await page.getByTestId("members-sidebar").isVisible())) {
await page.getByTestId("channel-general").click();
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
}
const row = page.getByTestId(`sidebar-member-${agentPubkey}`);
const menu = page.getByTestId(`sidebar-member-menu-${agentPubkey}`);
await row.hover();
await menu.focus();
await menu.press("Enter");
await page.getByTestId(`sidebar-edit-respond-to-${agentPubkey}`).click();
await expect(
page.getByRole("dialog", { name: "Manage agent access" }),
).toBeVisible();
}
test("open agent access explains the available access before save", async ({
page,
}) => {
const agent = TEST_IDENTITIES.charlie;
await installMockBridge(page, {
managedAgents: [
{
pubkey: agent.pubkey,
name: "Hack Day Helper",
status: "running",
channelNames: ["general"],
respondTo: "owner-only",
},
],
});
await page.goto("/");
await openAgentAccessDialog(page, agent.pubkey);
const accessSelect = page.getByTestId("agent-respond-to-select");
await expect(accessSelect).toHaveValue("owner-only");
await expect(page.getByTestId("agent-access-warning")).toHaveCount(0);
const saveAccess = page.getByRole("button", { name: "Save access" });
await expect(saveAccess).toBeVisible();
const commandsBeforeSave = await page.evaluate(
() => window.__BUZZ_E2E_COMMAND_LOG__?.length ?? 0,
);
await accessSelect.selectOption("anyone");
const warning = page.getByTestId("agent-access-warning");
await expect(warning).toBeVisible();
await expect(warning).toContainText(
"Anyone can use this agent to access your computer, including files, accounts, and connected tools.",
);
await waitForAnimations(page);
await page
.getByRole("dialog", { name: "Manage agent access" })
.screenshot({ path: `${SHOTS}/open-access-warning.png` });
await saveAccess.click();
await expect(
page.getByRole("dialog", { name: "Manage agent access" }),
).not.toBeVisible();
await expect
.poll(async () =>
page.evaluate((start) => {
const commands = window.__BUZZ_E2E_COMMAND_LOG__ ?? [];
return commands
.slice(start)
.some(
(entry) =>
entry.command === "update_managed_agent" &&
(entry.payload as { input?: { respondTo?: string } })?.input
?.respondTo === "anyone",
);
}, commandsBeforeSave),
)
.toBe(true);
await openAgentAccessDialog(page, agent.pubkey);
await expect(accessSelect).toHaveValue("anyone");
// Selected people narrows the audience but not the access, so the warning
// persists with its own audience phrase.
await accessSelect.selectOption("allowlist");
await expect(warning).toBeVisible();
await expect(warning).toContainText(
"Selected people can use this agent to access your computer, including files, accounts, and connected tools.",
);
const picker = page.getByTestId("agent-respond-to-allowlist");
await expect(
picker.getByText("Selected people", { exact: true }),
).toBeVisible();
// The warning sits below the picker so it never blocks the selection the
// user came here to make.
await waitForAnimations(page);
const pickerBox = await picker.boundingBox();
const warningBox = await warning.boundingBox();
expect(pickerBox?.y).toBeDefined();
expect(warningBox?.y).toBeGreaterThan(pickerBox?.y ?? 0);
await page
.getByRole("dialog", { name: "Manage agent access" })
.screenshot({ path: `${SHOTS}/selected-people-warning.png` });
// Only me shares nothing, so the warning goes away entirely.
await accessSelect.selectOption("owner-only");
await expect(warning).toHaveCount(0);
});
test("a provider-backed agent's warning names the server, not this computer", async ({
page,
}) => {
const agent = TEST_IDENTITIES.charlie;
await installMockBridge(page, {
managedAgents: [
{
pubkey: agent.pubkey,
name: "Remote Helper",
status: "running",
channelNames: ["general"],
respondTo: "owner-only",
backend: { type: "provider", id: "blox", config: {} },
},
],
});
await page.goto("/");
await openAgentAccessDialog(page, agent.pubkey);
await page.getByTestId("agent-respond-to-select").selectOption("anyone");
const warning = page.getByTestId("agent-access-warning");
await expect(warning).toContainText(
"Anyone can use this agent to access the server it runs on, including any accounts and tools available there.",
);
// The local wording must not leak into a remote-backed agent.
await expect(warning).not.toContainText("your computer");
});
test("persona-backed edit warns before saving open access", async ({
page,
}) => {
const agent = TEST_IDENTITIES.tyler;
await installMockBridge(page, {
managedAgents: [
{
pubkey: agent.pubkey,
name: "Tyler Agent",
status: "stopped",
channelNames: ["agents"],
respondTo: "owner-only",
},
],
});
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page.getByRole("button", { name: "Tyler Agent agent profile" }).click();
await page.getByTestId("user-profile-edit-agent").click();
const dialog = page.getByTestId("edit-agent-dialog");
await expect(dialog).toBeVisible();
await expect(page.locator("#agent-respond-to")).toHaveText(
"Only me (default)",
);
await choosePersonaAccess(page, "Anyone");
await expect(dialog.getByTestId("agent-access-warning")).toContainText(
"Anyone can use this agent to access your computer, including files, accounts, and connected tools.",
);
const commandsBeforeSave = await page.evaluate(
() => window.__BUZZ_E2E_COMMAND_LOG__?.length ?? 0,
);
await page.getByTestId("edit-agent-dialog-submit").click();
await expect(dialog).not.toBeVisible();
await expect
.poll(async () =>
page.evaluate((start) => {
const commands = window.__BUZZ_E2E_COMMAND_LOG__ ?? [];
return commands
.slice(start)
.some(
(entry) =>
entry.command === "update_managed_agent" &&
(entry.payload as { input?: { respondTo?: string } })?.input
?.respondTo === "anyone",
);
}, commandsBeforeSave),
)
.toBe(true);
});
@@ -0,0 +1,140 @@
/**
* Screenshot spec for structured agent provider error states (PR #1653).
*
* Exercises the two visible surfaces where friendly error copy appears:
* - Agent card avatar badge (CircleAlert icon + tooltip) for stopped agents
* with a lastError / lastErrorCode.
*
* ManagedAgentRow (StatusBlock text) is also exercised here even though it is
* not yet wired into a reachable route in the main app — it will be connected
* in the follow-up config-bridge PR. We render it in isolation by navigating
* to the agents view and letting the mock bridge expose the row through the
* unified section once that wiring lands; for now we capture the card badges
* which ARE reachable in the current build.
*/
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
const SHOTS = "test-results/pr-1653-screenshots";
// Two stopped agents — one with a structured -32002 code, one with a raw string.
const MODEL_NOT_FOUND_AGENT = {
pubkey: TEST_IDENTITIES.alice.pubkey,
name: "Databricks Agent",
status: "stopped" as const,
lastError:
"Agent reported error (code -32002): llm model not found: (goose-databricks-llama-3-3-70b) 404 Not Found: model not found",
lastErrorCode: -32002,
};
const GENERIC_ERROR_AGENT = {
pubkey: TEST_IDENTITIES.bob.pubkey,
name: "Local Agent",
status: "stopped" as const,
lastError: "harness exited with status 1",
lastErrorCode: null,
};
async function gotoAgentsView(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("open-agents-view")).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId("agents-library-personas")).toBeVisible({
timeout: 10_000,
});
}
test.describe("agent error state screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
});
// Shot 01: agent card with model-not-found error badge (red CircleAlert).
// The badge title shows the friendly structured copy instead of the raw
// JSON error string.
test("01-model-not-found-error-badge", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [MODEL_NOT_FOUND_AGENT],
});
await gotoAgentsView(page);
// Wait for the error badge to appear (stopped agent with error).
const errorBadge = page.getByTestId(
`agent-runtime-error-${MODEL_NOT_FOUND_AGENT.pubkey}`,
);
await expect(errorBadge).toBeVisible({ timeout: 10_000 });
await waitForAnimations(page);
// Capture the agent card element.
const agentCard = page.getByTestId(
`managed-agent-${MODEL_NOT_FOUND_AGENT.pubkey}`,
);
await agentCard.screenshot({
path: `${SHOTS}/01-model-not-found-error-badge.png`,
});
});
// Shot 02: agent card with generic (unclassified) error badge.
// The badge is present but the tooltip shows the raw exit string, not
// structured copy — demonstrating the error is still surfaced for any
// harness exit.
test("02-generic-error-badge", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [GENERIC_ERROR_AGENT],
});
await gotoAgentsView(page);
const errorBadge = page.getByTestId(
`agent-runtime-error-${GENERIC_ERROR_AGENT.pubkey}`,
);
await expect(errorBadge).toBeVisible({ timeout: 10_000 });
await waitForAnimations(page);
const agentCard = page.getByTestId(
`managed-agent-${GENERIC_ERROR_AGENT.pubkey}`,
);
await agentCard.screenshot({
path: `${SHOTS}/02-generic-error-badge.png`,
});
});
// Shot 03: side-by-side — both agents in the same view so the reviewer can
// see error badges on all stopped agents in a real usage context.
test("03-agents-section-both-errors", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [MODEL_NOT_FOUND_AGENT, GENERIC_ERROR_AGENT],
});
await gotoAgentsView(page);
// Wait for both error badges to be present.
await expect(
page.getByTestId(`agent-runtime-error-${MODEL_NOT_FOUND_AGENT.pubkey}`),
).toBeVisible({ timeout: 10_000 });
await expect(
page.getByTestId(`agent-runtime-error-${GENERIC_ERROR_AGENT.pubkey}`),
).toBeVisible();
await waitForAnimations(page);
// Capture the full agents section (scroll-bounded crop to the section).
const section = page.getByTestId("agents-library-personas");
await section.screenshot({
path: `${SHOTS}/03-agents-section-both-errors.png`,
});
});
});
@@ -0,0 +1,395 @@
/**
* E2E screenshots + regression tests for agent-lifecycle feedback (PR #1766):
*
* 1. Persona delete confirm dialog shows "Also deletes N agent instance(s)."
* when the persona has linked managed-agent instances.
* 2. Global-config save reports "Saved. Restarted N agents." when running agents
* were restarted.
* 3. Global-config save reports plain "Saved." when no agents were restarted;
* the old "Running agents keep their current settings…" text is gone.
*/
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/screenshots-lifecycle";
// Persona ID used across the cascade-delete test. Must be a custom (non-builtin)
// persona so the actions menu renders the Delete path.
const CASCADE_PERSONA_ID = "custom:test-cascade";
// Stable fake pubkeys for the two seeded managed agents. 64 lowercase hex chars
// that don't collide with TEST_IDENTITIES pubkeys.
const CASCADE_AGENT_A_PUBKEY = "aa".repeat(32);
const CASCADE_AGENT_B_PUBKEY = "bb".repeat(32);
/**
* Navigate to the Agents view and wait for its unified list to mount.
*/
async function openAgentsView(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId("unified-agents-groups")).toBeVisible({
timeout: 10_000,
});
}
async function openAiDefaultsSettings(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
await page.getByTestId("settings-nav-agents").click();
await expect(page.getByTestId("settings-global-agent-config")).toBeVisible({
timeout: 10_000,
});
await expect(page.locator(".animate-spin").first()).not.toBeVisible({
timeout: 5_000,
});
}
async function makeGlobalDefaultsDirty(
page: import("@playwright/test").Page,
effort = "high",
) {
// The defaults editor renders the app's styled dropdown (not a native
// <select>): open the trigger, then click the option row.
await page.getByTestId("global-agent-thinking-effort-select").click();
await page
.getByTestId(`global-agent-thinking-effort-select-option-${effort}`)
.click();
}
test.describe("agent lifecycle feedback screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
});
// Shot 01: persona delete confirm dialog — "Also deletes 2 agent instance(s)."
// Seeds a custom persona with two linked managed agents so instanceCount = 2.
// Triggers the delete confirm from the persona's "..." actions menu.
test("01-delete-cascade-copy", async ({ page }) => {
await installMockBridge(page, {
personas: [
{
id: CASCADE_PERSONA_ID,
displayName: "Cascade Test Agent",
systemPrompt: "A test persona for cascade delete E2E coverage.",
isActive: true,
},
],
managedAgents: [
{
pubkey: CASCADE_AGENT_A_PUBKEY,
name: "Cascade Instance A",
personaId: CASCADE_PERSONA_ID,
status: "stopped",
},
{
pubkey: CASCADE_AGENT_B_PUBKEY,
name: "Cascade Instance B",
personaId: CASCADE_PERSONA_ID,
status: "running",
},
],
});
await openAgentsView(page);
// The custom persona card appears in the library.
await expect(
page.getByText("Cascade Test Agent", { exact: true }),
).toBeVisible({ timeout: 10_000 });
// Open the actions menu for the custom persona. The trigger button carries
// an aria-label derived from the persona displayName.
await page
.getByRole("button", { name: "Open actions for Cascade Test Agent" })
.click();
// For a custom (non-builtin) persona, Delete opens PersonaDeleteDialog.
await page.getByRole("menuitem", { name: "Delete" }).click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Core assertion: the cascade copy shows the correct instance count
// (plural) and discloses the relay-side archival (PR #2135).
await expect(dialog).toContainText(
"Also deletes 2 agent instances and archives their identities on the relay",
);
await waitForAnimations(page);
await dialog.screenshot({
path: `${SHOTS}/01-delete-cascade-copy.png`,
});
});
// Shot 02: global config save with restarts — "Saved. Restarted 2 agents."
// The mock is configured to return restarted_count=2 so the card shows the
// restart-count feedback.
test("02-save-restarted", async ({ page }) => {
await installMockBridge(page, {
globalAgentConfig: {
provider: "anthropic",
model: "claude-opus-4-5",
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
},
globalConfigRestartedCount: 2,
});
await openAiDefaultsSettings(page);
const card = page.getByTestId("settings-global-agent-config");
// Change the provider to make the card dirty (enables "Save defaults").
await makeGlobalDefaultsDirty(page);
await expect(
page.getByRole("button", { name: "Save defaults" }),
).toBeEnabled({ timeout: 5_000 });
await page.getByRole("button", { name: "Save defaults" }).click();
// Core assertion: the restart-count feedback is visible.
await expect(card.getByText("Saved. Restarted 2 agents.")).toBeVisible({
timeout: 5_000,
});
await waitForAnimations(page);
await card.screenshot({
path: `${SHOTS}/02-save-restarted.png`,
});
});
// Shot 03: global config save with no restarts — plain "Saved."
// The default mock returns restarted_count=0. The old text
// "Running agents keep their current settings until restarted." must be absent.
test("03-save-plain", async ({ page }) => {
await installMockBridge(page, {
globalAgentConfig: {
provider: "anthropic",
model: "claude-opus-4-5",
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
},
});
await openAiDefaultsSettings(page);
const card = page.getByTestId("settings-global-agent-config");
// Change the provider to make the card dirty.
await makeGlobalDefaultsDirty(page);
await expect(
page.getByRole("button", { name: "Save defaults" }),
).toBeEnabled({ timeout: 5_000 });
await page.getByRole("button", { name: "Save defaults" }).click();
// Core assertion: plain "Saved." with no restart count.
// exact: true prevents matching "Saved. Restarted N agents." as a substring.
await expect(card.getByText("Saved.", { exact: true })).toBeVisible({
timeout: 5_000,
});
// Regression guard: the old stale-env message must not appear.
await expect(
card.getByText("Running agents keep their current settings"),
).not.toBeVisible();
await waitForAnimations(page);
await card.screenshot({
path: `${SHOTS}/03-save-plain.png`,
});
});
// Shot 04: persona delete confirm dialog — singular "Also deletes 1 agent instance."
// One linked instance → singular copy (no extra "s").
test("04-delete-cascade-singular", async ({ page }) => {
await installMockBridge(page, {
personas: [
{
id: CASCADE_PERSONA_ID,
displayName: "Cascade Test Agent",
systemPrompt: "A test persona.",
isActive: true,
},
],
managedAgents: [
{
pubkey: CASCADE_AGENT_A_PUBKEY,
name: "Cascade Instance A",
personaId: CASCADE_PERSONA_ID,
status: "stopped",
},
],
});
await openAgentsView(page);
await expect(
page.getByText("Cascade Test Agent", { exact: true }),
).toBeVisible({ timeout: 10_000 });
await page
.getByRole("button", { name: "Open actions for Cascade Test Agent" })
.click();
await page.getByRole("menuitem", { name: "Delete" }).click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Singular copy ("instance", "its identity") plus the archival disclosure.
await expect(dialog).toContainText(
"Also deletes 1 agent instance and archives its identity on the relay",
);
await waitForAnimations(page);
await dialog.screenshot({
path: `${SHOTS}/04-delete-cascade-singular.png`,
});
});
// Shot 05: persona delete confirm dialog — zero linked instances.
// No managed agents linked to the persona → "Also deletes…" line absent.
test("05-delete-cascade-zero-instances", async ({ page }) => {
await installMockBridge(page, {
personas: [
{
id: CASCADE_PERSONA_ID,
displayName: "Cascade Test Agent",
systemPrompt: "A test persona.",
isActive: true,
},
],
managedAgents: [],
});
await openAgentsView(page);
await expect(
page.getByText("Cascade Test Agent", { exact: true }),
).toBeVisible({ timeout: 10_000 });
await page
.getByRole("button", { name: "Open actions for Cascade Test Agent" })
.click();
await page.getByRole("menuitem", { name: "Delete" }).click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
// No linked instances → no cascade warning.
await expect(dialog).not.toContainText("Also deletes");
});
// Shot 06: global config save — singular "Saved. Restarted 1 agent."
test("06-save-restarted-singular", async ({ page }) => {
await installMockBridge(page, {
globalAgentConfig: {
provider: "anthropic",
model: "claude-opus-4-5",
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
},
globalConfigRestartedCount: 1,
});
await openAiDefaultsSettings(page);
const card = page.getByTestId("settings-global-agent-config");
await makeGlobalDefaultsDirty(page);
await expect(
page.getByRole("button", { name: "Save defaults" }),
).toBeEnabled({ timeout: 5_000 });
await page.getByRole("button", { name: "Save defaults" }).click();
// Singular copy: "Restarted 1 agent." (not "agents.").
await expect(card.getByText("Saved. Restarted 1 agent.")).toBeVisible({
timeout: 5_000,
});
});
// Shot 07: global config save — partial failure "M couldn't restart".
test("07-save-failed-restart", async ({ page }) => {
await installMockBridge(page, {
globalAgentConfig: {
provider: "anthropic",
model: "claude-opus-4-5",
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
},
globalConfigFailedRestartCount: 1,
});
await openAiDefaultsSettings(page);
const card = page.getByTestId("settings-global-agent-config");
await makeGlobalDefaultsDirty(page);
await expect(
page.getByRole("button", { name: "Save defaults" }),
).toBeEnabled({ timeout: 5_000 });
await page.getByRole("button", { name: "Save defaults" }).click();
// Partial failure copy (zero restarted): singular agent + Agents page prompt.
await expect(
card.getByText(
"Saved. 1 agent couldn't restart — check the Agents page.",
),
).toBeVisible({ timeout: 5_000 });
await waitForAnimations(page);
await card.screenshot({
path: `${SHOTS}/07-save-failed-restart.png`,
});
});
// Shot 08: an edit made while a save is in flight must survive the save
// resolving — the card keeps the newer value and stays dirty instead of
// clobbering it with the older response.
test("08-save-race-keeps-newer-edit", async ({ page }) => {
await installMockBridge(page, {
globalAgentConfig: {
provider: "anthropic",
model: "claude-opus-4-5",
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
},
globalConfigSaveDelayMs: 2_000,
});
await openAiDefaultsSettings(page);
const card = page.getByTestId("settings-global-agent-config");
const effort = page.getByTestId("global-agent-thinking-effort-select");
const saveButton = page.getByRole("button", { name: "Save defaults" });
await makeGlobalDefaultsDirty(page, "high");
await expect(saveButton).toBeEnabled({ timeout: 5_000 });
await saveButton.click();
// While the save is held open by the mock delay, make a newer edit.
await makeGlobalDefaultsDirty(page, "low");
// The save resolves with the OLD submitted config; the newer edit must
// survive and the card must stay dirty so it can be saved again.
await expect(card.getByText("Saved.", { exact: true })).toBeVisible({
timeout: 10_000,
});
await expect(effort).toHaveAttribute("data-value", "low");
await expect(saveButton).toBeEnabled();
});
});
@@ -0,0 +1,372 @@
/**
* Playwright regression tests for the numeric tuning fields (max output tokens,
* context limit, max rounds) on both the global Agent Defaults surface and the
* per-agent Advanced section.
*
* Covers:
* 1. Global defaults Advanced shows numeric inputs for buzz-agent.
* 2. Global defaults Advanced hides numeric inputs for non-capable runtimes.
* 3. Per-agent Goose: saving a max-tokens value globally surfaces as
* Inherit (<value>) placeholder in the per-agent edit dialog.
* 4. Delayed catalog: while loading, saved tuning env vars stay visible
* as generic rows (not silently dropped); structured controls appear
* once the catalog settles.
* 5. Failed catalog: when discovery errors, saved tuning env vars remain
* visible as generic rows (never the "unsupported" empty state).
*/
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
// ── Helpers ────────────────────────────────────────────────────────────────
async function openAiDefaultsSettings(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
await page.getByTestId("settings-nav-agents").click();
await expect(page.getByTestId("settings-global-agent-config")).toBeVisible({
timeout: 10_000,
});
await expect(page.locator(".animate-spin").first()).not.toBeVisible({
timeout: 5_000,
});
}
async function openEditAgentDialog(
page: import("@playwright/test").Page,
agentName: string,
) {
await page.goto("/");
await page.getByTestId("open-agents-view").click();
const agentButton = page.getByRole("button", {
name: `${agentName} agent profile`,
});
await expect(agentButton).toBeVisible({ timeout: 10_000 });
await agentButton.click();
await expect(page.getByTestId("user-profile-panel")).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("user-profile-edit-agent").click();
await expect(page.getByTestId("edit-agent-dialog")).toBeVisible({
timeout: 10_000,
});
}
// ── Tests ──────────────────────────────────────────────────────────────────
test("global_advanced_buzz_agent_shows_all_numeric_controls", async ({
page,
}) => {
// The mock bridge's withMockRuntimeConfigMetadata injects the numeric env var
// fields for buzz-agent. When buzz-agent is selected and Advanced is opened,
// all three numeric inputs must be visible.
await installMockBridge(page, {
acpRuntimesCatalog: [
{
id: "buzz-agent",
label: "Buzz Agent",
avatar_url: "",
availability: "available",
command: "buzz-agent",
binary_path: "/usr/local/bin/buzz-agent",
default_args: [],
mcp_command: null,
install_hint: "Ships with the Buzz desktop app.",
install_instructions_url: "https://github.com/block/buzz",
can_auto_install: false,
underlying_cli_path: null,
auth_status: { status: "not_applicable" },
},
],
globalAgentConfig: {
env_vars: {},
provider: "anthropic",
model: null,
preferred_runtime: "buzz-agent",
},
});
await openAiDefaultsSettings(page);
// Open the Advanced section. The settings card uses disclosure="full" (no
// animation wrapper), so we click the toggle and wait for content directly.
await page.getByTestId("global-agent-advanced-toggle").click();
// All three numeric inputs must be present for buzz-agent.
await expect(page.getByTestId("numeric-max-output-tokens-input")).toBeVisible(
{ timeout: 5_000 },
);
await expect(page.getByTestId("numeric-context-limit-input")).toBeVisible();
await expect(page.getByTestId("numeric-max-rounds-input")).toBeVisible();
});
test("global_advanced_non_capable_runtime_hides_numeric_controls", async ({
page,
}) => {
// Claude has no numeric tuning env vars (contextLimitEnvVar = null etc.).
// After selecting Claude, the Advanced section must show no numeric inputs.
await installMockBridge(page, {
acpRuntimesCatalog: [
{
id: "claude",
label: "Claude Code",
avatar_url: "",
availability: "available",
command: "/usr/local/bin/claude-agent",
binary_path: "/usr/local/bin/claude-agent",
default_args: ["acp"],
mcp_command: null,
install_hint: "Install via npm.",
install_instructions_url: "https://example.com",
can_auto_install: true,
underlying_cli_path: "/usr/local/bin/claude",
auth_status: { status: "logged_in" },
},
],
globalAgentConfig: {
env_vars: {},
provider: null,
model: null,
preferred_runtime: "claude",
},
});
await openAiDefaultsSettings(page);
await page.getByTestId("global-agent-advanced-toggle").click();
// No numeric inputs must render for a non-capable runtime.
await expect(page.getByTestId("numeric-max-output-tokens-input")).toHaveCount(
0,
);
await expect(page.getByTestId("numeric-context-limit-input")).toHaveCount(0);
await expect(page.getByTestId("numeric-max-rounds-input")).toHaveCount(0);
});
test("goose_per_agent_advanced_max_tokens_shows_inherited_global_placeholder", async ({
page,
}) => {
// Save GOOSE_MAX_TOKENS = 16384 in the global Agent Defaults settings via
// the UI, then open a Goose agent's edit dialog. The max-output-tokens input
// must show "Inherit (16384)" — the globally-saved value surfaced via the
// inherited placeholder.
await installMockBridge(page, {
globalAgentConfig: {
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test-key" },
provider: "anthropic",
model: "claude-opus-4-5",
preferred_runtime: "goose",
},
managedAgents: [
{
pubkey: TEST_IDENTITIES.tyler.pubkey,
name: "Tyler Agent",
runtime: "goose",
status: "stopped",
channelNames: ["agents"],
},
],
});
// Step 1: open global defaults, expand Advanced, enter the max-tokens value.
await openAiDefaultsSettings(page);
await page.getByTestId("global-agent-advanced-toggle").click();
await expect(page.getByTestId("numeric-max-output-tokens-input")).toBeVisible(
{ timeout: 5_000 },
);
await page.getByTestId("numeric-max-output-tokens-input").click();
await page
.getByTestId("numeric-max-output-tokens-input")
.pressSequentially("16384");
// Blur to ensure React's change event fires for the number input.
await page.keyboard.press("Tab");
// Step 2: save the global defaults.
await expect(page.getByRole("button", { name: "Save defaults" })).toBeEnabled(
{ timeout: 5_000 },
);
await page.getByRole("button", { name: "Save defaults" }).click();
// Wait for the save to complete: the button returns to disabled (dirty resets).
await expect(
page.getByRole("button", { name: "Save defaults" }),
).toBeDisabled({ timeout: 5_000 });
// Step 3: navigate back and open the per-agent edit dialog for the Goose
// agent. We use the app's Back link rather than page.goto("/") to preserve
// the in-memory mock state (page.goto causes a full reload that resets it).
await page.getByRole("button", { name: "Back to app" }).click();
await page.getByTestId("open-agents-view").click();
const agentButton = page.getByRole("button", {
name: "Tyler Agent agent profile",
});
await expect(agentButton).toBeVisible({ timeout: 10_000 });
await agentButton.click();
await expect(page.getByTestId("user-profile-panel")).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("user-profile-edit-agent").click();
await expect(page.getByTestId("edit-agent-dialog")).toBeVisible({
timeout: 10_000,
});
// Wait for the provider field — signals the catalog and dialog have settled.
await expect(page.locator("#edit-agent-llm-provider")).toBeVisible({
timeout: 10_000,
});
// Open the Advanced section.
await page.getByRole("button", { name: "Advanced", exact: true }).click();
await expect(page.getByTestId("numeric-max-output-tokens-input")).toBeVisible(
{ timeout: 5_000 },
);
// The placeholder must reflect the globally-saved value.
await expect(
page.getByTestId("numeric-max-output-tokens-input"),
).toHaveAttribute("placeholder", "Inherit (16384)");
});
test("delayed_catalog_per_agent_saved_tuning_values_visible_then_structured_controls_appear", async ({
page,
}) => {
// Scenario: catalog takes 5 seconds to respond (simulates slow discovery).
// The per-agent edit dialog opens. While the catalog is still in flight,
// saved tuning env vars must not be dropped from view — they appear as
// generic env rows (no hiddenKeys applied yet). Once the catalog settles,
// the structured numeric controls replace the generic rows.
await installMockBridge(page, {
acpRuntimesCatalog: [
{
id: "buzz-agent",
label: "Buzz Agent",
avatar_url: "",
availability: "available",
command: "buzz-agent",
binary_path: "/usr/local/bin/buzz-agent",
default_args: [],
mcp_command: null,
install_hint: "Ships with the Buzz desktop app.",
install_instructions_url: "https://github.com/block/buzz",
can_auto_install: false,
underlying_cli_path: null,
auth_status: { status: "not_applicable" },
},
],
// 5-second delay: generous enough that the dialog opens and Advanced is
// expanded while the catalog query is still in-flight (navigation takes
// ~1-2 s), but short enough to keep the test under 30 s.
acpRuntimesDelayMs: 5000,
globalAgentConfig: {
env_vars: {},
provider: "anthropic",
model: null,
preferred_runtime: "buzz-agent",
},
managedAgents: [
{
pubkey: TEST_IDENTITIES.tyler.pubkey,
name: "Tyler Agent",
runtime: "buzz-agent",
status: "stopped",
channelNames: ["agents"],
envVars: {
BUZZ_AGENT_MAX_OUTPUT_TOKENS: "4096",
BUZZ_AGENT_MAX_ROUNDS: "25",
},
},
],
});
await openEditAgentDialog(page, "Tyler Agent");
// Open Advanced before the catalog has settled (the dialog opens quickly;
// the catalog query fires when the dialog opens and takes ~5 seconds).
await page.getByRole("button", { name: "Advanced", exact: true }).click();
// While loading: structured numeric controls must NOT be visible yet —
// the catalog-settling gate withholds them.
await expect(page.getByTestId("numeric-max-output-tokens-input")).toHaveCount(
0,
);
// The saved tuning env vars must be visible as generic rows (not hidden)
// while the catalog hasn't settled: BUZZ_AGENT_MAX_OUTPUT_TOKENS and
// BUZZ_AGENT_MAX_ROUNDS should appear in the env-vars editor.
await expect(
page.locator(
'input[data-testid="env-vars-key"][value="BUZZ_AGENT_MAX_OUTPUT_TOKENS"]',
),
).toBeVisible();
await expect(
page.locator(
'input[data-testid="env-vars-key"][value="BUZZ_AGENT_MAX_ROUNDS"]',
),
).toBeVisible();
// After the catalog settles (allow up to 8 s — 5 s delay + margin):
// structured controls appear, replacing the generic rows.
await expect(page.getByTestId("numeric-max-output-tokens-input")).toBeVisible(
{ timeout: 8_000 },
);
await expect(page.getByTestId("numeric-max-rounds-input")).toBeVisible({
timeout: 8_000,
});
});
test("failed_catalog_per_agent_saved_tuning_values_remain_visible_as_generic_rows", async ({
page,
}) => {
// Scenario: catalog discovery fails (network error / IPC rejection).
// The per-agent edit dialog opens. The saved tuning env vars must remain
// visible as generic rows — the error state must never produce the
// "unsupported" no-controls state that would hide persisted values.
await installMockBridge(page, {
acpRuntimesError: true,
globalAgentConfig: {
env_vars: {},
provider: "anthropic",
model: null,
preferred_runtime: "buzz-agent",
},
managedAgents: [
{
pubkey: TEST_IDENTITIES.tyler.pubkey,
name: "Tyler Agent",
runtime: "buzz-agent",
status: "stopped",
channelNames: ["agents"],
envVars: {
BUZZ_AGENT_MAX_OUTPUT_TOKENS: "8192",
BUZZ_AGENT_MAX_ROUNDS: "10",
},
},
],
});
await openEditAgentDialog(page, "Tyler Agent");
// Open Advanced after a brief wait (query has had time to fail).
await page.waitForTimeout(500);
await page.getByRole("button", { name: "Advanced", exact: true }).click();
// Structured numeric controls must NOT render (catalog errored — no runtime).
await expect(page.getByTestId("numeric-max-output-tokens-input")).toHaveCount(
0,
);
// Saved tuning values must still be visible as generic env rows — the error
// state must never hide persisted values with no editor to replace them.
await expect(
page.locator(
'input[data-testid="env-vars-key"][value="BUZZ_AGENT_MAX_OUTPUT_TOKENS"]',
),
).toBeVisible({ timeout: 5_000 });
await expect(
page.locator(
'input[data-testid="env-vars-key"][value="BUZZ_AGENT_MAX_ROUNDS"]',
),
).toBeVisible();
});
@@ -0,0 +1,255 @@
/**
* Screenshot-regression spec for PR #1764: provider/model dropdown fixes.
*
* Three states that previously regressed on fresh OSS installs:
*
* 01 Global agent config provider select renders both Databricks v1 and v2
* options (they are always shown on OSS builds; the mock bridge returns an
* empty baked env, simulating an OSS install with no BUZZ_AGENT_PROVIDER).
*
* 02 Effort dropdown shows "Default (medium)" instead of bare "Inherit" when
* no effort is baked and the provider uses a known default effort level.
* (provider unset → effortDefault = "medium" → inheritFallbackLabel fires)
*
* 03 Edit dialog for a definition with runtime null auto-seeds the app-default
* runtime (buzz-agent) and model discovery runs → model combobox is non-empty.
* Previously, the seeding effect bailed in edit mode, leaving the runtime
* empty and the model dropdown silently blank.
*/
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
const SHOTS = "test-results/screenshots-dialogs";
/**
* Open Settings → Agents through the app UI and wait for the defaults card to
* finish loading. The CI static server does not provide SPA fallbacks for a
* direct `/settings` request.
*/
async function openAiDefaultsSettings(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
await page.getByTestId("settings-nav-agents").click();
await expect(page.getByTestId("settings-global-agent-config")).toBeVisible({
timeout: 10_000,
});
// The card shows a spinner during the async load effect; wait for it to clear.
await expect(page.locator(".animate-spin").first()).not.toBeVisible({
timeout: 5_000,
});
}
test.describe("agent provider dropdown screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
});
// Shot 01: OSS provider dropdown includes both Databricks v1 and v2.
//
// The mock bridge returns get_baked_build_env_keys = [] (OSS), so no
// BUZZ_AGENT_PROVIDER is baked and hideProviderIds is empty → v1 appears.
test("01-provider-dropdown-oss", async ({ page }) => {
await installMockBridge(page);
await openAiDefaultsSettings(page);
const providerSelect = page.getByTestId("global-agent-provider");
await expect(providerSelect).toBeVisible({ timeout: 5_000 });
// Regression: both v1 and v2 must be present on OSS. The defaults editor
// renders the app's styled dropdown, so open it and assert option rows.
await providerSelect.click();
await expect(
page.getByTestId("global-agent-provider-option-databricks"),
).toHaveText("Databricks");
await expect(
page.getByTestId("global-agent-provider-option-databricks_v2"),
).toHaveText("Databricks v2");
await page.keyboard.press("Escape");
await expect(
page.getByTestId("global-agent-provider-option-databricks"),
).toHaveCount(0);
await waitForAnimations(page);
// Screenshot the card — provider select is closed but the assertion above
// proves both Databricks options are present in the option list.
await page
.getByTestId("settings-global-agent-config")
.screenshot({ path: `${SHOTS}/01-provider-dropdown-oss.png` });
});
// Shot 02: Effort dropdown shows "Default (medium)" (no baked effort,
// provider=databricks_v2 with no model → effortDefault = "medium").
//
// getProviderEffortConfig("databricks_v2", "") hits the blank-model branch:
// → { defaultValue: "medium" }
// inheritFallbackLabel = "Default (medium)", bakedEffort = null (OSS)
// → the zero-value option reads "Default (medium)" instead of bare "Inherit".
//
// Using databricks_v2 here (rather than no provider) makes this card
// visually distinct from shot 01 — the provider select shows "Databricks v2"
// as the selected value — so the two PNG hashes differ.
test("02-effort-default-label", async ({ page }) => {
await installMockBridge(page, {
globalAgentConfig: {
provider: "databricks_v2",
model: null,
env_vars: {},
},
});
await openAiDefaultsSettings(page);
const effortSelect = page.getByTestId(
"global-agent-thinking-effort-select",
);
await expect(effortSelect).toBeVisible({ timeout: 5_000 });
// Regression: the zero-value option must show the provider's default
// effort rather than a bare "Inherit". The styled dropdown renders the
// zero value as the closed trigger's label (nothing is persisted) and as
// the "empty" option row when opened.
await expect(effortSelect).toHaveText("Default (medium)");
await effortSelect.click();
await expect(
page.getByTestId("global-agent-thinking-effort-select-option-empty"),
).toHaveText("Default (medium)");
await page.keyboard.press("Escape");
await expect(
page.getByTestId("global-agent-thinking-effort-select-option-empty"),
).toHaveCount(0);
await waitForAnimations(page);
await page
.getByTestId("settings-global-agent-config")
.screenshot({ path: `${SHOTS}/02-effort-default-label.png` });
});
// Shot 03: Edit dialog for a definition with null runtime auto-seeds the
// default runtime (buzz-agent via getDefaultPersonaRuntime) and model
// discovery runs, producing a non-empty model combobox.
//
// Previously the seeding effect bailed in edit mode ("id" in initialValues),
// leaving runtime = "" → modelFieldVisible = false → discovery never ran →
// the model dropdown was silently empty.
test("03-builtin-edit-runtime-seeded", async ({ page }) => {
await installMockBridge(page, {
personas: [
{
displayName: "Null Runtime Agent",
systemPrompt: "An agent with no runtime configured.",
// runtime/provider/model not set → all null in the mock, so the
// edit dialog must auto-seed the app default runtime.
},
],
});
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId("agents-library-personas")).toBeVisible({
timeout: 10_000,
});
// Open the persona's actions menu (visible for non-builtin personas).
const actionsBtn = page.getByRole("button", {
name: "Open actions for Null Runtime Agent",
});
await expect(actionsBtn).toBeVisible({ timeout: 8_000 });
await actionsBtn.click();
await page.getByRole("menuitem", { name: "Edit" }).click();
const dialog = page.getByTestId("persona-dialog");
await expect(dialog).toBeVisible({ timeout: 10_000 });
await dialog.getByRole("tab", { name: "Customize for this agent" }).click();
// Regression: the runtime trigger must not be empty — the auto-seed effect
// must have run and selected the app default (buzz-agent in the mock catalog).
const runtimeTrigger = dialog.locator("#persona-runtime");
await expect(runtimeTrigger).toBeVisible({ timeout: 8_000 });
await expect(runtimeTrigger).not.toContainText("No preference", {
timeout: 8_000,
});
// Regression: the model combobox must appear (modelFieldVisible = true once
// runtime is non-empty) and model discovery must have run, populating it.
const modelCombobox = dialog.getByRole("combobox", { name: /model/i });
await expect(modelCombobox).toBeVisible({ timeout: 8_000 });
// Open the picker and assert that a known model from the mock discovery
// response is listed. The mock returns "Claude Opus 4.6" for all providers
// (see discover_agent_models in e2eBridge.ts). A zero-model discovery
// regression would leave the list empty and this assertion would fail.
// The picker is a searchable command popover portaled outside the dialog
// whose items render as buttons — query at page level.
await modelCombobox.click();
await expect(
page.getByRole("button", { name: /Claude Opus 4\.6/i }),
).toBeVisible({ timeout: 5_000 });
// Close the popover so the screenshot captures the dialog's resting state.
await page.keyboard.press("Escape");
await expect(
page.getByRole("button", { name: /Claude Opus 4\.6/i }),
).toBeHidden();
await waitForAnimations(page);
await dialog.screenshot({
path: `${SHOTS}/03-builtin-edit-runtime-seeded.png`,
});
});
test("04-codex-definition-exposes-model-without-global-provider-defaults", async ({
page,
}) => {
await installMockBridge(page, {
globalAgentConfig: {
provider: "databricks_v2",
model: "global-databricks-model",
env_vars: {},
},
personas: [
{
displayName: "Codex Definition",
systemPrompt: "A Codex-backed definition.",
runtime: "codex",
},
],
});
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page
.getByRole("button", { name: "Open actions for Codex Definition" })
.click();
await page.getByRole("menuitem", { name: "Edit" }).click();
const dialog = page.getByTestId("persona-dialog");
await expect(dialog).toBeVisible({ timeout: 10_000 });
await expect(
dialog.getByRole("tab", { name: "Customize for this agent" }),
).toBeVisible();
await expect(
dialog.getByText("Harness default", { exact: true }),
).toBeVisible();
await expect(dialog.getByText(/Databricks/i)).toHaveCount(0);
await dialog.getByRole("tab", { name: "Customize for this agent" }).click();
await expect(
dialog.getByRole("combobox", { name: /model/i }),
).toBeVisible();
await expect(dialog.getByText("Model changes apply only")).toHaveCount(0);
});
});
@@ -0,0 +1,342 @@
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/agent-readiness";
// An existing goose-runtime managed agent for the Edit-dialog shot.
// Tyler's pubkey maps to gooseSurface in the mock bridge (runtimeId: "goose"),
// which supports LLM provider selection — the edit dialog's provider/model
// pickers render for it just as they do for buzz-agent.
const EDIT_AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
/**
* Navigate to the agents view and open the unified agent-create dialog via
* the "New agent" menu item, then fill a placeholder name.
*/
async function openCreateDialog(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.locator("#persona-display-name").fill("Test Agent");
}
/**
* Pick an option from a PersonaDropdownField (menu-based, not a native
* select): open the trigger, click the matching menuitemradio.
*/
async function selectDropdownOption(
page: import("@playwright/test").Page,
trigger: import("@playwright/test").Locator,
optionName: string | RegExp,
) {
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.press("Enter");
await page
.getByRole("menuitemradio", { name: optionName })
.click({ timeout: 5_000 });
}
/**
* Wait for the LLM provider field to become visible (buzz-agent
* auto-selected) then select the given provider option.
*/
async function selectProvider(
page: import("@playwright/test").Page,
providerName: string,
) {
await page.getByRole("tab", { name: "Customize for this agent" }).click();
await selectDropdownOption(
page,
page.locator("#persona-llm-provider"),
providerName,
);
}
/**
* Choose "Custom model..." from the model combobox and fill a custom model id.
*/
async function setCustomModel(
page: import("@playwright/test").Page,
modelId: string,
) {
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("discover_agent_models");
await page.locator("#persona-model").click();
await page
.getByRole("button", { name: "Custom model...", exact: true })
.click();
await page.getByLabel("Custom model ID").fill(modelId);
}
/**
* Open the Edit Agent dialog for a seeded managed agent.
* Opens the agents view, clicks the agent card to open the profile panel,
* then clicks the Edit quick-action button.
*/
async function openEditDialog(
page: import("@playwright/test").Page,
agentName: string,
) {
await page.goto("/");
await page.getByTestId("open-agents-view").click();
const agentButton = page.getByRole("button", {
name: `${agentName} agent profile`,
});
await expect(agentButton).toBeVisible({ timeout: 10_000 });
await agentButton.click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible({ timeout: 10_000 });
await page.getByTestId("user-profile-edit-agent").click();
// Wait for the Edit dialog's LLM provider field (goose runtime supports it).
// The Edit dialog renders provider selection via PersonaDropdownField, whose
// trigger button carries this id.
await expect(page.locator("#edit-agent-llm-provider")).toBeVisible({
timeout: 10_000,
});
}
// Settle any in-flight CSS / Web Animations before capture. allSettled, not
// all: a dropdown-menu close cancels its animation, which rejects `finished`.
async function settleAnimations(page: import("@playwright/test").Page) {
await page.evaluate(() =>
Promise.allSettled(document.getAnimations().map((a) => a.finished)),
);
}
test.describe("agent readiness gate screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
});
// Shot 01: inherited agent defaults are an explicit, valid choice. Provider and
// model controls stay hidden until the user chooses to customize this agent.
test("01-create-buzzagent-uses-ai-defaults", async ({ page }) => {
await installMockBridge(page, {
globalAgentConfig: {
provider: "anthropic",
model: "claude-opus-4-5",
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
},
});
await openCreateDialog(page);
await expect(
page.getByRole("tab", { name: "Use agent defaults" }),
).toHaveAttribute("data-state", "active");
await expect(page.locator("#persona-llm-provider")).not.toBeVisible();
await expect(page.locator("#persona-model")).not.toBeVisible();
await expect(page.getByTestId("persona-dialog-submit")).toBeEnabled({
timeout: 10_000,
});
await settleAnimations(page);
await page.getByRole("dialog").screenshot({
path: `${SHOTS}/01-create-buzzagent-uses-ai-defaults.png`,
});
});
// Shot 02: customized Anthropic configuration uses Automatic model instead
// of presenting an obsolete model-required error.
test("02-create-buzzagent-automatic-model", async ({ page }) => {
await installMockBridge(page);
await openCreateDialog(page);
await selectProvider(page, "Buzz shared compute");
await expect(page.locator("#persona-model")).toContainText("Automatic");
await expect(page.getByTestId("persona-dialog-submit")).toBeEnabled();
await settleAnimations(page);
await page.getByRole("dialog").screenshot({
path: `${SHOTS}/02-create-buzzagent-automatic-model.png`,
});
});
test("03-create-missing-credential-shows-top-level-required-field", async ({
page,
}) => {
await installMockBridge(page);
await openCreateDialog(page);
await selectProvider(page, "Anthropic");
await setCustomModel(page, "claude-opus-4-5");
await expect(page.getByLabel("Anthropic API Key")).toBeVisible();
await expect(page.getByTestId("persona-dialog-submit")).toBeDisabled({
timeout: 10_000,
});
await expect(
page.getByRole("button", { name: "Advanced", exact: true }),
).toHaveAttribute("aria-expanded", "false");
const createCountBefore = await page.evaluate(
() =>
(
window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }
).__BUZZ_E2E_COMMANDS__?.filter(
(command) => command === "create_persona",
).length ?? 0,
);
await page
.locator("#persona-dialog-form")
.evaluate((form) => (form as HTMLFormElement).requestSubmit());
await expect
.poll(async () =>
page.evaluate(
() =>
(
window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }
).__BUZZ_E2E_COMMANDS__?.filter(
(command) => command === "create_persona",
).length ?? 0,
),
)
.toBe(createCountBefore);
});
test("04-create-top-level-api-key-enables-submit", async ({ page }) => {
await installMockBridge(page);
await openCreateDialog(page);
await selectProvider(page, "Anthropic");
await setCustomModel(page, "claude-opus-4-5");
await page.getByLabel("Anthropic API Key").fill("sk-test-api-key-for-e2e");
await expect(page.getByTestId("persona-dialog-submit")).toBeEnabled({
timeout: 5_000,
});
});
// Shot 05: claude runtime (CLI-login) — provider not required; an explicit
// model completes the custom configuration and enables submit.
// Override the catalog to make claude fully available so it appears in the dropdown.
test("05-create-cli-login-runtime-no-provider-required", async ({ page }) => {
await installMockBridge(page, {
acpRuntimesCatalog: [
{
id: "buzz-agent",
label: "Buzz Agent",
avatar_url: "",
availability: "available",
command: "buzz-agent",
binary_path: "/usr/local/bin/buzz-agent",
default_args: [],
mcp_command: "buzz-dev-mcp",
install_hint: "Ships with the Buzz desktop app.",
install_instructions_url: "https://github.com/block/buzz",
can_auto_install: false,
underlying_cli_path: null,
},
{
id: "claude",
label: "Claude Code",
avatar_url: "",
availability: "available",
command: "/usr/local/bin/claude-agent",
binary_path: "/usr/local/bin/claude-agent",
default_args: ["acp"],
mcp_command: null,
install_hint: "Install the Claude Code ACP adapter via npm.",
install_instructions_url:
"https://www.npmjs.com/package/@anthropic-ai/claude-agent-acp",
can_auto_install: true,
underlying_cli_path: "/usr/local/bin/claude",
},
],
});
await openCreateDialog(page);
await page.getByRole("tab", { name: "Customize for this agent" }).click();
// Switch the auto-selected buzz-agent runtime to Claude Code.
await selectDropdownOption(
page,
page.locator("#persona-runtime"),
"Claude Code",
);
// Provider stays hidden for CLI-login runtimes. Customize still requires
// an explicit model choice.
await expect(page.locator("#persona-llm-provider")).not.toBeVisible();
await setCustomModel(page, "claude-opus-4-6");
await expect(page.getByTestId("persona-dialog-submit")).toBeEnabled({
timeout: 5_000,
});
await settleAnimations(page);
const dialog = page.getByRole("dialog");
await dialog.screenshot({
path: `${SHOTS}/05-create-cli-login-runtime-no-provider-required.png`,
});
});
// Shot 07: Edit dialog for an existing managed agent (goose runtime) showing
// the provider/model pickers.
test("07-edit-dialog-extracted-fields", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: EDIT_AGENT_PUBKEY,
name: "Tyler Agent",
status: "stopped" as const,
channelNames: ["agents"],
},
],
});
await openEditDialog(page, "Tyler Agent");
await settleAnimations(page);
const dialog = page.getByRole("dialog");
await dialog.screenshot({
path: `${SHOTS}/07-edit-dialog-extracted-fields.png`,
});
});
// Shot 08: goose runtime, provider empty + no global → save BLOCKED (same rule as buzz-agent).
test("08-create-goose-empty-provider-marker", async ({ page }) => {
await installMockBridge(page);
await openCreateDialog(page);
// Opt into per-agent customization, then switch to goose to confirm a
// genuinely incomplete customized configuration remains blocked.
await page.getByRole("tab", { name: "Customize for this agent" }).click();
await expect(page.locator("#persona-llm-provider")).toBeVisible({
timeout: 10_000,
});
await selectDropdownOption(page, page.locator("#persona-runtime"), "Goose");
// Provider field still visible for goose (also a provider-selection runtime).
await expect(page.locator("#persona-llm-provider")).toBeVisible({
timeout: 5_000,
});
// Provider empty + no global provider → save is BLOCKED per the
// provider-default rule. The submit button must be disabled.
await expect(page.getByTestId("persona-dialog-submit")).toBeDisabled({
timeout: 10_000,
});
await settleAnimations(page);
const dialog = page.getByRole("dialog");
await dialog.screenshot({
path: `${SHOTS}/08-create-goose-empty-provider-marker.png`,
});
});
});
@@ -0,0 +1,357 @@
/**
* Recipient-native agent snapshot card/import E2E spec.
*
* These tests exercise the AgentSnapshotCard rendered in a message timeline
* when an .agent.json or .agent.png attachment is detected, and the full
* Add agent → preview → confirm flow.
*/
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
type CommandLogEntry = { command: string; payload: unknown };
function hasVisibleBoxShadow(boxShadow: string) {
if (boxShadow === "none") return false;
const colors = [...boxShadow.matchAll(/rgba?\(([^)]+)\)/gu)];
if (colors.length === 0) return true;
return colors.some(([, channels]) => {
const values = channels.split(",").map((value) => value.trim());
return values.length < 4 || Number(values[3]) > 0;
});
}
async function readCommandLog(page: import("@playwright/test").Page) {
return page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: CommandLogEntry[];
}
).__BUZZ_E2E_COMMAND_LOG__ ?? [],
);
}
async function invokeMockCommand(
page: import("@playwright/test").Page,
command: string,
payload: Record<string, unknown>,
) {
// The app installs its dynamically imported bridge during bootstrap. Wait for
// that installation after navigation instead of racing the first invoke.
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
null,
{ timeout: 5_000 },
);
return page.evaluate(
async ({ command: cmd, payload: request }) => {
const invoke = (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__: (
command: string,
payload: Record<string, unknown>,
) => Promise<unknown>;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
return invoke(cmd, request);
},
{ command, payload },
);
}
const ANALYST_PERSONA_ID = "test-analyst";
const ANALYST_PUBKEY =
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
const SHA256 = "a".repeat(64);
/** Full imeta descriptor for a .agent.json attachment. */
const SNAPSHOT_UPLOAD_DESCRIPTOR = {
url: `https://mock.relay/media/${SHA256}.json`,
sha256: SHA256,
size: 1234,
type: "application/json",
uploaded: Math.floor(Date.now() / 1000),
filename: "e2e-agent.agent.json",
};
// ── Helper: seed bridge, send snapshot to #general, navigate to timeline ─────
async function seedAndSendSnapshot(
page: import("@playwright/test").Page,
opts: { snapshotFetchError?: string } = {},
) {
await installMockBridge(page, {
personas: [
{
id: ANALYST_PERSONA_ID,
displayName: "Analyst",
systemPrompt: "You are an analyst.",
},
],
managedAgents: [
{
pubkey: ANALYST_PUBKEY,
name: "Analyst",
personaId: ANALYST_PERSONA_ID,
},
],
uploadDescriptors: [SNAPSHOT_UPLOAD_DESCRIPTOR],
...(opts.snapshotFetchError
? { snapshotFetchError: opts.snapshotFetchError }
: {}),
});
await page.goto("/");
// JSON snapshots can still arrive from saved files or older clients, even
// though new in-app shares always encode PNG. Seed one directly so this
// recipient suite retains JSON-card coverage independent of send behavior.
await invokeMockCommand(page, "send_channel_message", {
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
content: `[${SNAPSHOT_UPLOAD_DESCRIPTOR.filename}](${SNAPSHOT_UPLOAD_DESCRIPTOR.url})`,
mediaTags: [
[
"imeta",
`url ${SNAPSHOT_UPLOAD_DESCRIPTOR.url}`,
`m ${SNAPSHOT_UPLOAD_DESCRIPTOR.type}`,
`x ${SNAPSHOT_UPLOAD_DESCRIPTOR.sha256}`,
`size ${SNAPSHOT_UPLOAD_DESCRIPTOR.size}`,
`filename ${SNAPSHOT_UPLOAD_DESCRIPTOR.filename}`,
],
],
});
// Navigate to #general.
await page.getByTestId("channel-general").click();
}
// ── Timeline renders AgentSnapshotCard, not generic FileCard ──────────────────
test("recipient_timeline_renders_agent_snapshot_card_not_file_card", async ({
page,
}) => {
await seedAndSendSnapshot(page);
// The sent attachment must render as AgentSnapshotCard.
const card = page.getByTestId("agent-snapshot-card").last();
await expect(card).toBeVisible({ timeout: 5000 });
await expect(card).toHaveAttribute("data-slot", "attachment");
// Show the agent name without the snapshot file extension.
const title = card.locator('[data-slot="attachment-title"]');
await expect(title).toBeVisible();
await expect(title).toHaveText("E2e Agent");
await expect(card).not.toContainText("e2e-agent.agent.json");
const mediaBox = await card
.locator('[data-slot="attachment-media"]')
.boundingBox();
expect(mediaBox?.width).toBe(mediaBox?.height);
// Metadata names the sharer and keeps the file size.
await expect(card).toContainText("Shared by npub1mock... · 1.2 KB");
await expect(card).not.toContainText("Agent snapshot");
// Both actions must be present.
await expect(card.getByTestId("agent-snapshot-card-import")).toBeVisible();
await expect(card.getByTestId("agent-snapshot-card-download")).toBeVisible();
await expect(card.locator('[data-slot="attachment-action"]')).toHaveCount(2);
const actions = card.locator('[data-slot="attachment-actions"] button');
await expect(actions.nth(0)).toHaveAccessibleName("Download E2e Agent");
await expect(actions.nth(0)).toHaveText("");
await expect(actions.nth(1)).toHaveText("Add agent");
const addAgentButton = actions.nth(1);
const restingButtonStyle = await addAgentButton.evaluate((element) => {
const style = getComputedStyle(element);
return {
backgroundColor: style.backgroundColor,
boxShadow: style.boxShadow,
color: style.color,
};
});
expect(restingButtonStyle.color).not.toBe(restingButtonStyle.backgroundColor);
expect(hasVisibleBoxShadow(restingButtonStyle.boxShadow)).toBe(false);
await addAgentButton.hover();
await expect
.poll(async () =>
hasVisibleBoxShadow(
await addAgentButton.evaluate(
(element) => getComputedStyle(element).boxShadow,
),
),
)
.toBe(false);
const [contentBox, downloadBox, addAgentBox] = await Promise.all([
card.locator('[data-slot="attachment-content"]').boundingBox(),
actions.nth(0).boundingBox(),
addAgentButton.boundingBox(),
]);
expect(
(downloadBox?.x ?? 0) - ((contentBox?.x ?? 0) + (contentBox?.width ?? 0)),
).toBeGreaterThanOrEqual(28);
expect(
(addAgentBox?.x ?? 0) - ((downloadBox?.x ?? 0) + (downloadBox?.width ?? 0)),
).toBeGreaterThanOrEqual(8);
const [titleElement, descriptionElement] = await Promise.all([
title.elementHandle(),
card.locator('[data-slot="attachment-description"]').elementHandle(),
]);
expect(
await titleElement?.evaluate(
(element) => element.scrollWidth <= element.clientWidth,
),
).toBe(true);
expect(
await descriptionElement?.evaluate(
(element) => element.scrollWidth <= element.clientWidth,
),
).toBe(true);
// Generic FileCard must NOT be present for this attachment.
await expect(page.getByTestId("file-card")).toHaveCount(0);
});
// ── Download still invokes download_file, not fetch_snapshot_bytes ───────────
test("recipient_download_invokes_download_file_only", async ({ page }) => {
await seedAndSendSnapshot(page);
const card = page.getByTestId("agent-snapshot-card").last();
await expect(card).toBeVisible({ timeout: 5000 });
await card.getByTestId("agent-snapshot-card-download").click();
const log = await readCommandLog(page);
const downloadCmds = log.filter((e) => e.command === "download_file");
const fetchSnapshotCmds = log.filter(
(e) => e.command === "fetch_snapshot_bytes",
);
expect(downloadCmds.length).toBeGreaterThanOrEqual(1);
expect(fetchSnapshotCmds.length).toBe(0);
});
// ── Add agent: fetch → navigate to agents → open preview ─────────────────────
test("recipient_import_navigates_to_agents_and_opens_preview", async ({
page,
}) => {
await seedAndSendSnapshot(page);
const card = page.getByTestId("agent-snapshot-card").last();
await expect(card).toBeVisible({ timeout: 5000 });
// Click Add agent.
await card.getByTestId("agent-snapshot-card-import").click();
// fetch_snapshot_bytes must have been called.
await expect(async () => {
const log = await readCommandLog(page);
const fetchCmds = log.filter((e) => e.command === "fetch_snapshot_bytes");
expect(fetchCmds.length).toBeGreaterThanOrEqual(1);
}).toPass({ timeout: 5000 });
// download_file must NOT have been called for Add agent.
const log = await readCommandLog(page);
const downloadCmds = log.filter((e) => e.command === "download_file");
expect(downloadCmds.length).toBe(0);
// Preview dialog must open on the agents view.
const dialog = page.getByTestId("agent-snapshot-import-dialog");
await expect(dialog).toBeVisible({ timeout: 8000 });
// Decoded display name must appear.
await expect(dialog).toContainText("Imported Agent");
const metadata = dialog.getByTestId("agent-definition-metadata");
await expect(metadata).toContainText("Type");
await expect(metadata).toContainText("Built-in agent");
await expect(metadata).toContainText("Preferred model");
await expect(metadata).toContainText("claude-opus-4-5");
await expect(metadata).toContainText("Preferred runtime");
await expect(metadata).toContainText("goose");
});
// ── Confirm imports the agent ─────────────────────────────────────────────────
test("recipient_import_confirm_calls_confirm_once_and_shows_result", async ({
page,
}) => {
await seedAndSendSnapshot(page);
const card = page.getByTestId("agent-snapshot-card").last();
await expect(card).toBeVisible({ timeout: 5000 });
await card.getByTestId("agent-snapshot-card-import").click();
const dialog = page.getByTestId("agent-snapshot-import-dialog");
await expect(dialog).toBeVisible({ timeout: 8000 });
// Confirm the import.
await dialog.getByTestId("agent-snapshot-import-confirm").click();
// confirm_agent_snapshot_import must have been called exactly once.
await expect(async () => {
const log = await readCommandLog(page);
const confirmCmds = log.filter(
(e) => e.command === "confirm_agent_snapshot_import",
);
expect(confirmCmds.length).toBe(1);
}).toPass({ timeout: 5000 });
});
// ── Malformed/error candidate shows error, never opens preview ────────────────
test("recipient_fetch_error_shows_error_and_download_remains", async ({
page,
}) => {
await seedAndSendSnapshot(page, {
snapshotFetchError: "hash mismatch: fetched bytes do not match",
});
const card = page.getByTestId("agent-snapshot-card").last();
await expect(card).toBeVisible({ timeout: 5000 });
await card.getByTestId("agent-snapshot-card-import").click();
// Error must appear; preview must NOT open.
const errorEl = card.getByTestId("agent-snapshot-card-error");
await expect(errorEl).toBeVisible({ timeout: 5000 });
await expect(errorEl).toContainText("hash mismatch");
await expect(
page.getByTestId("agent-snapshot-import-dialog"),
).not.toBeVisible();
// Download must remain available after error.
await expect(card.getByTestId("agent-snapshot-card-download")).toBeVisible();
});
// ── Double-click produces one fetch/preview flow ──────────────────────────────
test("recipient_double_click_import_opens_one_preview", async ({ page }) => {
await seedAndSendSnapshot(page);
const card = page.getByTestId("agent-snapshot-card").last();
await expect(card).toBeVisible({ timeout: 5000 });
const importBtn = card.getByTestId("agent-snapshot-card-import");
// Click Add agent.
await importBtn.click();
// Import dialog must open exactly once (not duplicated by rapid clicks).
const dialog = page.getByTestId("agent-snapshot-import-dialog");
await expect(dialog).toHaveCount(1, { timeout: 5000 });
// Exactly one fetch_snapshot_bytes call.
const log = await readCommandLog(page);
const fetchCount = log.filter(
(e) => e.command === "fetch_snapshot_bytes",
).length;
expect(fetchCount).toBe(1);
});
@@ -0,0 +1,261 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { expect, test } from "@playwright/test";
import { TwoRelayHarness, type RelaySpec } from "./helpers/twoRelayHarness";
const exec = promisify(execFile);
const enabled = process.env.BUZZ_E2E_AGENTS_EVERYWHERE === "1";
const relayBin = process.env.BUZZ_E2E_RELAY_BIN;
const cliBin = process.env.BUZZ_E2E_CLI_BIN;
const adminBin = process.env.BUZZ_E2E_ADMIN_BIN;
function required(name: string, value: string | undefined): string {
if (!value) throw new Error(`${name} is required for the live gate`);
return value;
}
async function run(
binary: string,
args: string[],
env: NodeJS.ProcessEnv = {},
): Promise<string> {
const { stdout } = await exec(binary, args, {
cwd: "..",
env: { ...process.env, BUZZ_AUTH_TAG: "", ...env },
});
return stdout;
}
function keyField(output: string, label: string): string {
const value = output.match(new RegExp(`^${label}:\\s+(\\S+)$`, "m"))?.[1];
if (!value) throw new Error(`missing ${label} in key output`);
return value;
}
async function processTree(
rootPid: number,
): Promise<Array<{ pid: number; rssKb: number; command: string }>> {
const { stdout } = await exec("ps", ["-axo", "pid=,ppid=,rss=,command="]);
const rows = stdout
.trim()
.split("\n")
.map((line) => {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/);
if (!match) return undefined;
return {
pid: Number(match[1]),
ppid: Number(match[2]),
rssKb: Number(match[3]),
command: match[4],
};
})
.filter((row): row is NonNullable<typeof row> => row !== undefined);
const pids = new Set([rootPid]);
let changed = true;
while (changed) {
changed = false;
for (const row of rows) {
if (pids.has(row.ppid) && !pids.has(row.pid)) {
pids.add(row.pid);
changed = true;
}
}
}
return rows
.filter((row) => pids.has(row.pid))
.map(({ pid, rssKb, command }) => ({ pid, rssKb, command }));
}
async function eventually<T>(fn: () => Promise<T | undefined>): Promise<T> {
const deadline = Date.now() + 30_000;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const value = await fn();
if (value !== undefined) return value;
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
throw lastError ?? new Error("live assertion timed out");
}
test.describe("agents everywhere live two-relay gate", () => {
test.skip(!enabled, "set BUZZ_E2E_AGENTS_EVERYWHERE=1 to run live gate");
test("same agent listens and wakes independently in two communities", async () => {
test.setTimeout(90_000);
const portBase = 20_000 + (process.pid % 5_000) * 2;
const specs: [RelaySpec, RelaySpec] = [
{
name: "relay-a",
ports: {
main: portBase,
health: portBase + 10_000,
metrics: portBase + 20_000,
},
databaseUrl: required(
"BUZZ_E2E_DATABASE_URL",
process.env.BUZZ_E2E_DATABASE_URL,
),
redisUrl: process.env.BUZZ_E2E_REDIS_A ?? "redis://127.0.0.1:6379/11",
},
{
name: "relay-b",
ports: {
main: portBase + 1,
health: portBase + 10_001,
metrics: portBase + 20_001,
},
databaseUrl: required(
"BUZZ_E2E_DATABASE_URL",
process.env.BUZZ_E2E_DATABASE_URL,
),
redisUrl: process.env.BUZZ_E2E_REDIS_B ?? "redis://127.0.0.1:6379/12",
},
];
const harness = await TwoRelayHarness.create(specs);
try {
await harness.startRelays(required("BUZZ_E2E_RELAY_BIN", relayBin));
const senderOutput = await run(required("BUZZ_E2E_ADMIN_BIN", adminBin), [
"generate-key",
]);
const agentOutput = await run(required("BUZZ_E2E_ADMIN_BIN", adminBin), [
"generate-key",
]);
const senderKey = keyField(senderOutput, "Secret key");
const agentKey = keyField(agentOutput, "Secret key");
const agentPubkey = keyField(agentOutput, "Public key");
const channels: Array<{ relay: RelaySpec; id: string }> = [];
for (const relay of specs) {
const relayHttp = `http://127.0.0.1:${relay.ports.main}`;
const senderEnv = {
BUZZ_RELAY_URL: relayHttp,
BUZZ_PRIVATE_KEY: senderKey,
};
const created = JSON.parse(
await run(
required("BUZZ_E2E_CLI_BIN", cliBin),
[
"channels",
"create",
"--name",
`ae-live-${relay.name}-${process.pid}`,
"--type",
"stream",
"--visibility",
"open",
],
senderEnv,
),
);
await run(
required("BUZZ_E2E_CLI_BIN", cliBin),
[
"channels",
"add-member",
"--channel",
created.channel_id,
"--pubkey",
agentPubkey,
"--role",
"member",
],
senderEnv,
);
await run(
required("BUZZ_E2E_CLI_BIN", cliBin),
["users", "set-profile", "--name", "AgentsEverywhereProbe"],
{ BUZZ_RELAY_URL: relayHttp, BUZZ_PRIVATE_KEY: agentKey },
);
channels.push({ relay, id: created.channel_id });
}
const acpChildren = [];
for (const { relay } of channels) {
acpChildren.push(
await harness.startAcp(
`acp-${relay.name}`,
`ws://127.0.0.1:${relay.ports.main}`,
agentKey,
{ BUZZ_ACP_RESPOND_TO: "anyone", BUZZ_ACP_NO_MEMORY: "true" },
),
);
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
if (!acpChildren.every((child) => child.exitCode === null)) {
throw new Error(`ACP listener exited early:\n${await harness.logs()}`);
}
const idleTrees = await Promise.all(
acpChildren.map((child) => {
if (!child.pid) throw new Error("ACP listener has no pid");
return processTree(child.pid);
}),
);
expect(idleTrees.every((tree) => tree.length === 1)).toBe(true);
test.info().annotations.push({
type: "idle-processes",
description: idleTrees
.map(
(tree) =>
`${tree.length} process / ${tree.reduce((sum, row) => sum + row.rssKb, 0)} KiB RSS`,
)
.join("; "),
});
for (const { relay, id } of channels) {
const relayHttp = `http://127.0.0.1:${relay.ports.main}`;
await run(
required("BUZZ_E2E_CLI_BIN", cliBin),
[
"messages",
"send",
"--channel",
id,
"--content",
`@AgentsEverywhereProbe AE-ID:${relay.name}`,
],
{ BUZZ_RELAY_URL: relayHttp, BUZZ_PRIVATE_KEY: senderKey },
);
const messages = await eventually(async () => {
const output = await run(
required("BUZZ_E2E_CLI_BIN", cliBin),
["messages", "get", "--channel", id, "--limit", "20"],
{ BUZZ_RELAY_URL: relayHttp, BUZZ_PRIVATE_KEY: senderKey },
);
const rows = JSON.parse(output) as Array<{ content?: string }>;
return rows.some((row) => row.content === `AE-ACK:${relay.name}`)
? rows
: undefined;
});
expect(
messages.filter((row) => row.content === `AE-ACK:${relay.name}`),
).toHaveLength(1);
}
} catch (error) {
console.error(await harness.logs());
throw error;
} finally {
await harness.stop();
await eventually(async () => {
const survivors = (
await Promise.all(
harness.ownedPids.map(async (pid) => {
try {
process.kill(pid, 0);
return pid;
} catch {
return undefined;
}
}),
)
).filter((pid) => pid !== undefined);
return survivors.length === 0 ? true : undefined;
});
}
});
});
File diff suppressed because it is too large Load Diff
+330
View File
@@ -0,0 +1,330 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { installFakeCamera } from "../helpers/fakeCamera";
import { openSettings } from "../helpers/settings";
// The review editor (preview + framing + poster strip + backdrop panel) is
// taller than the default 720px viewport — raise it so the whole editor remains visible.
test.use({ viewport: { height: 1280, width: 1280 } });
const UPLOADED_PNG_DESCRIPTOR = {
sha256: "ab".repeat(32),
size: 4096,
type: "image/png",
uploaded: 1700000000,
url: `https://relay.example.com/media/${"ab".repeat(32)}.png`,
};
async function openAnimatedTab(
page: import("@playwright/test").Page,
options: { cameraDelayMs?: number; holdCamera?: boolean } = {},
) {
await installFakeCamera(page, options);
await installMockBridge(page, {
uploadDescriptors: [UPLOADED_PNG_DESCRIPTOR],
});
await page.goto("/");
await openSettings(page, "profile");
await page.getByTestId("profile-avatar-edit").click();
await page.getByRole("tab", { name: "Animated" }).click();
await expect(page.getByTestId("profile-avatar-animated")).toBeVisible();
// Let the tab indicator + content height animations settle.
await page.waitForTimeout(400);
}
test.describe("animated avatar", () => {
test("01 — animated tab idle state", async ({ page }) => {
await openAnimatedTab(page);
await expect(
page.getByTestId("profile-avatar-animated-camera-iphone"),
).toBeVisible();
await expect(
page.getByTestId("profile-avatar-animated-camera-computer"),
).toBeVisible();
await expect(
page.getByTestId("profile-avatar-animated-camera-iphone"),
).toHaveAttribute("aria-pressed", "false");
await expect(
page.getByTestId("profile-avatar-animated-camera-computer"),
).toHaveAttribute("aria-pressed", "false");
await expect(page.getByTestId("profile-avatar-animated-start")).toHaveCount(
0,
);
await expect(
page.getByTestId("profile-avatar-animated-camera-select"),
).toHaveCount(0);
await expect(page.getByTestId("profile-avatar-done")).toHaveCount(0);
});
test("02 — live camera preview", async ({ page }) => {
await openAnimatedTab(page, { holdCamera: true });
const animatedTabHeight = () =>
page
.getByTestId("profile-avatar-animated")
.evaluate((element) =>
Math.round(element.getBoundingClientRect().height),
);
const idleHeight = await animatedTabHeight();
await page.getByTestId("profile-avatar-animated-camera-iphone").click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_CAMERA_REQUEST_COUNT__?: number;
}
).__BUZZ_E2E_CAMERA_REQUEST_COUNT__ ?? 0,
),
)
.toBeGreaterThan(0);
const previewSlot = page.getByTestId(
"profile-avatar-animated-preview-slot",
);
await expect(
page.getByRole("status").filter({ hasText: "Starting camera" }),
).toBeVisible();
await expect(
previewSlot.getByRole("status").filter({ hasText: "Starting camera" }),
).toBeVisible();
await expect(page.getByTestId("profile-avatar-preview")).toHaveCount(0);
await expect(
page.getByTestId("profile-avatar-animated-record"),
).toHaveCount(0);
const startingHeight = await animatedTabHeight();
expect(Math.abs(startingHeight - idleHeight)).toBeLessThanOrEqual(1);
await page.evaluate(() => {
(
window as Window & {
__BUZZ_E2E_RELEASE_CAMERA__?: () => void;
}
).__BUZZ_E2E_RELEASE_CAMERA__?.();
});
await expect(
page.getByTestId("profile-avatar-animated-record"),
).toBeVisible({ timeout: 10_000 });
await expect(
previewSlot.getByTestId("profile-avatar-animated-preview"),
).toBeVisible();
const liveHeight = await animatedTabHeight();
expect(Math.abs(liveHeight - idleHeight)).toBeLessThanOrEqual(1);
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_CAMERA_CONSTRAINTS__?: MediaStreamConstraints[];
}
).__BUZZ_E2E_CAMERA_CONSTRAINTS__?.at(-1)?.video,
),
)
.toEqual(
expect.objectContaining({
deviceId: { exact: "iphone-continuity" },
facingMode: { ideal: "user" },
}),
);
await expect(page.getByTestId("profile-avatar-done")).toHaveCount(0);
await page.waitForTimeout(500);
await page.getByTestId("profile-avatar-animated-record").click();
await expect(
page.getByTestId("profile-avatar-animated-sections"),
).toBeVisible({ timeout: 60_000 });
await expect(
page.getByTestId("profile-avatar-animated-size"),
).toHaveAttribute("aria-valuenow", "116");
});
test("03 — recording ring, pop-out review, custom color", async ({
page,
}) => {
test.setTimeout(120_000);
await openAnimatedTab(page);
await page.getByTestId("profile-avatar-animated-camera-computer").click();
await expect(
page.getByTestId("profile-avatar-animated-record"),
).toBeVisible({ timeout: 10_000 });
await page.getByTestId("profile-avatar-animated-record").click();
// Mid-recording: the timer stroke sweeps around the capture circle.
await expect(
page.getByTestId("profile-avatar-animated-timer-ring"),
).toBeVisible();
await page.waitForTimeout(1500);
// Review: the section icon row appears once processing finishes.
await expect(
page.getByTestId("profile-avatar-animated-sections"),
).toBeVisible({ timeout: 60_000 });
await expect(
page.getByTestId("profile-avatar-animated-size"),
).toHaveAttribute("aria-valuenow", "126");
await expect(page.getByTestId("profile-avatar-done")).toBeVisible();
await expect(
page.getByTestId("profile-avatar-animated-framing-readout"),
).toHaveCount(0);
const previewSnapshot = () =>
page
.getByTestId("profile-avatar-animated-review-preview")
.evaluate((canvas) => (canvas as HTMLCanvasElement).toDataURL());
// Color section: pick a deterministic backdrop color (the default is random).
await page.getByTestId("profile-avatar-animated-section-color").click();
await expect(
page.getByTestId("profile-avatar-animated-backdrop-none"),
).toHaveCount(0);
await page
.getByTestId("profile-avatar-animated-backdrop-grid")
.locator("button")
.nth(5)
.click();
// Poster section: selecting a frame updates the (paused) preview.
await page.getByTestId("profile-avatar-animated-section-poster").click();
await expect(
page.getByTestId("profile-avatar-animated-poster-strip"),
).toBeVisible();
await expect(
page.getByTestId("profile-avatar-animated-poster-selector"),
).toBeVisible();
await expect(
page.getByTestId("profile-avatar-animated-review-help"),
).toHaveText("Pick the still shown before hover.");
await expect(
page.getByTestId("profile-avatar-animated-poster-prev"),
).toHaveCount(0);
await expect(
page.getByTestId("profile-avatar-animated-poster-next"),
).toHaveCount(0);
const scrubber = page.getByTestId(
"profile-avatar-animated-poster-scrubber",
);
const scrubberBox = await scrubber.boundingBox();
if (!scrubberBox) {
throw new Error("Animated avatar poster scrubber did not render bounds.");
}
await scrubber.click({
position: {
x: scrubberBox.width * 0.72,
y: scrubberBox.height / 2,
},
});
await expect(scrubber).not.toHaveAttribute("aria-valuenow", "0");
// The Circle tool stays implemented, but it is hidden in the review row for now.
await expect(
page.getByTestId("profile-avatar-animated-section-shape"),
).toHaveCount(0);
// You section: the DialKit-style local slider re-composes the preview.
await page.getByTestId("profile-avatar-animated-section-person").click();
await expect(
page.getByTestId("profile-avatar-animated-review-help"),
).toHaveCount(0);
await expect(
page.getByTestId("profile-avatar-animated-outline-toggle"),
).toHaveAttribute("aria-pressed", "true");
await page.getByTestId("profile-avatar-animated-outline-toggle").click();
await expect(
page.getByTestId("profile-avatar-animated-outline-toggle"),
).toHaveAttribute("aria-pressed", "false");
await page.getByTestId("profile-avatar-animated-outline-toggle").click();
const sizeSlider = page.getByTestId("profile-avatar-animated-size");
const initialSizeValue = await sizeSlider.getAttribute("aria-valuenow");
const sizeSliderBox = await sizeSlider.boundingBox();
if (!sizeSliderBox) {
throw new Error("Animated avatar size slider did not render bounds.");
}
await sizeSlider.click({
position: {
x: sizeSliderBox.width * 0.25,
y: sizeSliderBox.height / 2,
},
});
await expect(sizeSlider).not.toHaveAttribute(
"aria-valuenow",
initialSizeValue ?? "",
);
// Restore defaults so the standard framing remains covered.
await page.getByTestId("profile-avatar-animated-reset-framing").click();
await page.waitForTimeout(400);
// Custom backdrop color panel (shared HSV picker).
await page.getByTestId("profile-avatar-animated-section-color").click();
await page.getByTestId("profile-avatar-animated-backdrop-custom").click();
await expect(
page.getByTestId("profile-avatar-animated-custom-color-spectrum"),
).toBeVisible();
await expect(page.getByTestId("profile-avatar-done")).toHaveCount(0);
const beforeCustomColor = await previewSnapshot();
const customColorSpectrum = page.getByTestId(
"profile-avatar-animated-custom-color-spectrum",
);
const customColorSpectrumBox = await customColorSpectrum.boundingBox();
if (!customColorSpectrumBox) {
throw new Error(
"Animated avatar custom color picker did not render bounds.",
);
}
await customColorSpectrum.click({
position: {
x: customColorSpectrumBox.width * 0.88,
y: customColorSpectrumBox.height * 0.82,
},
});
await page.waitForTimeout(400);
const customColorPreview = await previewSnapshot();
expect(customColorPreview).not.toEqual(beforeCustomColor);
await page.getByTestId("profile-avatar-animated-custom-color-done").click();
await expect(
page.getByTestId("profile-avatar-animated-backdrop-custom"),
).toHaveAttribute("aria-pressed", "true");
expect(await previewSnapshot()).toEqual(customColorPreview);
// Done applies the recording (APNG encode + mocked two-file upload) and
// then saves the profile in one step.
const doneButton = page.getByTestId("profile-avatar-done");
await doneButton.click();
await expect(doneButton).toContainText("Saving", { timeout: 2_000 });
await expect(page.getByRole("tab", { name: "Image" })).toBeDisabled();
await expect(page.getByRole("tab", { name: "Emoji" })).toBeDisabled();
await expect(page.getByRole("tab", { name: "Animated" })).toBeDisabled();
await expect
.poll(
() =>
page.evaluate(
() =>
(
window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }
).__BUZZ_E2E_COMMANDS__?.filter(
(command) => command === "upload_media_bytes",
).length ?? 0,
),
{ timeout: 30_000 },
)
.toBeGreaterThanOrEqual(2);
await expect
.poll(
() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
{ timeout: 30_000 },
)
.toEqual(expect.arrayContaining(["update_profile"]));
await expect(page.getByTestId("profile-avatar-animated-error")).toHaveCount(
0,
);
await expect(page.getByTestId("profile-avatar-preview")).toBeVisible({
timeout: 10_000,
});
});
});
+746
View File
@@ -0,0 +1,746 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
const SHOTS = "test-results/channel-row-decoration-pr";
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
kind?: number,
) {
await expect
.poll(async () => {
return page.evaluate(
({ currentChannelName, kind: k }) => {
return (
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
kind?: number;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: currentChannelName,
kind: k,
}) ?? false
);
},
{ currentChannelName: channelName, kind },
);
})
.toBe(true);
}
async function getBadgeState(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const w = window as Window & {
__BUZZ_E2E_APP_BADGE_STATE__?: string;
__BUZZ_E2E_APP_BADGE_COUNT__?: number;
};
return {
state: w.__BUZZ_E2E_APP_BADGE_STATE__ ?? "none",
count: w.__BUZZ_E2E_APP_BADGE_COUNT__ ?? 0,
};
});
}
async function waitForBadgeState(
page: import("@playwright/test").Page,
expected: { state: string; count?: number },
) {
await expect
.poll(async () => getBadgeState(page), { timeout: 5_000 })
.toEqual(
expect.objectContaining({
state: expected.state,
...(expected.count !== undefined ? { count: expected.count } : {}),
}),
);
}
async function getSettledBadgeState(page: import("@playwright/test").Page) {
// The mock bridge seeds a couple of unread items during app startup. Let
// those settle before asserting deltas from newly emitted messages.
await page.waitForTimeout(2000);
return getBadgeState(page);
}
async function getSidebarHomeBadgeText(page: import("@playwright/test").Page) {
return page
.getByTestId("sidebar-home-count")
.allTextContents()
.then((texts) => texts[0] ?? null);
}
function withAdditionalBadgeCount(baseline: { count: number }, count: number) {
return { state: "count", count: baseline.count + count };
}
function withDotOnlyBadge(baseline: { state: string; count: number }) {
return baseline.count > 0 ? baseline : { state: "dot", count: 0 };
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
test("selected Inbox and Agents rows keep their highlight without bold text", async ({
page,
}) => {
await page.goto("/");
const inbox = page
.getByTestId("sidebar-primary-menu")
.getByRole("button", { name: "Inbox", exact: true });
await expect(inbox).toHaveAttribute("data-active", "true");
await expect(inbox).toHaveCSS("font-weight", "400");
const agents = page.getByTestId("open-agents-view");
await agents.click();
await expect(agents).toHaveAttribute("data-active", "true");
await expect(agents).toHaveCSS("font-weight", "400");
});
test("hovering a channel keeps its text color", async ({ page }) => {
await page.goto("/");
const channel = page.getByTestId("channel-engineering");
const initialColor = await channel.evaluate(
(element) => getComputedStyle(element).color,
);
await channel.hover();
await expect(channel).toHaveCSS("color", initialColor);
});
test("direct-message rows become prominent only when unread", async ({
page,
}) => {
await page.addInitScript(() => {
window.localStorage.setItem("buzz-theme", "buzz-dark");
});
await page.goto("/");
const directMessage = page.getByTestId("channel-alice-tyler");
await directMessage.click();
await waitForMockLiveSubscription(page, "alice-tyler");
await page.getByTestId("channel-general").click();
const label = directMessage.locator("[data-sidebar-row-label]");
await expect(directMessage).toHaveCSS("opacity", "1");
await expect(label).toHaveCSS("opacity", "0.8");
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "alice-tyler",
content: "An unread direct message",
kind: 40002,
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
await expect(label).toHaveCSS("opacity", "1");
await expect(directMessage).toHaveCSS("font-weight", "700");
});
test("light mode reserves full opacity for unread text and avatars", async ({
page,
}) => {
await page.goto("/");
const directMessage = page.getByTestId("channel-alice-tyler");
await directMessage.click();
await waitForMockLiveSubscription(page, "alice-tyler");
await page.getByTestId("channel-general").click();
const inbox = page
.getByTestId("sidebar-primary-menu")
.getByRole("button", { name: "Inbox", exact: true });
await expect(inbox).toHaveCSS("opacity", "1");
await expect(inbox.locator("[data-sidebar=menu-label]")).toHaveCSS(
"opacity",
"0.8",
);
await expect(inbox.locator("svg")).toHaveCSS("opacity", "0.8");
await expect(directMessage).toHaveCSS("opacity", "1");
await expect(directMessage.locator("[data-sidebar-row-label]")).toHaveCSS(
"opacity",
"0.8",
);
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "alice-tyler",
content: "An unread direct message in light mode",
kind: 40002,
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
await expect(directMessage.locator("[data-sidebar-row-label]")).toHaveCSS(
"opacity",
"1",
);
await expect(directMessage).toHaveCSS("font-weight", "700");
});
test("dark mode keeps selected labels regular and channel-level unread labels bold", async ({
page,
}) => {
await page.addInitScript(() => {
window.localStorage.setItem("buzz-theme", "buzz-dark");
});
await page.goto("/");
await expect(page.locator("html")).toHaveClass(/dark/);
const inbox = page
.getByTestId("sidebar-primary-menu")
.getByRole("button", { name: "Inbox", exact: true });
await expect(inbox).toHaveAttribute("data-active", "true");
await expect(inbox).toHaveCSS("font-weight", "400");
await expect(page.getByTestId("open-agents-view")).toHaveCSS("opacity", "1");
await expect(
page.getByTestId("open-agents-view").locator("[data-sidebar=menu-label]"),
).toHaveCSS("opacity", "0.8");
await expect(page.getByTestId("open-agents-view").locator("svg")).toHaveCSS(
"opacity",
"0.8",
);
await page.getByTestId("channel-general").click();
await expect(inbox).toHaveCSS("opacity", "1");
await expect(inbox.locator("[data-sidebar=menu-label]")).toHaveCSS(
"opacity",
"0.8",
);
await waitForMockLiveSubscription(page, "random");
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "A dark-mode channel-level unread message",
kind: 40002,
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
const unreadChannel = page.getByTestId("channel-random");
const engineeringLabel = page
.getByTestId("channel-engineering")
.locator("[data-sidebar-row-label]");
await expect(engineeringLabel).toHaveCSS("opacity", "0.8");
await expect(
page.getByTestId("channel-engineering").locator("svg"),
).toHaveCSS("opacity", "0.8");
await expect(unreadChannel.locator("[data-sidebar-row-label]")).toHaveCSS(
"opacity",
"1",
);
await expect(unreadChannel).toHaveCSS("font-weight", "700");
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/sidebar-dark-unread.png`,
clip: { x: 0, y: 0, width: 320, height: 720 },
});
});
test("offscreen top-level unread shows the primary sidebar arrow", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 360 });
await page.goto("/");
await page.getByTestId("channel-random").click();
await waitForMockLiveSubscription(page, "random");
await page.getByTestId("channel-general").click();
const sidebarScroller = page
.getByTestId("app-sidebar")
.locator('[data-sidebar="content"]');
await sidebarScroller.evaluate((element) => {
const random = element.querySelector<HTMLElement>(
'[data-testid="channel-random"]',
);
if (!random) throw new Error("Could not find #random in the sidebar");
// Keep #random just above the viewport so it is the next unread row.
element.scrollTop +=
random.getBoundingClientRect().bottom -
element.getBoundingClientRect().top +
1;
});
await expect(page.getByTestId("channel-random")).not.toBeInViewport();
await page.evaluate(
({ pubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "A regular channel message",
kind: 40002,
pubkey,
});
},
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
const activityArrow = page.getByTestId("sidebar-more-unread-above");
await expect(activityArrow).toBeVisible();
await expect(activityArrow).toHaveClass(/bg-primary/);
await activityArrow.click();
await expect(page.getByTestId("channel-random")).toBeInViewport();
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/sidebar-top-level-unread-arrow.png`,
clip: { x: 0, y: 0, width: 320, height: 360 },
});
});
test("offscreen unread DM shows the primary sidebar arrow", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-alice-tyler").click();
await waitForMockLiveSubscription(page, "alice-tyler");
await page.getByTestId("channel-general").click();
await page.setViewportSize({ width: 1280, height: 360 });
const sidebarScroller = page
.getByTestId("app-sidebar")
.locator('[data-sidebar="content"]');
await sidebarScroller.evaluate((element) => {
element.scrollTop = 0;
});
await expect(page.getByTestId("channel-alice-tyler")).not.toBeInViewport();
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "alice-tyler",
content: "An unread direct message",
kind: 40002,
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
const activityArrow = page.getByTestId("sidebar-more-unread-below");
await expect(activityArrow).toBeVisible();
await expect(activityArrow).toHaveClass(/bg-primary/);
});
test("regular message bolds inactive channel without numeric badge", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "random");
const baselineBadge = await getSettledBadgeState(page);
await page.evaluate(
({ pubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Regular message, no mention",
kind: 40002,
pubkey,
});
},
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
await waitForBadgeState(page, withDotOnlyBadge(baselineBadge));
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("channel-random")).toHaveAttribute(
"data-active",
"true",
);
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"400",
);
});
test("top-level @mention bolds the channel without a row badge", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "random");
const baselineBadge = await getSettledBadgeState(page);
await page.evaluate(
({ pubkey, mentionPubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Hey @tyler check this out",
kind: 40002,
pubkey,
mentionPubkeys: [mentionPubkey],
});
},
{
pubkey: TEST_IDENTITIES.alice.pubkey,
mentionPubkey: DEFAULT_MOCK_PUBKEY,
},
);
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
});
test("numeric badge increments for DM message", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "alice-tyler");
const baselineBadge = await getSettledBadgeState(page);
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "alice-tyler",
content: "Hey, got a minute?",
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
await expect(page.getByTestId("channel-unread-alice-tyler")).toBeVisible();
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
});
test("interested thread reply shows the channel preview dot without incrementing Inbox", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "random");
const baselineBadge = await getSettledBadgeState(page);
const baselineHomeBadge = await getSidebarHomeBadgeText(page);
const rootEventId = await page.evaluate(() => {
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Conversation I started",
kind: 40002,
pubkey: "deadbeef".repeat(8),
});
return root?.id;
});
await page.evaluate(
({ parentEventId, pubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Thread reply to a followed conversation",
kind: 40002,
parentEventId,
pubkey,
});
},
{ parentEventId: rootEventId, pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toBeVisible();
await expect
.poll(() => getSidebarHomeBadgeText(page))
.toBe(baselineHomeBadge);
await waitForBadgeState(page, baselineBadge);
});
test("broadcast reply bolds the channel without a thread dot", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "random");
const baselineBadge = await getSettledBadgeState(page);
await page.evaluate(
({ pubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Broadcast reply to the channel",
kind: 40002,
pubkey,
extraTags: [
["broadcast", "1"],
["e", "some-root-event-id"],
],
});
},
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
});
test("mark-as-read via context menu clears channel unread indicator", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "random");
// Wait for catch-up to settle, then record baseline badge state
// (other mock channels may have pre-existing unreads from seeded history)
await page.waitForTimeout(2000);
const baselineBadge = await getBadgeState(page);
await page.evaluate(
({ pubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Message to be marked read",
kind: 40002,
pubkey,
});
},
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await page.getByTestId("channel-random").click({ button: "right" });
await page.getByText("Mark as read").click();
await expect(page.getByTestId("channel-random")).not.toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await waitForBadgeState(page, baselineBadge);
});
test("mark-as-unread via context menu bolds the channel", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
const baselineBadge = await getSettledBadgeState(page);
await page.getByTestId("channel-random").click({ button: "right" });
await page.getByText("Mark unread").click();
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
});
test("marking a message unread bolds its channel after leaving", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await waitForMockLiveSubscription(page, "random");
const message = await page.evaluate(
({ pubkey }) =>
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Keep this channel message unread",
kind: 40002,
pubkey,
}),
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
if (!message) {
throw new Error("Mock message emitter is unavailable");
}
const messageRow = page
.getByTestId("message-row")
.filter({ hasText: "Keep this channel message unread" });
await expect(messageRow).toBeVisible();
await messageRow.hover();
await page.getByTestId(`more-actions-${message.id}`).click();
const toggle = page.getByTestId(`mark-read-toggle-${message.id}`);
await expect(toggle).toHaveText("Mark unread");
await toggle.click();
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"700",
);
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/sidebar-active-manual-unread.png`,
clip: { x: 0, y: 0, width: 320, height: 720 },
});
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
});
test("remote read-state rollback is ignored while local mark-unread still increments badge", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Baseline: random has no unread dot
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
const baselineBadge = await getSettledBadgeState(page);
// Wait for ReadStateManager's live subscription (kind:30078) to be
// established before injecting events.
await expect
.poll(async () => {
return page.evaluate(() => {
return (
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
kind?: number;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: "general",
kind: 30078,
}) ?? false
);
});
})
.toBe(true);
const REMOTE_CLIENT_ID = "other-device-client-id";
const REMOTE_SLOT_ID = "e2e00000000000000000000000000000";
const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d";
const now = Math.floor(Date.now() / 1000);
// Step 1: seed a "read at now" state from the remote device so the
// local manager has a baseline value for this channel context.
await page.evaluate(
({ clientId, slotId, channelId, ts }) => {
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: {
clientId: string;
contexts: Record<string, number>;
createdAt: number;
slotId: string;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_READ_STATE__?.({
clientId,
slotId,
contexts: { [channelId]: ts },
createdAt: ts,
});
},
{
clientId: REMOTE_CLIENT_ID,
slotId: REMOTE_SLOT_ID,
channelId: RANDOM_CHANNEL_ID,
ts: now,
},
);
// Step 2: a remote rollback carries an older read timestamp in a newer
// event. NIP-RS read markers are monotonic, so this must be ignored.
await page.evaluate(
({ clientId, slotId, channelId, ts, createdAt }) => {
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: {
clientId: string;
contexts: Record<string, number>;
createdAt: number;
slotId: string;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_READ_STATE__?.({
clientId,
slotId,
contexts: { [channelId]: ts },
createdAt,
});
},
{
clientId: REMOTE_CLIENT_ID,
slotId: REMOTE_SLOT_ID,
channelId: RANDOM_CHANNEL_ID,
ts: now - 100,
createdAt: now + 5,
},
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
// Local mark-unread remains an in-session affordance and should still bold
// the channel immediately without publishing a lower read timestamp.
await page.getByTestId("channel-random").click({ button: "right" });
await page.getByText("Mark unread").click();
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
// Step 3: remote advance clears the local forced-unread dot.
await page.evaluate(
({ clientId, slotId, channelId, ts, createdAt }) => {
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: {
clientId: string;
contexts: Record<string, number>;
createdAt: number;
slotId: string;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_READ_STATE__?.({
clientId,
slotId,
contexts: { [channelId]: ts },
createdAt,
});
},
{
clientId: REMOTE_CLIENT_ID,
slotId: REMOTE_SLOT_ID,
channelId: RANDOM_CHANNEL_ID,
ts: now + 10,
createdAt: now + 10,
},
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
});
+57
View File
@@ -0,0 +1,57 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// Cold-boot splash hold: on a real boot the community resolves in well under
// 100ms — before the hidden Tauri window ever puts a frame on screen — so the
// loading gate keeps the flapping bee up as an overlay above the already
// mounted app for a minimum visible duration, then fades out. E2E runs skip
// the hold by default (it would slow every spec's boot and block pointer
// actionability); this spec opts back in via __BUZZ_E2E__.bootSplashHoldMs.
test("boot splash overlay holds with a flapping bee, then dismisses", async ({
page,
}) => {
await installMockBridge(page);
// Registered after installMockBridge so it runs after the bridge's init
// script and can extend the config it assigns.
await page.addInitScript(() => {
const testWindow = window as Window & {
__BUZZ_E2E__?: { bootSplashHoldMs?: number };
};
testWindow.__BUZZ_E2E__ = {
...(testWindow.__BUZZ_E2E__ ?? {}),
bootSplashHoldMs: 1_500,
};
});
await page.goto("/");
const overlay = page.getByTestId("boot-splash-overlay");
await expect(overlay).toBeVisible();
// The bee is actually animating while the overlay holds — pure CSS, no SMIL.
const wingState = await overlay.locator(".bee-wing-left").evaluate((wing) => {
const animation = wing.getAnimations()[0];
return {
name: getComputedStyle(wing).animationName,
state: animation?.playState,
};
});
expect(wingState).toEqual({ name: "bee-wing-left-flap", state: "running" });
// The app mounts and loads beneath the overlay — boot is not delayed.
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
// After the hold elapses the overlay fades out and unmounts.
await expect(overlay).toHaveCount(0, { timeout: 6_000 });
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
});
test("boot splash overlay is skipped when the hold is zero (e2e default)", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await expect(page.getByTestId("boot-splash-overlay")).toHaveCount(0);
});
@@ -0,0 +1,639 @@
import { expect, test, type Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/buzz-theme";
const THEME_STORAGE_KEY = "buzz-theme";
const MOCK_PUBKEY = "deadbeef".repeat(8);
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
/**
* Seed the active theme into localStorage BEFORE the mock bridge installs so
* ThemeProvider reads it on first mount (init scripts run in registration
* order; React reads state on mount, which the bridge triggers).
*/
async function seedTheme(page: Page, theme: string) {
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, value);
},
{ key: THEME_STORAGE_KEY, value: theme },
);
}
async function seedIconChannelSection(page: Page) {
await page.addInitScript(
({ channelId, pubkey }) => {
window.localStorage.setItem(
`buzz-channel-sections.v1:${pubkey}`,
JSON.stringify({
version: 1,
sections: [
{
id: "alignment-section",
name: "Team channels",
icon: "📌",
order: 0,
},
],
assignments: { [channelId]: "alignment-section" },
}),
);
},
{ channelId: GENERAL_CHANNEL_ID, pubkey: MOCK_PUBKEY },
);
}
async function openChannel(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
}
async function expectBuzzSidebarPalette(page: Page, mode: "light" | "dark") {
const mutedColor =
mode === "light" ? "rgba(0, 0, 0, 0.4)" : "rgba(255, 255, 255, 0.4)";
const searchSurface =
mode === "light" ? "rgba(0, 0, 0, 0.04)" : "rgba(255, 255, 255, 0.04)";
const hoverSurface =
mode === "light" ? "rgba(0, 0, 0, 0.04)" : "rgba(255, 255, 255, 0.04)";
const activeSurface =
mode === "light" ? "rgba(0, 0, 0, 0.07)" : "color(srgb 1 1 1 / 0.16)";
const chromeColor =
mode === "light" ? "rgba(0, 0, 0, 0.5)" : "rgba(255, 255, 255, 0.5)";
const search = page.getByTestId("open-search");
const pinnedHeader = page.getByTestId("sidebar-pinned-header");
const sidebarScroller = page.locator(".buzz-sidebar-scrollbar");
const scrollContent = page.getByTestId("sidebar-scroll-content");
const primaryMenu = page.getByTestId("sidebar-primary-menu");
const sectionLabel = page
.locator('[data-sidebar="group-label"]')
.filter({ hasText: "Channels" })
.first();
await expect(sectionLabel).toHaveCSS("color", mutedColor);
await expect(search).toHaveCSS("background-color", searchSurface);
await expect(search.locator("svg").first()).toHaveCSS("color", mutedColor);
await expect(search.locator("span").first()).toHaveCSS("color", mutedColor);
await expect(pinnedHeader).toHaveCSS("padding-bottom", "8px");
await expect(pinnedHeader).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(pinnedHeader).toHaveCSS("margin-left", "3px");
await expect(pinnedHeader).toHaveCSS("margin-right", "3px");
await expect(pinnedHeader).toHaveCSS("padding-right", "8px");
await expect(sidebarScroller).toHaveCSS("padding-left", "0px");
await expect(sidebarScroller).toHaveCSS("padding-right", "0px");
await expect(scrollContent).toHaveCSS("padding-left", "3px");
await expect(scrollContent).toHaveCSS("padding-right", "3px");
const pinnedSpacerColor = await pinnedHeader.evaluate(
(element) => getComputedStyle(element, "::before").backgroundColor,
);
expect(pinnedSpacerColor).toBe("rgba(0, 0, 0, 0)");
await expect(sidebarScroller.getByTestId("open-agents-view")).toBeVisible();
const searchBox = await search.boundingBox();
const pinnedHeaderBox = await pinnedHeader.boundingBox();
const primaryMenuBox = await primaryMenu.boundingBox();
const primaryRowBox = await page
.getByTestId("open-agents-view")
.boundingBox();
const activeRowBox = await page.getByTestId("channel-general").boundingBox();
const hoverRowBox = await page.getByTestId("channel-random").boundingBox();
const scrollContentBox = await scrollContent.evaluate((element) => {
const box = element.getBoundingClientRect();
return { left: box.left, right: box.right };
});
expect(searchBox).not.toBeNull();
expect(pinnedHeaderBox).not.toBeNull();
expect(primaryMenuBox).not.toBeNull();
expect(primaryRowBox).not.toBeNull();
expect(activeRowBox).not.toBeNull();
expect(hoverRowBox).not.toBeNull();
if (
!searchBox ||
!pinnedHeaderBox ||
!primaryMenuBox ||
!primaryRowBox ||
!activeRowBox ||
!hoverRowBox
) {
throw new Error("Sidebar search or primary navigation geometry is missing");
}
expect(primaryMenuBox.y - (searchBox.y + searchBox.height)).toBe(8);
expect(
pinnedHeaderBox.y +
pinnedHeaderBox.height -
(searchBox.y + searchBox.height),
).toBe(8);
expect(primaryMenuBox.y - (pinnedHeaderBox.y + pinnedHeaderBox.height)).toBe(
0,
);
for (const rowBox of [primaryRowBox, activeRowBox, hoverRowBox]) {
expect(Math.abs(rowBox.x - searchBox.x)).toBeLessThanOrEqual(0.5);
// Linux CI reserves a classic scrollbar gutter while macOS uses an
// overlay scrollbar. Compare each row to its usable scroll area so the
// alignment check remains platform-independent.
const rowLeftSpacing = rowBox.x - scrollContentBox.left;
const rowRightSpacing = scrollContentBox.right - (rowBox.x + rowBox.width);
expect(Math.abs(rowLeftSpacing - rowRightSpacing)).toBeLessThanOrEqual(0.5);
}
await expect(page.locator("[data-buzz-sidebar-secondary]").first()).toHaveCSS(
"color",
mutedColor,
);
await expect(page.locator('[data-sidebar="trigger"]')).toHaveCSS(
"color",
chromeColor,
);
await expect(page.getByTestId("global-back")).toHaveCSS("color", chromeColor);
await expect(page.getByTestId("global-forward")).toHaveCSS(
"color",
chromeColor,
);
await expect(page.getByTestId("channel-general")).not.toHaveCSS(
"color",
mutedColor,
);
await expect(page.getByTestId("channel-general")).toHaveCSS(
"background-color",
activeSurface,
);
const hoverChannel = page.getByTestId("channel-random");
await hoverChannel.hover();
await expect(hoverChannel).toHaveCSS("background-color", hoverSurface);
const firstDmItem = page
.getByTestId("dm-list")
.locator('[data-sidebar="menu-item"]')
.first();
const firstDmButton = firstDmItem.locator('[data-sidebar="menu-button"]');
const closeDmButton = firstDmItem.getByRole("button", {
name: "Close direct message",
});
await firstDmItem.hover();
await expect(closeDmButton).toBeVisible();
await closeDmButton.hover();
await expect(firstDmButton).toHaveCSS("background-color", hoverSurface);
const scrollbarThumbColor = await sidebarScroller.evaluate(
(element) =>
getComputedStyle(element, "::-webkit-scrollbar-thumb").backgroundColor,
);
expect(scrollbarThumbColor).toBe(hoverSurface);
}
async function expectIconlessSectionTitleAligned(
page: Page,
listTestId: "stream-list" | "dm-list",
) {
const titleBox = await page
.getByTestId(`${listTestId}-section-label`)
.locator("[data-sidebar-section-title]")
.boundingBox();
const firstRowIconX = await page
.getByTestId(listTestId)
.locator('[data-sidebar="menu-button"]')
.first()
.evaluate((element) => {
const box = element.getBoundingClientRect();
const paddingLeft = Number.parseFloat(
getComputedStyle(element).paddingLeft,
);
return box.x + paddingLeft;
});
expect(titleBox).not.toBeNull();
if (!titleBox) {
throw new Error(`Sidebar section ${listTestId} is missing label geometry`);
}
expect(Math.abs(titleBox.x - firstRowIconX)).toBeLessThanOrEqual(0.5);
}
async function expectBuzzContentShadow(page: Page, mode: "light" | "dark") {
const effects = await page.evaluate(() => {
const shell = document.querySelector(".buzz-huddle-shell");
const content = document.querySelector("[data-buzz-content-surface]");
const shadowViewport = document.querySelector(
"[data-buzz-shadow-viewport]",
);
return {
appStroke: shell ? getComputedStyle(shell, "::before").boxShadow : "",
contentShadow: content ? getComputedStyle(content).boxShadow : "",
shadowViewportOverflow: shadowViewport
? getComputedStyle(shadowViewport).overflow
: "",
};
});
expect(effects.appStroke).toBe("none");
if (mode === "light") {
expect(effects.contentShadow).toContain("4px");
expect(effects.contentShadow).toContain("rgba(0, 0, 0, 0.07)");
expect(effects.shadowViewportOverflow).toBe("visible");
} else {
expect(effects.contentShadow).not.toContain("4px");
expect(effects.contentShadow).not.toContain("rgba(255, 255, 255, 0.07)");
expect(effects.shadowViewportOverflow).toBe("hidden");
}
}
async function expectBuzzGradientPaint(
page: Page,
mode: "light" | "dark",
): Promise<string> {
const paint = await page.evaluate(() => {
const root = document.documentElement;
const appSurface = document.querySelector(".buzz-huddle-app-surface");
const lightLayer = document.querySelector('[data-buzz-gradient="light"]');
const darkLayer = document.querySelector('[data-buzz-gradient="dark"]');
const sidebarRoot = document.querySelector(
'[data-testid="app-sidebar"], [data-testid="settings-sidebar"]',
);
const sidebarSurface =
sidebarRoot?.querySelector('[data-sidebar="sidebar"]') ?? sidebarRoot;
const appStyles = appSurface ? getComputedStyle(appSurface) : null;
const lightStyles = lightLayer ? getComputedStyle(lightLayer) : null;
const darkStyles = darkLayer ? getComputedStyle(darkLayer) : null;
return {
isDark: root.classList.contains("dark"),
theme: root.getAttribute("data-buzz-theme"),
surfaceImage: appStyles?.backgroundImage ?? "",
lightImage: lightStyles?.backgroundImage ?? "",
lightOpacity: lightStyles?.opacity ?? "",
darkImage: darkStyles?.backgroundImage ?? "",
darkOpacity: darkStyles?.opacity ?? "",
sidebarImage: sidebarSurface
? getComputedStyle(sidebarSurface).backgroundImage
: "",
};
});
expect(paint.theme).toBe(mode === "light" ? "buzz" : "buzz-dark");
expect(paint.isDark).toBe(mode === "dark");
expect(paint.surfaceImage).toBe("none");
expect(paint.lightImage).not.toBe("");
expect(paint.lightImage).not.toBe("none");
expect(paint.darkImage).not.toBe("");
expect(paint.darkImage).not.toBe("none");
expect(paint.lightImage).not.toBe(paint.darkImage);
expect(paint.lightOpacity).toBe(mode === "light" ? "1" : "0");
expect(paint.darkOpacity).toBe(mode === "dark" ? "1" : "0");
expect(paint.sidebarImage).toBe("none");
return mode === "light" ? paint.lightImage : paint.darkImage;
}
async function expectBuzzSettingsPalette(page: Page, mode: "light" | "dark") {
const mutedColor =
mode === "light" ? "rgba(0, 0, 0, 0.4)" : "rgba(255, 255, 255, 0.4)";
const sidebar = page.getByTestId("settings-sidebar");
const sectionLabel = sidebar
.locator('[data-sidebar="group-label"]')
.filter({ hasText: "Personal" });
await expect(sectionLabel).toHaveCSS("color", mutedColor);
await expect(page.getByTestId("settings-nav-profile")).not.toHaveCSS(
"color",
mutedColor,
);
await expectBuzzGradientPaint(page, mode);
const version = page.getByTestId("settings-version");
if ((await version.count()) > 0) {
await expect(version).toHaveCSS("color", mutedColor);
}
}
async function expectAppliedBuzzTheme(
page: Page,
themeName: "buzz" | "buzz-dark",
storedTheme: "buzz" | "buzz-dark" = themeName,
) {
const isDark = themeName === "buzz-dark";
await expect
.poll(() =>
page.evaluate((storageKey) => {
const root = document.documentElement;
const styles = getComputedStyle(root);
return {
storedTheme: window.localStorage.getItem(storageKey),
isDark: root.classList.contains("dark"),
buzzTheme: root.getAttribute("data-buzz-theme"),
gradientTop: styles.getPropertyValue("--buzz-gradient-top").trim(),
gradientBottom: styles
.getPropertyValue("--buzz-gradient-bottom")
.trim(),
};
}, THEME_STORAGE_KEY),
)
.toEqual({
storedTheme,
isDark,
buzzTheme: themeName,
gradientTop: isDark ? "#4a4616" : "#e6e6b6",
gradientBottom: isDark ? "#0a1423" : "#c4d0da",
});
}
async function emitNativeThemeChange(page: Page, theme: "light" | "dark") {
await page.evaluate(async (nextTheme) => {
const tauriWindow = window as typeof window & {
__TAURI_INTERNALS__?: {
invoke?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
};
const invoke = tauriWindow.__TAURI_INTERNALS__?.invoke;
if (!invoke) throw new Error("Mock Tauri invoke bridge is unavailable.");
await invoke("plugin:event|emit", {
event: "tauri://theme-changed",
payload: nextTheme,
});
}, theme);
}
test("buzz light sidebar gradient", async ({ page }) => {
await seedTheme(page, "buzz");
await installMockBridge(page);
await openChannel(page);
await expectBuzzGradientPaint(page, "light");
await expectBuzzSidebarPalette(page, "light");
await expectBuzzContentShadow(page, "light");
await expectIconlessSectionTitleAligned(page, "stream-list");
await expectIconlessSectionTitleAligned(page, "dm-list");
await waitForAnimations(page);
await page
.getByTestId("app-sidebar")
.screenshot({ path: `${SHOTS}/01-buzz-light-sidebar.png` });
});
test("buzz dark sidebar gradient", async ({ page }) => {
await seedTheme(page, "buzz-dark");
await installMockBridge(page);
await openChannel(page);
await expectBuzzGradientPaint(page, "dark");
await expectBuzzSidebarPalette(page, "dark");
await expectBuzzContentShadow(page, "dark");
await expectIconlessSectionTitleAligned(page, "stream-list");
await expectIconlessSectionTitleAligned(page, "dm-list");
await expect(page.locator("[data-buzz-content-surface]")).toHaveCSS(
"background-color",
"rgb(26, 26, 26)",
);
await waitForAnimations(page);
await page
.getByTestId("app-sidebar")
.screenshot({ path: `${SHOTS}/02-buzz-dark-sidebar.png` });
});
test("custom section icon and name align with channel columns", async ({
page,
}) => {
await seedTheme(page, "buzz");
await seedIconChannelSection(page);
await installMockBridge(page);
await openChannel(page);
const sectionIconBox = await page
.getByTestId("section-icon-alignment-section")
.boundingBox();
const sectionTitleBox = await page
.getByTestId("section-title-alignment-section")
.boundingBox();
const channelButton = page.getByTestId("channel-general");
const channelIconBox = await channelButton
.locator("svg")
.first()
.boundingBox();
const channelTitleBox = await channelButton
.locator("[data-sidebar-row-label]")
.boundingBox();
expect(sectionIconBox).not.toBeNull();
expect(sectionTitleBox).not.toBeNull();
expect(channelIconBox).not.toBeNull();
expect(channelTitleBox).not.toBeNull();
if (
!sectionIconBox ||
!sectionTitleBox ||
!channelIconBox ||
!channelTitleBox
) {
throw new Error("Custom section alignment geometry is missing");
}
expect(Math.abs(sectionIconBox.x - channelIconBox.x)).toBeLessThanOrEqual(
0.5,
);
expect(Math.abs(sectionTitleBox.x - channelTitleBox.x)).toBeLessThanOrEqual(
0.5,
);
});
async function openAppearance(page: Page, mode: "system" | "light" | "dark") {
// Settings renders at the AppShell level; open it via the profile card
// button, then select the Appearance section.
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await page.getByTestId("settings-nav-appearance").click();
const panel = page.getByTestId("settings-theme");
await expect(panel).toBeVisible({ timeout: 10_000 });
await page.getByTestId(`appearance-mode-${mode}`).click();
await waitForAnimations(page);
return panel;
}
test("appearance picker — system tab (Buzz follows OS)", async ({ page }) => {
await seedTheme(page, "buzz");
await installMockBridge(page);
const panel = await openAppearance(page, "system");
await panel.screenshot({ path: `${SHOTS}/03-picker-system.png` });
});
test("appearance picker — light tab (Buzz)", async ({ page }) => {
await seedTheme(page, "buzz");
await installMockBridge(page);
const panel = await openAppearance(page, "light");
await panel.screenshot({ path: `${SHOTS}/04-picker-light.png` });
});
test("appearance picker — dark tab (Buzz Dark)", async ({ page }) => {
await seedTheme(page, "buzz-dark");
await installMockBridge(page);
const panel = await openAppearance(page, "dark");
await panel.screenshot({ path: `${SHOTS}/05-picker-dark.png` });
});
test("settings nav uses Buzz active pill + hover (light)", async ({ page }) => {
await seedTheme(page, "buzz");
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 10_000 });
const profileRow = page.getByTestId("settings-nav-profile");
const profileLabel = profileRow.locator('[data-sidebar="menu-label"]');
await expect(profileRow).toHaveAttribute("data-active", "true");
await expect(profileRow).toHaveCSS("font-weight", "600");
const selectedLabelBox = await profileLabel.boundingBox();
// Appearance is the active section here; its nav row should carry the Buzz
// white active pill (data-active=true), matching the Left Nav treatment.
await page.getByTestId("settings-nav-appearance").click();
await expect(profileRow).toHaveCSS("font-weight", "400");
const unselectedLabelBox = await profileLabel.boundingBox();
expect(selectedLabelBox).not.toBeNull();
expect(unselectedLabelBox).not.toBeNull();
if (!selectedLabelBox || !unselectedLabelBox) {
throw new Error("Settings nav label geometry is missing");
}
expect(Math.abs(selectedLabelBox.width - unselectedLabelBox.width)).toBe(0);
await expectBuzzSettingsPalette(page, "light");
const activeRow = page.getByTestId("settings-nav-appearance");
await expect(activeRow).toHaveAttribute("data-active", "true");
await waitForAnimations(page);
await sidebar.screenshot({ path: `${SHOTS}/06-settings-nav-light.png` });
});
test("settings nav uses Buzz active pill + hover (dark)", async ({ page }) => {
await seedTheme(page, "buzz-dark");
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 10_000 });
await page.getByTestId("settings-nav-appearance").click();
await expectBuzzSettingsPalette(page, "dark");
await expect(page.getByTestId("settings-content-surface")).toHaveCSS(
"background-color",
"rgb(26, 26, 26)",
);
await waitForAnimations(page);
await sidebar.screenshot({ path: `${SHOTS}/07-settings-nav-dark.png` });
await page.getByTestId("settings-view").screenshot({
path: `${SHOTS}/09-settings-content-dark.png`,
});
});
test("settings content uses the same inset surface as the main app", async ({
page,
}) => {
await seedTheme(page, "buzz");
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
const searchBox = await page.getByTestId("open-search").boundingBox();
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
const settingsView = page.getByTestId("settings-view");
const contentSurface = page.getByTestId("settings-content-surface");
const backToAppBox = await page
.getByTestId("settings-back-to-app")
.boundingBox();
await expect(contentSurface).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("settings-content-scroll")).toHaveCSS(
"padding-top",
"24px",
);
const viewBox = await settingsView.boundingBox();
const surfaceBox = await contentSurface.boundingBox();
expect(searchBox).not.toBeNull();
expect(backToAppBox).not.toBeNull();
expect(viewBox).not.toBeNull();
expect(surfaceBox).not.toBeNull();
if (!searchBox || !backToAppBox || !viewBox || !surfaceBox) {
throw new Error("Settings layout is missing");
}
expect(Math.abs(backToAppBox.y - searchBox.y)).toBeLessThanOrEqual(0.5);
// Match the normal app shell: a fixed 40px top chrome strip, then a 1px
// top/left inset and 8px right/bottom inset around the rounded content card.
expect(surfaceBox.y - viewBox.y).toBe(41);
expect(surfaceBox.x - viewBox.x).toBe(1);
expect(viewBox.x + viewBox.width - (surfaceBox.x + surfaceBox.width)).toBe(8);
expect(viewBox.y + viewBox.height - (surfaceBox.y + surfaceBox.height)).toBe(
8,
);
await waitForAnimations(page);
await settingsView.screenshot({
path: `${SHOTS}/08-settings-content-inset.png`,
});
});
test("appearance hides accent picker under Buzz", async ({ page }) => {
await seedTheme(page, "buzz");
await installMockBridge(page);
const panel = await openAppearance(page, "light");
// The accent picker is hidden while a Buzz theme is active. Its neutral
// swatch testid must not be present.
await expect(page.getByTestId("accent-color-neutral")).toHaveCount(0);
await panel.screenshot({ path: `${SHOTS}/10-appearance-no-accent.png` });
});
test("accent picker reveals/hides when toggling Buzz", async ({ page }) => {
// Start on a non-Buzz theme so the accent picker is present, then select the
// Buzz tile — the picker should animate out and unmount. Reselecting a
// non-Buzz tile brings it back. Asserts the presence toggle (the motion
// wrapper) works end to end.
await seedTheme(page, "github-light");
await installMockBridge(page);
await openAppearance(page, "light");
await expect(page.getByTestId("accent-color-neutral")).toBeVisible();
// Switch to Buzz — picker should leave (allow the exit animation to settle).
await page.getByTestId("theme-option-buzz").click();
await expect(page.getByTestId("accent-color-neutral")).toHaveCount(0);
// Back to a non-Buzz theme — picker returns.
await page.getByTestId("theme-option-github-light").click();
await expect(page.getByTestId("accent-color-neutral")).toBeVisible();
});
test("Buzz light and dark modes apply live without a reload", async ({
page,
}) => {
await seedTheme(page, "buzz");
await installMockBridge(page);
await openAppearance(page, "light");
await expectAppliedBuzzTheme(page, "buzz");
const lightGradient = await expectBuzzGradientPaint(page, "light");
await page.getByTestId("appearance-mode-dark").click();
await expectAppliedBuzzTheme(page, "buzz-dark");
const darkGradient = await expectBuzzGradientPaint(page, "dark");
expect(darkGradient).not.toBe(lightGradient);
await page.getByTestId("appearance-mode-light").click();
await expectAppliedBuzzTheme(page, "buzz");
await expectBuzzGradientPaint(page, "light");
// Exercise the overlap that previously let a slower, stale theme load win.
await page.getByTestId("appearance-mode-dark").click();
await page.getByTestId("appearance-mode-light").click();
await expectAppliedBuzzTheme(page, "buzz");
});
test("Buzz follows native system theme changes without a reload", async ({
page,
}) => {
await seedTheme(page, "buzz");
await page.addInitScript(() => {
(window as typeof window & { isTauri?: boolean }).isTauri = true;
});
await installMockBridge(page);
await openAppearance(page, "system");
await emitNativeThemeChange(page, "dark");
await expectAppliedBuzzTheme(page, "buzz-dark", "buzz");
await expectBuzzGradientPaint(page, "dark");
await emitNativeThemeChange(page, "light");
await expectAppliedBuzzTheme(page, "buzz", "buzz");
await expectBuzzGradientPaint(page, "light");
});
@@ -0,0 +1,801 @@
import { expect, test, type Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SELF_PUBKEY = "deadbeef".repeat(8);
const CHANNEL_GENERAL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const AGENT_PUBKEY = TEST_IDENTITIES.charlie.pubkey;
type MockMessageEvent = {
id: string;
created_at: number;
pubkey: string;
};
type MockInboxFeedItem = {
content: string;
id: string;
tags: string[][];
};
async function waitForMockLiveSubscription(page: Page, channelName: string) {
await expect
.poll(() =>
page.evaluate(
(name) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: name,
}) ?? false,
channelName,
),
)
.toBe(true);
}
async function emitMockMessage(
page: Page,
content: string,
options: {
parentEventId?: string;
pubkey: string;
createdAt: number;
mentionPubkeys?: string[];
},
): Promise<MockMessageEvent> {
const event = await page.evaluate(
({ body, parentEventId, pubkey, createdAt, mentionPubkeys }) =>
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
parentEventId?: string;
pubkey: string;
createdAt: number;
mentionPubkeys?: string[];
}) => MockMessageEvent;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: body,
parentEventId,
pubkey,
createdAt,
mentionPubkeys,
}),
{
body: content,
parentEventId: options.parentEventId,
pubkey: options.pubkey,
createdAt: options.createdAt,
mentionPubkeys: options.mentionPubkeys,
},
);
if (!event) {
throw new Error("Mock message emitter is unavailable");
}
return event;
}
async function pushMockInboxFeedItems(page: Page, items: MockInboxFeedItem[]) {
await page.waitForFunction(
() =>
typeof (window as Window & { __BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: unknown })
.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function",
);
await page.evaluate(
({ channelId, feedItems, senderPubkey }) => {
const pushFeedItem = (
window as Window & {
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: {
category: "mention";
channel_id: string;
channel_name: string;
channel_type: "stream";
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
}) => void;
}
).__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!pushFeedItem) {
throw new Error("Mock feed injection helper is unavailable");
}
const createdAt = Math.floor(Date.now() / 1_000) - 300;
for (const item of feedItems) {
pushFeedItem({
category: "mention",
channel_id: channelId,
channel_name: "general",
channel_type: "stream",
content: item.content,
created_at: createdAt,
id: item.id,
kind: 9,
pubkey: senderPubkey,
tags: item.tags,
});
}
},
{
channelId: CHANNEL_GENERAL,
feedItems: items,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
}
async function getForcedUnreadSources(page: Page): Promise<string[]> {
return page.evaluate(
({ channelId, pubkey }) => {
const raw = window.localStorage.getItem(
`buzz-forced-unread.v1:${pubkey}`,
);
if (!raw) return [];
const entry = JSON.parse(raw)?.[channelId];
if (entry === undefined) return [];
return typeof entry === "object" && entry !== null
? (entry.sources ?? [])
: ["manual"];
},
{ channelId: CHANNEL_GENERAL, pubkey: SELF_PUBKEY },
);
}
async function seedChannelActivity(
page: Page,
{
extraThreadCount = 0,
includeAgent = true,
}: { extraThreadCount?: number; includeAgent?: boolean } = {},
) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const ownRoot = await emitMockMessage(
page,
"How should the handoff work for longer agent tasks?",
{
pubkey: SELF_PUBKEY,
createdAt: Math.floor(Date.now() / 1000) - 20,
},
);
const extraRoots = await Promise.all(
Array.from({ length: extraThreadCount }, (_, index) =>
emitMockMessage(page, `Overflow preview thread ${index + 1}`, {
pubkey: SELF_PUBKEY,
createdAt: Math.floor(Date.now() / 1000) - 19 + index,
}),
),
);
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
const unreadAt = Math.floor(Date.now() / 1000) + 60;
await emitMockMessage(
page,
"I tightened the empty state and added the direct thread link.",
{
parentEventId: "mock-general-welcome",
pubkey: TEST_IDENTITIES.alice.pubkey,
createdAt: unreadAt,
mentionPubkeys: [SELF_PUBKEY],
},
);
await emitMockMessage(
page,
"The updated interaction keeps context visible without opening the channel first.",
{
parentEventId: ownRoot.id,
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: unreadAt + 1,
},
);
await Promise.all(
extraRoots.map((root, index) =>
emitMockMessage(
page,
`A new reply in overflow preview thread ${index + 1}`,
{
parentEventId: root.id,
pubkey: TEST_IDENTITIES.alice.pubkey,
createdAt: unreadAt + 2 + index,
},
),
),
);
if (includeAgent) {
await page.waitForFunction(
() =>
typeof (window as Window & { __BUZZ_E2E_SEED_ACTIVE_TURNS__?: unknown })
.__BUZZ_E2E_SEED_ACTIVE_TURNS__ === "function",
);
await page.evaluate(
({ agentPubkey, channelId }) => {
(
window as Window & {
__BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: {
agentPubkey: string;
channelId: string;
turnId: string;
}) => void;
}
).__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({
agentPubkey,
channelId,
turnId: "channel-hover-preview",
});
},
{ agentPubkey: AGENT_PUBKEY, channelId: CHANNEL_GENERAL },
);
}
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
if (includeAgent) {
await expect(page.getByTestId("channel-working-general")).toBeVisible();
}
}
async function openActivityPopover(page: Page) {
await page.getByTestId("channel-general").hover();
const popover = page.getByTestId("channel-activity-popover-general");
await expect(popover).toBeVisible();
return popover;
}
test.describe("channel activity hover preview", () => {
test.beforeEach(async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: "Charlie",
status: "running",
channelNames: ["general"],
},
],
});
});
test("shows unread channel activity and working agents, then opens the selected thread", async ({
page,
}) => {
await seedChannelActivity(page, { extraThreadCount: 5 });
const popover = await openActivityPopover(page);
await waitForAnimations(page);
const enterScale = await popover.evaluate((element) =>
getComputedStyle(element).getPropertyValue("--tw-enter-scale"),
);
expect(enterScale).toBe("1");
const heading = popover.getByRole("heading", {
name: "Channel activity",
});
await expect(heading).toBeVisible();
await expect(heading).toHaveCSS("backdrop-filter", /blur/);
const activityScroll = popover.getByTestId("channel-activity-scroll");
await expect(activityScroll).toHaveCSS("overflow-y", "auto");
await expect(activityScroll.getByRole("heading")).toHaveCount(0);
await expect
.poll(() =>
activityScroll.evaluate(
(element) => element.scrollHeight > element.clientHeight,
),
)
.toBe(true);
await waitForAnimations(page);
const headingY = (await heading.boundingBox())?.y;
const scrollY = (await activityScroll.boundingBox())?.y;
const headingBottom = await heading.evaluate(
(element) => element.getBoundingClientRect().bottom,
);
expect(scrollY).toBeGreaterThanOrEqual(headingBottom - 1);
await activityScroll.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await expect
.poll(() => activityScroll.evaluate((element) => element.scrollTop))
.toBeGreaterThan(0);
const scrolledHeadingY = (await heading.boundingBox())?.y;
expect(headingY).toBeDefined();
expect(scrolledHeadingY).toBeDefined();
expect(Math.abs((scrolledHeadingY ?? 0) - (headingY ?? 0))).toBeLessThan(
0.5,
);
await activityScroll.evaluate((element) => {
element.scrollTop = 0;
});
await expect
.poll(() =>
activityScroll.evaluate(
(element) =>
getComputedStyle(element, "::-webkit-scrollbar-thumb")
.backgroundColor,
),
)
.toMatch(/0\.15\)/);
await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(7);
await expect(popover).toContainText(
"I tightened the empty state and added the direct thread link.",
);
await expect(popover.getByRole("heading")).toHaveCount(1);
await expect(
popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`),
).toContainText("Charlie");
await expect(
popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`),
).toContainText("Working");
const orderedRows = popover.locator(
'[data-testid^="channel-activity-agent-"], [data-testid^="channel-activity-item-"]',
);
await expect(orderedRows.first()).toHaveAttribute(
"data-testid",
`channel-activity-agent-${AGENT_PUBKEY}`,
);
await waitForAnimations(page);
await page.screenshot({
clip: { x: 0, y: 80, width: 720, height: 640 },
path: "test-results/channel-activity-hover/channel-activity-popover.png",
});
const targetRow = popover
.getByTestId(/^channel-activity-item-/)
.filter({ hasText: "direct thread link" });
await targetRow.getByRole("button", { name: /Open thread/ }).click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await expect(threadPanel).toContainText("direct thread link");
});
test("removes the dot and preview after the final activity is read", async ({
page,
}) => {
await seedChannelActivity(page, { includeAgent: false });
const popover = await openActivityPopover(page);
await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(2);
for (let remaining = 1; remaining >= 0; remaining -= 1) {
const row = popover.getByTestId(/^channel-activity-item-/).first();
await row.hover();
await row.getByRole("button", { name: "Mark as read" }).click();
await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(
remaining,
);
}
await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0);
await expect(
page.getByTestId("channel-activity-popover-general"),
).toHaveCount(0);
});
test("groups multiple unread replies against the previewed channel", async ({
page,
}) => {
await seedChannelActivity(page, { includeAgent: false });
await emitMockMessage(
page,
"A second unread reply should stay grouped with this thread.",
{
parentEventId: "mock-general-welcome",
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: Math.floor(Date.now() / 1_000) + 120,
mentionPubkeys: [SELF_PUBKEY],
},
);
const popover = await openActivityPopover(page);
const groupedRow = popover
.getByTestId(/^channel-activity-item-/)
.filter({ hasText: "direct thread link" });
await expect(groupedRow).toContainText("2 unread");
});
test("marks projected thread activity read from the active channel menu", async ({
page,
}) => {
await seedChannelActivity(page, { includeAgent: false });
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
await page.getByTestId("channel-general").click({ button: "right" });
await expect(
page.getByRole("menuitem", { name: "Mark as read" }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Mark unread" }),
).toHaveCount(0);
await page.getByRole("menuitem", { name: "Mark as read" }).click();
await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0);
});
test("marks projected thread activity read in the active channel with Shift+Escape", async ({
page,
}) => {
await seedChannelActivity(page, { includeAgent: false });
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
const shortcutHandled = await page.evaluate(() => {
const event = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: "Escape",
shiftKey: true,
});
window.dispatchEvent(event);
return event.defaultPrevented;
});
expect(shortcutHandled).toBe(true);
await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0);
await page.getByTestId("channel-general").hover();
await expect(
page.getByTestId("channel-activity-popover-general"),
).toHaveCount(0);
});
test("supports row actions and opens an agent's scoped activity", async ({
page,
}) => {
await seedChannelActivity(page);
let popover = await openActivityPopover(page);
const initialRows = popover.getByTestId(/^channel-activity-item-/);
await expect(initialRows).toHaveCount(2);
const firstRow = initialRows.first();
await firstRow.hover();
await firstRow.getByRole("button", { name: "Mark as read" }).click();
await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(1);
const remainingRow = popover.getByTestId(/^channel-activity-item-/).first();
await remainingRow.hover();
await remainingRow.getByRole("button", { name: "Remind me later" }).click();
await expect(
page.getByRole("dialog").getByText("Remind me later"),
).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
popover = await openActivityPopover(page);
await popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`).click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("agent-session-agent-name")).toContainText(
"Charlie",
);
await expect(page.getByTestId("agent-session-scope-label")).toContainText(
"#general",
);
});
test("keeps multiple Inbox threads visible after opening their channel", async ({
page,
}) => {
const inboxItemIds = [
"older-inbox-thread-for-hover",
"second-inbox-thread-for-hover",
];
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByRole("button", { name: "Inbox", exact: true }).click();
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await pushMockInboxFeedItems(
page,
inboxItemIds.map((id, index) => ({
content:
index === 0
? "Older Inbox thread reopened for hover testing."
: "A second Inbox thread reopened for hover testing.",
id,
tags: [
["h", CHANNEL_GENERAL],
[
"e",
index === 0
? "older-inbox-thread-root"
: "second-inbox-thread-root",
"",
"root",
],
[
"e",
index === 0
? "older-inbox-thread-parent"
: "second-inbox-thread-parent",
"",
"reply",
],
["p", TEST_IDENTITIES.tyler.pubkey],
],
})),
);
for (const itemId of inboxItemIds) {
const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`);
await expect(inboxRow).toBeVisible();
await inboxRow.hover();
await inboxRow.getByRole("button", { name: "Mark as read" }).click();
await inboxRow.hover();
await inboxRow.getByRole("button", { name: "Mark unread" }).click();
}
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"700",
);
for (const [index, itemId] of inboxItemIds.entries()) {
const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`);
await inboxRow.hover();
await inboxRow.getByRole("button", { name: "Mark as read" }).click();
if (index === 0) {
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"700",
);
}
}
await expect(page.getByTestId("channel-general")).not.toHaveCSS(
"font-weight",
"700",
);
for (const itemId of inboxItemIds) {
const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`);
await inboxRow.hover();
await inboxRow.getByRole("button", { name: "Mark unread" }).click();
}
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
await page.mouse.move(900, 680);
let popover = await openActivityPopover(page);
await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(2);
await expect(popover).toContainText("Older Inbox thread reopened");
await expect(popover).toContainText("A second Inbox thread reopened");
await expect(popover.getByText("Thread", { exact: true })).toHaveCount(2);
await popover
.getByTestId(/^channel-activity-item-/)
.filter({ hasText: "Older Inbox thread reopened" })
.getByRole("button", { name: /Open thread/ })
.click();
await expect(popover).toBeHidden();
await page.mouse.move(900, 680);
popover = await openActivityPopover(page);
await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(1);
await expect(popover).not.toContainText("Older Inbox thread reopened");
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"400",
);
await page.mouse.move(900, 680);
await expect(popover).toBeHidden();
await page.getByRole("button", { name: "Inbox", exact: true }).click();
const topLevelItemId = "top-level-inbox-item-for-channel-read";
await pushMockInboxFeedItems(page, [
{
content: "Top-level Inbox item reopened for channel read testing.",
id: topLevelItemId,
tags: [
["h", CHANNEL_GENERAL],
["p", TEST_IDENTITIES.tyler.pubkey],
],
},
]);
const topLevelInboxRow = page.getByTestId(
`home-inbox-item-${topLevelItemId}`,
);
await expect(topLevelInboxRow).toBeVisible();
await topLevelInboxRow.hover();
await topLevelInboxRow.getByRole("button", { name: "Mark unread" }).click();
await page.getByTestId("channel-general").click({ button: "right" });
await page.getByRole("menuitem", { name: "Mark as read" }).click();
await topLevelInboxRow.hover();
await expect(
topLevelInboxRow.getByRole("button", { name: "Mark unread" }),
).toBeVisible();
await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0);
await page.getByTestId("channel-general").hover();
await expect(
page.getByTestId("channel-activity-popover-general"),
).toHaveCount(0);
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(page.getByTestId("channel-general")).not.toHaveCSS(
"font-weight",
"700",
);
});
test("reading a grouped Inbox thread preserves an unrelated manual unread", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const manualUnreadMessage = await emitMockMessage(
page,
"Keep this separate timeline message unread.",
{
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: Math.floor(Date.now() / 1_000),
},
);
const manualUnreadRow = page
.getByTestId("message-row")
.filter({ hasText: "Keep this separate timeline message unread." });
await manualUnreadRow.hover();
await page.getByTestId(`more-actions-${manualUnreadMessage.id}`).click();
await page
.getByTestId(`mark-read-toggle-${manualUnreadMessage.id}`)
.click();
await page.getByRole("button", { name: "Inbox", exact: true }).click();
const groupedRootId = "grouped-inbox-root-preserve-manual";
const groupedReplyId = "grouped-inbox-reply-preserve-manual";
await pushMockInboxFeedItems(page, [
{
content: "Grouped Inbox root for manual unread preservation.",
id: groupedRootId,
tags: [
["h", CHANNEL_GENERAL],
["p", TEST_IDENTITIES.tyler.pubkey],
],
},
{
content: "Grouped Inbox reply marked read independently.",
id: groupedReplyId,
tags: [
["h", CHANNEL_GENERAL],
["e", groupedRootId, "", "root"],
["e", groupedRootId, "", "reply"],
["p", TEST_IDENTITIES.tyler.pubkey],
],
},
]);
const groupedInboxRow = page.getByTestId(
`home-inbox-item-${groupedReplyId}`,
);
await expect(groupedInboxRow).toBeVisible();
await groupedInboxRow.hover();
await groupedInboxRow.getByRole("button", { name: "Mark as read" }).click();
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"700",
);
});
test("preserves Inbox ownership while another top-level row remains unread", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByRole("button", { name: "Inbox", exact: true }).click();
const topLevelItemIds = [
"first-top-level-inbox-owner",
"second-top-level-inbox-owner",
];
await pushMockInboxFeedItems(
page,
topLevelItemIds.map((id, index) => ({
content: `Top-level Inbox owner ${index + 1}.`,
id,
tags: [
["h", CHANNEL_GENERAL],
["p", TEST_IDENTITIES.tyler.pubkey],
],
})),
);
for (const itemId of topLevelItemIds) {
const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`);
await expect(inboxRow).toBeVisible();
await inboxRow.hover();
await inboxRow.getByRole("button", { name: "Mark unread" }).click();
}
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"700",
);
await expect.poll(() => getForcedUnreadSources(page)).toEqual(["inbox"]);
await page.getByRole("button", { name: "Inbox", exact: true }).click();
for (const [index, itemId] of topLevelItemIds.entries()) {
const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`);
await inboxRow.hover();
await inboxRow.getByRole("button", { name: "Mark as read" }).click();
if (index === 0) {
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"700",
);
await expect
.poll(() => getForcedUnreadSources(page))
.toEqual(["inbox"]);
}
}
await expect.poll(() => getForcedUnreadSources(page)).toEqual([]);
});
test("surfaces future replies after the user reacts to a thread root", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const root = await emitMockMessage(
page,
"Reacting here means I care about future replies.",
{
pubkey: TEST_IDENTITIES.alice.pubkey,
createdAt: Math.floor(Date.now() / 1_000) - 20,
},
);
const rootRow = page
.getByTestId("message-row")
.filter({ hasText: "Reacting here means I care" });
await expect(rootRow).toBeVisible();
await rootRow.hover();
const actionBar = page.getByTestId(`message-action-bar-${root.id}`);
await expect(actionBar).toBeVisible();
await actionBar
.getByRole("button", { name: /^React with / })
.first()
.click();
await expect(
rootRow.getByRole("button", { name: /^Toggle .* reaction$/ }),
).toBeVisible();
await page.getByTestId("channel-random").click();
await emitMockMessage(
page,
"This follow-up appears because the root was reacted to.",
{
parentEventId: root.id,
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: Math.floor(Date.now() / 1_000) + 60,
},
);
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
const popover = await openActivityPopover(page);
await expect(popover).toContainText(
"This follow-up appears because the root was reacted to.",
);
});
});
@@ -0,0 +1,46 @@
import { test } from "@playwright/test";
import { installMockBridge, openChannelBrowser } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
const OUTDIR = "test-results/channel-add";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
test("capture: add-channel default state", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-dialog").waitFor();
await waitForAnimations(page);
await page.screenshot({ path: `${OUTDIR}/01-add-channel-default.png` });
});
test("capture: create row on partial match", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill("desig");
await page.getByTestId("channel-browser-create-row").waitFor();
await waitForAnimations(page);
await page.screenshot({ path: `${OUTDIR}/02-create-row-partial.png` });
});
test("capture: create row on no match", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill("release-notes");
await page.getByTestId("channel-browser-create-row").waitFor();
await waitForAnimations(page);
await page.screenshot({ path: `${OUTDIR}/03-create-row-no-match.png` });
});
test("capture: prefilled create form", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill("release-notes");
await page.getByTestId("channel-browser-create-row").click();
await page.getByTestId("create-channel-name").waitFor();
await waitForAnimations(page);
await page.screenshot({ path: `${OUTDIR}/04-create-form.png` });
});
+552
View File
@@ -0,0 +1,552 @@
import { expect, test, type Page } from "@playwright/test";
import { installMockBridge, openChannelBrowser } from "../helpers/bridge";
const MOCK_PUBKEY = "deadbeef".repeat(8);
const CUSTOM_SECTION = { id: "sec-projects", name: "Projects", order: 0 };
async function seedCustomSection(page: Page) {
await page.addInitScript(
({ pubkey, section }) => {
window.localStorage.setItem(
`buzz-channel-sections.v1:${pubkey}`,
JSON.stringify({ version: 1, sections: [section], assignments: {} }),
);
},
{ pubkey: MOCK_PUBKEY, section: CUSTOM_SECTION },
);
}
test.beforeEach(async ({ page }, testInfo) => {
await installMockBridge(
page,
testInfo.title.includes("failed section create")
? { createChannelErrors: ["Create failed"] }
: undefined,
);
});
test("keyboard shortcut opens the channel browser dialog", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
const isMacBrowser = await page.evaluate(() =>
/mac|iphone|ipad|ipod/i.test(navigator.platform),
);
if (isMacBrowser) {
await page.evaluate(() => {
window.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: "O",
metaKey: true,
shiftKey: true,
}),
);
});
} else {
await page.keyboard.press("Control+Shift+O");
}
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
});
test("channel browser shows channels not yet joined", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Browse channels" }),
).toBeVisible();
// "design" and "sales" are open channels the mock user is NOT a member of
await expect(page.getByTestId("browse-channel-design")).toBeVisible();
await expect(page.getByTestId("browse-channel-sales")).toBeVisible();
// "general" is a channel the mock user IS a member of — shown in "Joined" section
await expect(page.getByTestId("browse-channel-general")).toBeVisible();
});
test("channel browser sorts alphabetically or by member count", async ({
page,
}) => {
await page.goto("/");
await openChannelBrowser(page);
const rows = page.locator('[data-testid^="browse-channel-"]');
await expect(rows).toHaveText([
/#agents/,
/#all-replies/,
/#deep-history/,
/#design/,
/#engineering/,
/#general/,
/#random/,
/#sales/,
/#secret-projects/,
/#welcome-everyone/,
]);
await page.getByTestId("channel-browser-sort").click();
await page.getByTestId("channel-browser-sort-members").click();
await expect(rows).toHaveText([
/#general/,
/#agents/,
/#engineering/,
/#random/,
/#all-replies/,
/#deep-history/,
/#design/,
/#sales/,
/#secret-projects/,
/#welcome-everyone/,
]);
await expect(page.getByTestId("channel-browser-sort")).toHaveAttribute(
"aria-label",
"Sort channels: Most members",
);
});
test("channel browser sorts by recent activity", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
const rows = page.locator('[data-testid^="browse-channel-"]');
await page.getByTestId("channel-browser-sort").click();
await page.getByTestId("channel-browser-sort-recent").click();
await expect(rows).toHaveText([
/#all-replies/,
/#deep-history/,
/#general/,
/#agents/,
/#sales/,
/#engineering/,
/#design/,
/#random/,
/#secret-projects/,
/#welcome-everyone/,
]);
await expect(page.getByTestId("channel-browser-sort")).toHaveAttribute(
"aria-label",
"Sort channels: Recent",
);
});
test("channel browser search filters by name", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await page.getByTestId("channel-browser-search").fill("design");
await expect(page.getByTestId("browse-channel-design")).toBeVisible();
await expect(page.getByTestId("browse-channel-sales")).toHaveCount(0);
await expect(page.getByTestId("browse-channel-general")).toHaveCount(0);
});
test("channel browser search filters by description", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill("pipeline");
// "sales" has "pipeline" in its description
await expect(page.getByTestId("browse-channel-sales")).toBeVisible();
await expect(page.getByTestId("browse-channel-design")).toHaveCount(0);
});
test("channel browser shows no results for unmatched search", async ({
page,
}) => {
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill("zzz-nonexistent");
await expect(page.getByText("No channels match your search")).toBeVisible();
});
test("channel browser fuzzy-matches a subsequence", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
// "engr" is not a substring of "engineering", but it is an in-order
// subsequence — plain includes() would miss it, fuzzy matching finds it.
await page.getByTestId("channel-browser-search").fill("engr");
await expect(page.getByTestId("browse-channel-engineering")).toBeVisible();
await expect(page.getByTestId("browse-channel-general")).toHaveCount(0);
});
test("channel browser matches a scattered subsequence", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
// "sls" is neither a substring nor a prefix of "sales" — it only matches as
// an in-order subsequence (s·a·l·e·s). Proves fuzzy matching end-to-end.
await page.getByTestId("channel-browser-search").fill("sls");
await expect(page.getByTestId("browse-channel-sales")).toBeVisible();
await expect(page.getByTestId("browse-channel-general")).toHaveCount(0);
});
test("channel browser ranks the best match first", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
// "gen" is a prefix of "general" (strong match) but only a substring of
// "agents" and a subsequence of "engineering" (weaker). The prefix match
// should float to the top regardless of the alphabetical default sort.
await page.getByTestId("channel-browser-search").fill("gen");
const firstRow = page.getByTestId(/^browse-channel-/).first();
await expect(firstRow).toHaveAttribute(
"data-testid",
"browse-channel-general",
);
});
test("sidebar add-channel button creates without treating the click as a callback", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await page.getByTestId("section-actions-channels-quick-create").click();
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
const channelName = `sidebar-created-${Date.now()}`;
await page.getByTestId("channel-browser-search").fill(channelName);
await page.getByTestId("channel-browser-create-row").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
});
test("custom section add button creates directly into that section", async ({
page,
}) => {
await seedCustomSection(page);
await page.goto("/");
const addButton = page.getByTestId(
`section-actions-${CUSTOM_SECTION.id}-quick-create`,
);
await expect(addButton).toHaveAccessibleName("Add channel to Projects");
await addButton.click();
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
const channelName = `section-created-${Date.now()}`;
await page.getByTestId("channel-browser-search").fill(channelName);
await page.getByTestId("channel-browser-create-row").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(
page.getByTestId(`section-title-${CUSTOM_SECTION.id}`),
).toBeVisible();
await expect(page.getByTestId(`channel-${channelName}`)).toBeVisible();
await expect(page.getByTestId("stream-list")).not.toContainText(channelName);
});
test("canceling section create does not affect the next global create", async ({
page,
}) => {
await seedCustomSection(page);
await page.goto("/");
await page
.getByTestId(`section-actions-${CUSTOM_SECTION.id}-quick-create`)
.click();
// Gate on the dialog mounting before dismissing it. Escape sent before mount
// is dropped (no handler yet), and not.toBeVisible() then passes vacuously
// against a dialog that hasn't rendered — so the dialog opens *after* the
// assertion and its overlay swallows every later click.
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
// The overlay outlives the content by one exit animation; wait for it to
// detach so it can't intercept the next click.
await expect(page.getByTestId("dialog-overlay")).toHaveCount(0);
await page.getByTestId("section-actions-channels-quick-create").click();
const channelName = `global-after-cancel-${Date.now()}`;
await page.getByTestId("channel-browser-search").fill(channelName);
await page.getByTestId("channel-browser-create-row").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
});
test("failed section create retry still assigns to the section", async ({
page,
}) => {
await seedCustomSection(page);
await page.goto("/");
await page
.getByTestId(`section-actions-${CUSTOM_SECTION.id}-quick-create`)
.click();
const channelName = `section-retry-${Date.now()}`;
await page.getByTestId("channel-browser-search").fill(channelName);
await page.getByTestId("channel-browser-create-row").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByText("Create failed")).toBeVisible();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(page.getByTestId(`channel-${channelName}`)).toBeVisible();
await expect(page.getByTestId("stream-list")).not.toContainText(channelName);
});
test("create affordance is visible on open before typing", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
// The create row is present from the get-go so it's clear you can browse OR
// create — not just after you start typing.
const createRow = page.getByTestId("channel-browser-create-row");
await expect(createRow).toBeVisible();
await expect(createRow).toContainText("Create a new channel");
});
test("typing a partial match surfaces a persistent create row", async ({
page,
}) => {
await page.goto("/");
await openChannelBrowser(page);
// "desig" matches "design" by substring but is not an exact channel name,
// so both the matching channel AND the create row are shown.
await page.getByTestId("channel-browser-search").fill("desig");
const createRow = page.getByTestId("channel-browser-create-row");
await expect(createRow).toBeVisible();
await expect(createRow).toContainText("desig");
await expect(page.getByTestId("browse-channel-design")).toBeVisible();
});
test("exact name match hides the create row", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill("general");
await expect(page.getByTestId("browse-channel-general")).toBeVisible();
await expect(page.getByTestId("channel-browser-create-row")).toHaveCount(0);
});
test("no-match search pins a create row above the empty state", async ({
page,
}) => {
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill("zzz-nonexistent");
await expect(page.getByText("No channels match your search")).toBeVisible();
const createRow = page.getByTestId("channel-browser-create-row");
await expect(createRow).toBeVisible();
await expect(createRow).toContainText("zzz-nonexistent");
});
test("create row leads to the prefilled create form", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill("desig");
await page.getByTestId("channel-browser-create-row").click();
// Create mode reuses the shared form; the name is prefilled from the query.
await expect(page.getByTestId("create-channel-name")).toHaveValue("desig");
// Back returns to the search list without closing the dialog.
await page.getByTestId("channel-browser-create-back").click();
await expect(page.getByTestId("channel-browser-search")).toBeVisible();
});
test("creating from the browser adds the channel to the sidebar", async ({
page,
}) => {
const channelName = `browse-created-${Date.now()}`;
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill(channelName);
await page.getByTestId("channel-browser-create-row").click();
await expect(page.getByTestId("create-channel-name")).toHaveValue(
channelName,
);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toContainText(channelName);
});
test("Enter with no matches jumps to create", async ({ page }) => {
const channelName = `enter-created-${Date.now()}`;
await page.goto("/");
await openChannelBrowser(page);
await page.getByTestId("channel-browser-search").fill(channelName);
await page.keyboard.press("Enter");
await expect(page.getByTestId("create-channel-name")).toHaveValue(
channelName,
);
});
test("arrow keys reach the pinned create row and Enter activates it", async ({
page,
}) => {
await page.goto("/");
await openChannelBrowser(page);
// "desig" keeps a channel match (#design) AND the create row visible, so the
// create row is not the only actionable item — it must be reachable by
// keyboard, not just Tab.
await page.getByTestId("channel-browser-search").fill("desig");
const createRow = page.getByTestId("channel-browser-create-row");
await expect(createRow).toBeVisible();
// The create row is pinned at the top → first ArrowDown highlights it.
await page.keyboard.press("ArrowDown");
await expect(createRow).toHaveAttribute("data-selected", "true");
// Enter on the highlighted create row enters the prefilled create form.
await page.keyboard.press("Enter");
await expect(page.getByTestId("create-channel-name")).toHaveValue("desig");
});
test("Enter selects a channel when create row is not highlighted", async ({
page,
}) => {
await page.goto("/");
await openChannelBrowser(page);
// With the create row present but NOT highlighted, Enter should still select
// the first channel match rather than jumping to create.
await page.getByTestId("channel-browser-search").fill("desig");
await expect(page.getByTestId("browse-channel-design")).toBeVisible();
await page.keyboard.press("Enter");
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(page.getByTestId("chat-title")).toHaveText("design");
});
test("joining a channel from browser adds it to the sidebar", async ({
page,
}) => {
await page.goto("/");
// Verify "design" is not in the sidebar
const streamList = page.getByTestId("stream-list");
await expect(streamList).not.toContainText("design");
// Open browser and join
await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await page
.getByTestId("browse-channel-design")
.getByRole("button", { name: "Join" })
.click();
// Dialog should close and navigate to the joined channel
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(page).toHaveURL(/#\/channels\//);
await expect(page.getByTestId("chat-title")).toHaveText("design");
// Channel should now appear in the sidebar
await expect(streamList).toContainText("design");
});
test("clicking a joined channel in browser navigates to it", async ({
page,
}) => {
await page.goto("/");
await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
// "general" is already joined — clicking should navigate without join
await page.getByTestId("browse-channel-general").click();
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(page).toHaveURL(/#\/channels\//);
await expect(page.getByTestId("chat-title")).toHaveText("general");
});
test("channel browser does not show DM or private channels", async ({
page,
}) => {
await page.goto("/");
await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
// DM channels should not appear
await expect(page.getByTestId("browse-channel-alice-tyler")).toHaveCount(0);
await expect(page.getByTestId("browse-channel-bob-tyler")).toHaveCount(0);
// Private forum should not appear
await expect(page.getByTestId("browse-channel-announcements")).toHaveCount(0);
});
test("channel browser closes on escape", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
});
test("keyboard navigation works in channel browser", async ({ page }) => {
await page.goto("/");
await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
// Filter to unjoined channels only to get a predictable list
await page.getByTestId("channel-browser-search").fill("design");
await expect(page.getByTestId("browse-channel-design")).toBeVisible();
// Press Enter to join the selected (first) channel
await page.keyboard.press("Enter");
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(page.getByTestId("chat-title")).toHaveText("design");
});
test("sidebar only shows channels the user has joined", async ({ page }) => {
await page.goto("/");
const streamList = page.getByTestId("stream-list");
// Channels the mock user IS a member of
await expect(streamList).toContainText("general");
await expect(streamList).toContainText("random");
await expect(streamList).toContainText("engineering");
await expect(streamList).toContainText("agents");
// Channels the mock user is NOT a member of
await expect(streamList).not.toContainText("design");
await expect(streamList).not.toContainText("sales");
});
@@ -0,0 +1,365 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
// The channel and thread composers float over their conversation scrollers.
// When the conversation is scrolled up, later rows pass underneath the
// composer overlay — the overlay must mask them (gradient fade + solid
// bottom strip, mirroring the inbox treatment from PR #2143) instead of
// letting them bleed out below the composer box.
const CHANNEL = "general";
const TYPING_KIND = 20002;
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
kind?: number,
) {
await expect
.poll(() =>
page.evaluate(
({ channelName, kind }) =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName,
kind,
}) ?? false,
{ channelName, kind },
),
)
.toBe(true);
}
async function emit(
page: import("@playwright/test").Page,
content: string,
parentEventId: string | null = null,
) {
const event = await page.evaluate(
(payload) =>
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: payload.channel,
content: payload.content,
parentEventId: payload.parentEventId,
}),
{ channel: CHANNEL, content, parentEventId },
);
if (!event) throw new Error("mock message emitter is not installed");
return event as { created_at: number; id: string };
}
type ComposerDockGeometry = {
composerBottom: number;
composerLeft: number;
composerRight: number;
composerTop: number;
dockHeight: number;
dockTop: number;
};
async function composerDockGeometry(
overlay: import("@playwright/test").Locator,
): Promise<ComposerDockGeometry> {
return overlay.evaluate((element) => {
const composer = element.querySelector<HTMLElement>(
'[data-testid="message-composer"]',
);
const dock = element.querySelector<HTMLElement>(".composer-dock");
if (!composer || !dock) throw new Error("Missing composer dock geometry");
const composerRect = composer.getBoundingClientRect();
const dockRect = dock.getBoundingClientRect();
return {
composerBottom: composerRect.bottom,
composerLeft: composerRect.left,
composerRight: composerRect.right,
composerTop: composerRect.top,
dockHeight: dockRect.height,
dockTop: dockRect.top,
};
});
}
async function expectOverlayBottomMask(
overlay: import("@playwright/test").Locator,
) {
const hasMask = await overlay.evaluate((element) => {
const after = getComputedStyle(element, "::after");
const before = getComputedStyle(element, "::before");
const composer = element.querySelector<HTMLElement>(
'[data-testid="message-composer"]',
);
const cornerMaskContainer = element.querySelector<HTMLElement>(
".composer-overlay-corner-masks",
);
if (!composer || !cornerMaskContainer) return false;
const overlayRect = element.getBoundingClientRect();
const composerRect = composer.getBoundingClientRect();
const leftMask = getComputedStyle(cornerMaskContainer, "::before");
const rightMask = getComputedStyle(cornerMaskContainer, "::after");
const tolerance = 0.5;
return (
after.content !== "none" &&
before.content !== "none" &&
leftMask.maskImage !== "none" &&
rightMask.maskImage !== "none" &&
Math.abs(
composerRect.left - overlayRect.left - Number.parseFloat(leftMask.left),
) <= tolerance &&
Math.abs(
overlayRect.right -
composerRect.right -
Number.parseFloat(rightMask.right),
) <= tolerance &&
Math.abs(
overlayRect.bottom -
composerRect.bottom -
Number.parseFloat(leftMask.bottom),
) <= tolerance &&
leftMask.bottom === rightMask.bottom
);
});
expect(hasMask).toBe(true);
}
test.describe("composer overlays mask scrolled content", () => {
test("channel timeline rows fade out behind the composer", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL}`).click();
await expect(page.getByTestId("message-timeline")).toBeVisible();
await waitForMockLiveSubscription(page, CHANNEL);
for (let i = 0; i < 20; i++) {
await emit(
page,
`Channel filler ${i} — enough text to occupy vertical space so the conversation scrolls and rows pass behind the composer overlay.`,
);
}
await page.waitForTimeout(400);
// Scroll the conversation up so trailing rows sit behind the overlay.
await page.evaluate(() => {
const scroller = document.querySelector<HTMLElement>(
'[data-buzz-conversation-scroll="true"]',
);
if (!scroller) throw new Error("Missing conversation scroll container");
scroller.scrollTop = Math.max(
0,
scroller.scrollHeight - scroller.clientHeight - 220,
);
});
await page.waitForTimeout(300);
await waitForAnimations(page);
await page.screenshot({
path: "test-results/channel-overflow/channel-composer.png",
clip: { x: 300, y: 720 - 280, width: 980, height: 280 },
});
await expectOverlayBottomMask(page.getByTestId("channel-composer-overlay"));
});
test("activity trades composer height without moving the dock", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL}`).click();
await waitForMockLiveSubscription(page, CHANNEL);
await waitForMockLiveSubscription(page, CHANNEL, TYPING_KIND);
const overlay = page.getByTestId("channel-composer-overlay");
const input = overlay.getByTestId("message-input");
await input.fill(
"A multiline draft that grows the composer.\nSecond line keeps the natural-height path active.",
);
await waitForAnimations(page);
const quiet = await composerDockGeometry(overlay);
await page.evaluate((channelName) => {
window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({ channelName });
}, CHANNEL);
await expect(
overlay.getByTestId("channel-composer-activity-row"),
).toBeVisible();
await waitForAnimations(page);
const active = await composerDockGeometry(overlay);
expect(Math.abs(active.dockTop - quiet.dockTop)).toBeLessThanOrEqual(0.5);
expect(Math.abs(active.dockHeight - quiet.dockHeight)).toBeLessThanOrEqual(
0.5,
);
expect(
Math.abs(active.composerTop - quiet.composerTop),
).toBeLessThanOrEqual(0.5);
expect(active.composerBottom).toBeLessThan(quiet.composerBottom - 8);
});
test("reduced motion removes dock geometry transitions", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL}`).click();
const dock = page
.getByTestId("channel-composer-overlay")
.locator(".composer-dock");
const spacer = dock.locator(".composer-dock-quiet-spacer");
await expect(dock).toHaveCSS("transition-duration", "0s");
await expect(spacer).toHaveCSS("transition-duration", "0s");
});
test("bottom-pinned messages clear a growing composer", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL}`).click();
await expect(page.getByTestId("message-timeline")).toBeVisible();
await waitForMockLiveSubscription(page, CHANNEL);
let newestMessageId = "";
for (let i = 0; i < 20; i++) {
newestMessageId = (await emit(page, `Bottom-clearance message ${i}`)).id;
}
await page.waitForTimeout(300);
await page.evaluate(() => {
const scroller = document.querySelector<HTMLElement>(
'[data-buzz-conversation-scroll="true"]',
);
if (!scroller) throw new Error("Missing conversation scroll container");
scroller.scrollTop = scroller.scrollHeight;
});
await page
.getByTestId("message-input")
.fill(
"A long draft grows the composer and must keep the newest message above it.\nSecond line.\nThird line.",
);
await waitForAnimations(page);
const clearsComposer = await page.evaluate((messageId) => {
const composer = document.querySelector<HTMLElement>(
'[data-testid="channel-composer-overlay"] [data-testid="message-composer"]',
);
const newestRow = document.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(messageId)}"]`,
);
if (!composer || !newestRow) return false;
return (
newestRow.getBoundingClientRect().bottom <=
composer.getBoundingClientRect().top
);
}, newestMessageId);
expect(clearsComposer).toBe(true);
await page.setViewportSize({ width: 1280, height: 620 });
await expect
.poll(() =>
page.evaluate((messageId) => {
const composer = document.querySelector<HTMLElement>(
'[data-testid="channel-composer-overlay"] [data-testid="message-composer"]',
);
const newestRow = document.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(messageId)}"]`,
);
return Boolean(
composer &&
newestRow &&
newestRow.getBoundingClientRect().bottom <=
composer.getBoundingClientRect().top,
);
}, newestMessageId),
)
.toBe(true);
});
test("thread panel replies fade out behind the reply composer", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL}`).click();
await expect(page.getByTestId("message-timeline")).toBeVisible();
await waitForMockLiveSubscription(page, CHANNEL);
const root = await emit(page, "Thread root — the discussion begins here.");
for (let i = 0; i < 20; i++) {
await emit(
page,
`Thread reply ${i} — enough text to occupy vertical space so the thread body scrolls and replies pass behind the reply composer.`,
root.id,
);
}
await page.waitForTimeout(400);
const summary = page.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${root.id}"]`,
);
await expect(summary).toBeVisible();
await summary.click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await waitForAnimations(page);
// Scroll the thread body up so trailing replies sit behind the overlay.
await page.evaluate(() => {
const scroller = document.querySelector<HTMLElement>(
'[data-testid="message-thread-body"]',
);
if (!scroller) throw new Error("Missing thread body scroll container");
scroller.scrollTop = Math.max(
0,
scroller.scrollHeight - scroller.clientHeight - 220,
);
});
await page.waitForTimeout(300);
await waitForAnimations(page);
await page.screenshot({
path: "test-results/channel-overflow/thread-composer.png",
clip: { x: 1280 - 560, y: 720 - 300, width: 560, height: 300 },
});
await expectOverlayBottomMask(page.getByTestId("thread-composer-overlay"));
await waitForMockLiveSubscription(page, CHANNEL, TYPING_KIND);
await page.evaluate(
({ channelName, createdAt, pubkey, threadHeadId }) => {
window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
channelName,
createdAt,
pubkey,
threadHeadId,
});
},
{
channelName: CHANNEL,
createdAt: root.created_at + 1,
pubkey: TEST_IDENTITIES.bob.pubkey,
threadHeadId: root.id,
},
);
const threadOverlay = page.getByTestId("thread-composer-overlay");
const activityRow = threadOverlay.getByTestId("message-typing-indicator");
await expect(activityRow).toBeVisible();
await waitForAnimations(page);
const [composerBounds, activityBounds] = await Promise.all([
threadOverlay.getByTestId("message-composer").boundingBox(),
activityRow.boundingBox(),
]);
expect(composerBounds).not.toBeNull();
expect(activityBounds).not.toBeNull();
expect(activityBounds?.x ?? 0).toBeGreaterThanOrEqual(
(composerBounds?.x ?? 0) - 0.5,
);
expect(
(activityBounds?.x ?? 0) + (activityBounds?.width ?? 0),
).toBeLessThanOrEqual(
(composerBounds?.x ?? 0) + (composerBounds?.width ?? 0) + 0.5,
);
});
});
+407
View File
@@ -0,0 +1,407 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// `general` seeds the mock identity as owner, so the owner/admin-gated
// visibility + ephemeral controls are editable. Visibility is a deferred
// draft: selecting a value only updates local state; it persists on Save.
async function openManagementSheet(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
}
async function openEditDialog(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-edit").click();
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toBeVisible();
}
async function settle(page: import("@playwright/test").Page) {
await page.evaluate(() =>
Promise.all(document.getAnimations().map((a) => a.finished)),
);
}
async function selectTemporaryChannelType(
page: import("@playwright/test").Page,
) {
await page.getByTestId("channel-management-channel-type").click();
await page.getByLabel("Temporary channel").click();
}
test.describe("channel controls", () => {
test("01 — lifecycle section: visibility + channel type", async ({
page,
}) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
await expect(
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
await expect(page.getByText(/Update settings for/)).toHaveCount(0);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await expect(
page.getByTestId("channel-management-permissions-container"),
).toBeVisible();
await expect(
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Public");
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();
await expect(
page.getByTestId("channel-management-channel-type"),
).toBeVisible();
await expect(
page.getByTestId("channel-management-channel-type"),
).toContainText("Ongoing");
await expect(
page.getByTestId("channel-management-ephemeral-settings"),
).toHaveCount(0);
await expect(
page
.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
})
.getByTestId("channel-management-topic"),
).toHaveCount(0);
await expect(
page
.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
})
.getByTestId("channel-management-purpose"),
).toHaveCount(0);
await settle(page);
});
test("02 — visibility defers to Save", async ({ page }) => {
await installMockBridge(page, { updateChannelDelayMs: 500 });
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
const permissions = page.getByTestId("channel-management-permissions");
// Save starts disabled with no pending edits.
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();
// Selecting a visibility only updates the local draft: the dialog title
// reflects the pending choice and Save becomes enabled, but nothing is
// persisted yet (no "Updating…" state).
await permissions.click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
await expect(permissions).toHaveAccessibleName("Visibility: Private");
await expect(permissions).not.toHaveAttribute("aria-busy", "true");
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
// Wait for the dropdown to fully close before reopening it, so the
// trigger stays mounted/stable for the next interaction.
await expect(
page.getByTestId("channel-management-permissions-option-private"),
).toHaveCount(0);
// Toggling back to the original value clears the draft and disables Save.
await permissions.click();
await page
.getByTestId("channel-management-permissions-option-open")
.click();
await expect(
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
await expect(permissions).toHaveAccessibleName("Visibility: Public");
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();
await expect(
page.getByTestId("channel-management-permissions-option-open"),
).toHaveCount(0);
// Choose Private again and commit via Save.
await permissions.click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
await page.getByTestId("channel-management-save-changes").click();
await expect(
page.getByTestId("channel-management-save-changes"),
).toHaveText("Saving...");
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);
// Reopen to confirm the visibility change persisted.
await openEditDialog(page);
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
await expect(
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Private");
await settle(page);
});
test("03 — Temporary type reveals expiration presets", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await selectTemporaryChannelType(page);
const ephemeralSettings = page.getByTestId(
"channel-management-ephemeral-settings",
);
await expect(ephemeralSettings).toBeVisible();
const ttl = ephemeralSettings.getByTestId("channel-management-ttl");
await expect(ttl).toBeVisible();
await expect(ttl).toHaveAttribute("aria-label", "Expires after");
await expect(ttl).toContainText("7 days");
await ttl.click();
await expect(
page.getByTestId("channel-management-ttl-option-1800"),
).toHaveText("30 minutes");
await expect(
page.getByTestId("channel-management-ttl-option-2592000"),
).toHaveText("30 days");
await page.getByTestId("channel-management-ttl-option-86400").click();
await expect(ttl).toContainText("1 day");
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
await page.getByTestId("channel-management-channel-type").click();
await page.getByLabel("Ongoing channel").click();
await expect(ephemeralSettings).toHaveCount(0);
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();
});
test("04 — changing the timeout preset keeps save enabled", async ({
page,
}) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await selectTemporaryChannelType(page);
const ttl = page.getByTestId("channel-management-ttl");
await ttl.click();
await page.getByTestId("channel-management-ttl-option-21600").click();
await expect(ttl).toContainText("6 hours");
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
});
test("05 — sticky footer pins lifecycle buttons", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
const footer = page.getByTestId("channel-management-footer");
await expect(footer).toBeVisible();
await expect(page.getByTestId("channel-management-archive")).toBeVisible();
await settle(page);
});
test("06 — full sheet with new controls", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
const sheet = page.getByTestId("channel-management-sheet");
await expect(sheet).toBeVisible();
await settle(page);
});
test("07 — saving lifecycle uses unified save", async ({ page }) => {
await installMockBridge(page, { updateChannelDelayMs: 500 });
await openManagementSheet(page);
await openEditDialog(page);
await selectTemporaryChannelType(page);
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
await page.getByTestId("channel-management-save-changes").click();
await expect(
page.getByTestId("channel-management-save-changes"),
).toHaveText("Saving...");
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);
});
test("08 — saved ephemeral lifecycle is reflected after reopen", async ({
page,
}) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
await page.getByTestId("channel-management-permissions").click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
await selectTemporaryChannelType(page);
await page.getByTestId("channel-management-save-changes").click();
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);
await page.getByTestId("auxiliary-panel-close").click();
await expect(
page.getByTestId("channel-management-sheet"),
).not.toBeVisible();
await page.getByTestId("channel-management-trigger").click();
await openEditDialog(page);
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await expect(
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Private");
await expect(
page.getByTestId("channel-management-channel-type"),
).toContainText("Temporary");
await expect(page.getByTestId("channel-management-ttl")).toContainText(
"7 days",
);
await settle(page);
});
test("09 — cancel discards unsaved channel drafts", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
await page.getByTestId("channel-management-name").fill("discarded-name");
await page
.getByRole("textbox", { name: "Description" })
.fill("This description should be discarded");
await page.getByTestId("channel-management-permissions").click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
await selectTemporaryChannelType(page);
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);
await openEditDialog(page);
await expect(page.getByTestId("channel-management-name")).toHaveValue(
"general",
);
await expect(
page.getByRole("textbox", { name: "Description" }),
).toHaveValue("General discussion for everyone");
await expect(
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
await expect(
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Public");
await expect(
page.getByTestId("channel-management-channel-type"),
).toContainText("Ongoing");
await expect(
page.getByTestId("channel-management-ephemeral-settings"),
).toHaveCount(0);
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();
});
test("10 — unsaved visibility draft does not leak to a new channel", async ({
page,
}) => {
await installMockBridge(page, { updateChannelDelayMs: 1_500 });
await openManagementSheet(page);
await openEditDialog(page);
const permissions = page.getByTestId("channel-management-permissions");
await permissions.click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
// Draft only — never saved. The dialog title reflects the pending choice.
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
const agentsChannelId = await page
.getByTestId("channel-agents")
.getAttribute("data-channel-id");
if (!agentsChannelId) {
throw new Error("Expected the agents channel id.");
}
await page.evaluate((channelId) => {
const hash = window.location.hash.replace(/^#/, "") || "/";
const [, query = ""] = hash.split("?");
const nextHash = `#/channels/${channelId}${query ? `?${query}` : ""}`;
window.history.pushState(
{},
"",
`${window.location.pathname}${nextHash}`,
);
window.dispatchEvent(new HashChangeEvent("hashchange"));
window.dispatchEvent(new PopStateEvent("popstate"));
}, agentsChannelId);
await expect(page.getByTestId("chat-title")).toHaveText("agents");
// The unsaved draft must not carry over: the new channel re-syncs from
// server state and shows its own (public) visibility.
await expect(
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
await expect(permissions).toHaveAccessibleName("Visibility: Public");
});
});
@@ -0,0 +1,168 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// Lane 1c regression — the dense-second reachability wall.
//
// A bare `until` (`created_at`) cursor cannot advance past a single
// `created_at` second holding more messages than one page: it re-returns the
// same newest slice of that second forever, so everything behind it is
// unreachable and the progress guard stalls.
//
// The channel-window read path (`get_channel_window`, NIP-CW) pages with a
// composite `(created_at, event_id)` keyset cursor instead, which advances
// within a tied second via `id > event_id` under the relay's
// `created_at DESC, id ASC` order.
//
// This test seeds one second with ~450 top-level messages (many window pages)
// sitting behind the cold-load window, then pages to the top and asserts:
// (a) a *continuation* window request fired (cursor != null) — the head load
// always issues `get_channel_window`, so only a cursor-bearing request
// proves keyset paging engaged, and
// (b) every dense-second message becomes reachable (union of rendered rows
// equals the full seed) — impossible behind a bare-`until` wall.
const DENSE_SECOND = 1_700_000_000;
const DENSE_COUNT = 450; // many multiples of CHANNEL_WINDOW_PAGE_SIZE (50)
const NEWER_COUNT = 60; // fills the cold-load window, pushing the dense block older
test("dense single second beyond one window page is fully reachable via composite keyset cursor", async ({
page,
}, testInfo) => {
testInfo.setTimeout(90_000);
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(
({ denseSecond, denseCount, newerCount }) => {
// The dense wall: `denseCount` top-level messages all at one second.
for (let index = 0; index < denseCount; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `dense ${index}`,
createdAt: denseSecond,
});
}
// Newer window so the cold load (newest CHANNEL_HISTORY_LIMIT) does NOT
// include the dense block — it must be paged into from scroll-up.
for (let index = 0; index < newerCount; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `newer ${index}`,
createdAt: denseSecond + 1 + index,
});
}
},
{
denseSecond: DENSE_SECOND,
denseCount: DENSE_COUNT,
newerCount: NEWER_COUNT,
},
);
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await page.waitForFunction(() => {
const element = document.querySelector(
'[data-testid="message-timeline"]',
) as HTMLDivElement | null;
return element ? element.scrollHeight > element.clientHeight + 500 : false;
});
// Collect the union of dense-second indices ever rendered. Virtualization
// only mounts a window of rows, so we accumulate across scroll passes rather
// than snapshot once.
const renderedDenseIndices = async () =>
timeline.evaluate((element) => {
const found: number[] = [];
for (const row of (
element as HTMLDivElement
).querySelectorAll<HTMLElement>("[data-message-id]")) {
const match = row.textContent?.match(/dense (\d+)/);
if (match) found.push(Number(match[1]));
}
return found;
});
// Drive a real wheel-up gesture each pass: the older-history sentinel arms on
// a genuine leave→enter transition (IntersectionObserver), so a raw
// `scrollTop = 0` write on the virtualized container can fail to re-fire.
// A wheel event is what a real user issues and what the observer honors.
const wheelToTop = async () => {
for (let step = 0; step < 12; step += 1) {
const atTop = await timeline.evaluate(
(element) => (element as HTMLDivElement).scrollTop <= 1,
);
if (atTop) break;
await page.mouse.wheel(0, -6000);
await page.waitForTimeout(40);
}
};
const seen = new Set<number>();
const collectRendered = async () => {
for (const index of await renderedDenseIndices()) {
seen.add(index);
}
};
await timeline.hover();
let stallStreak = 0;
for (
let attempt = 0;
attempt < 120 && seen.size < DENSE_COUNT;
attempt += 1
) {
const before = seen.size;
await wheelToTop();
// Each gesture pages a bounded step (one pass of the row-floor pager, which
// may itself engage the keyset drain). The sentinel disconnects while the
// prepend's index-restore owns scroll and only re-arms once settled, so
// poll for real growth rather than a fixed sleep.
try {
await expect
.poll(
async () => {
await collectRendered();
return seen.size;
},
{ timeout: 4_000 },
)
.toBeGreaterThan(before);
} catch {
// No growth this pass — count it toward a genuine stall.
}
await collectRendered();
if (seen.size > before) {
stallStreak = 0;
} else {
stallStreak += 1;
if (stallStreak > 8) break;
}
}
// (a) Keyset paging actually engaged — the head load always issues
// `get_channel_window` with a null cursor, so require at least one
// continuation request carrying a composite cursor.
const continuationRequests = await page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(entry) =>
entry.command === "get_channel_window" &&
(entry.payload as { cursor?: unknown } | null)?.cursor != null,
).length,
);
expect(continuationRequests).toBeGreaterThan(0);
// (b) Reachability parity: the union of paged dense rows crosses far past
// one window page — impossible behind a bare-`until` wall, where paging
// stalls on the newest slice of the dense second. We assert the vast
// majority became reachable; virtualization can drop a few transient rows
// between scroll settles, so we allow a small slack rather than demanding
// an exact 450.
expect(seen.size).toBeGreaterThan(DENSE_COUNT * 0.9);
});
+190
View File
@@ -0,0 +1,190 @@
import { expect, test } from "@playwright/test";
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
const MOCK_PUBKEY = "deadbeef".repeat(8);
const ENGINEERING_CHANNEL_ID = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9";
const MUTE_STORAGE_KEY = `buzz-channel-mutes.v1:${MOCK_PUBKEY}`;
function seedMuteState(
page: import("@playwright/test").Page,
channelId: string,
) {
return page.addInitScript(
({ key, id }) => {
localStorage.setItem(
key,
JSON.stringify({
version: 1,
channels: {
[id]: { muted: true, updatedAt: 1700000000 },
},
}),
);
},
{ key: MUTE_STORAGE_KEY, id: channelId },
);
}
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
) {
await expect
.poll(async () => {
return page.evaluate(
({ ch }) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ??
false,
{ ch: channelName },
);
})
.toBe(true);
}
test.describe("channel muting", () => {
test("01 — context menu shows Mute channel", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("channel-general").click({ button: "right" });
const muteItem = page.getByRole("menuitem", { name: "Mute channel" });
await expect(muteItem).toBeVisible();
await muteItem.evaluate((el) =>
Promise.all(
el
.closest("[data-state]")
?.getAnimations()
.map((a) => a.finished) ?? [],
),
);
});
test("02 — muted channel is dimmed with BellOff icon", async ({ page }) => {
await seedMuteState(page, ENGINEERING_CHANNEL_ID);
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
const engRow = page.getByTestId("channel-engineering");
await expect(engRow).toBeVisible();
await expect(engRow).toHaveCSS("opacity", "1");
await expect(engRow.locator("[data-sidebar-row-label]")).toHaveCSS(
"opacity",
"0.5",
);
await expect(engRow.locator("svg.lucide-hash")).toHaveCSS("opacity", "0.5");
await expect(engRow.locator("svg.lucide-bell-off")).toHaveCount(1);
});
test("02b — muted channels recede further in dark mode", async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("buzz-theme", "buzz-dark");
});
await seedMuteState(page, ENGINEERING_CHANNEL_ID);
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-random").click();
const mutedLabel = page
.getByTestId("channel-engineering")
.locator("[data-sidebar-row-label]");
await expect(mutedLabel).toHaveCSS("opacity", "0.45");
});
test("03 — muted channel with a top-level @mention is emphasized", async ({
page,
}) => {
await seedMuteState(page, ENGINEERING_CHANNEL_ID);
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-engineering").click();
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
await waitForMockLiveSubscription(page, "engineering");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.evaluate(
({ pubkey, mockPubkey }) => {
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
pubkey: string;
mentionPubkeys: string[];
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "engineering",
content: "Hey check this out",
pubkey,
mentionPubkeys: [mockPubkey],
});
},
{
pubkey: TEST_IDENTITIES.alice.pubkey,
mockPubkey: MOCK_PUBKEY,
},
);
await expect(page.getByTestId("channel-engineering")).toHaveCSS(
"font-weight",
"700",
);
await expect(
page.getByTestId("channel-unread-dot-engineering"),
).toHaveCount(0);
});
test("04 — context menu shows Unmute channel when muted", async ({
page,
}) => {
await seedMuteState(page, ENGINEERING_CHANNEL_ID);
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("channel-engineering").click({ button: "right" });
const unmuteItem = page.getByRole("menuitem", { name: "Unmute channel" });
await expect(unmuteItem).toBeVisible();
await unmuteItem.evaluate((el) =>
Promise.all(
el
.closest("[data-state]")
?.getAnimations()
.map((a) => a.finished) ?? [],
),
);
});
test("05 — muted icon visible on selected channel", async ({ page }) => {
await seedMuteState(page, ENGINEERING_CHANNEL_ID);
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-engineering").click();
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
const engRow = page.getByTestId("channel-engineering");
await expect(engRow.locator("svg.lucide-bell-off")).toHaveCount(1);
});
});
@@ -0,0 +1,134 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
type MockMessageWindow = Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
parentEventId?: string | null;
pubkey?: string;
}) => { id: string } | undefined;
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
};
const CHANNEL_NAME = "engineering";
const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8);
const ALICE_PUBKEY =
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
) {
await expect
.poll(async () => {
return page.evaluate((name) => {
return (
(
window as MockMessageWindow
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: name }) ??
false
);
}, channelName);
})
.toBe(true);
}
test.describe("channel shared header backdrop", () => {
test.use({ viewport: { width: 1280, height: 720 } });
test("spans channel and split auxiliary columns with one backdrop", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL_NAME}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL_NAME);
await waitForMockLiveSubscription(page, CHANNEL_NAME);
const rootId = await page.evaluate(
({ channelName, pubkey }) =>
(window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName,
content: "Root message for shared header backdrop coverage.",
pubkey,
})?.id ?? null,
{ channelName: CHANNEL_NAME, pubkey: MOCK_IDENTITY_PUBKEY },
);
expect(rootId).not.toBeNull();
await page.evaluate(
({ channelName, parentEventId, pubkey }) => {
(window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName,
content: "Reply that opens a split thread panel.",
parentEventId,
pubkey,
});
},
{
channelName: CHANNEL_NAME,
parentEventId: rootId,
pubkey: ALICE_PUBKEY,
},
);
const replyButton = page.locator('[data-testid^="reply-message-"]').first();
await expect(replyButton).toBeVisible();
await replyButton.click({ force: true });
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
const sharedBackdrop = page.getByTestId("channel-shared-header-backdrop");
await expect(sharedBackdrop).toHaveCount(1);
const chatHeader = page.getByTestId("chat-header");
const auxiliaryResizeHandle = page.getByTestId(
"right-auxiliary-pane-resize-handle",
);
const [
hostBox,
backdropBox,
backdropFilter,
backdropZIndex,
headerZIndex,
resizeHandleZIndex,
auxiliaryPaneAnimationName,
] = await Promise.all([
page.getByTestId("channel-drop-zone").locator("..").boundingBox(),
sharedBackdrop.boundingBox(),
sharedBackdrop.evaluate(
(element) => getComputedStyle(element).backdropFilter,
),
sharedBackdrop.evaluate((element) =>
Number(getComputedStyle(element).zIndex),
),
chatHeader.evaluate((element) =>
Number(getComputedStyle(element.parentElement ?? element).zIndex),
),
auxiliaryResizeHandle.evaluate((element) =>
Number(getComputedStyle(element).zIndex),
),
page
.getByTestId("message-thread-panel")
.evaluate((element) => getComputedStyle(element).animationName),
]);
expect(hostBox).not.toBeNull();
expect(backdropBox).not.toBeNull();
expect(Math.round(backdropBox?.x ?? 0)).toBe(Math.round(hostBox?.x ?? 0));
expect(Math.round(backdropBox?.width ?? 0)).toBe(
Math.round(hostBox?.width ?? 0),
);
expect(backdropFilter).not.toBe("none");
expect(headerZIndex).toBeGreaterThan(backdropZIndex);
expect(resizeHandleZIndex).toBeGreaterThan(backdropZIndex);
expect(auxiliaryPaneAnimationName).toBe("none");
await waitForAnimations(page);
});
});
+175
View File
@@ -0,0 +1,175 @@
import { expect, test } from "@playwright/test";
import type { Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/channel-sort";
// Mock-mode current-user pubkey and relay (see e2eBridge DEFAULT_MOCK_PUBKEY /
// DEFAULT_RELAY_WS_URL). Sort preferences persist under the relay-scoped key
// buzz-channel-sort.v1:<pubkey>:<encoded-relay>.
const MOCK_PUBKEY = "deadbeef".repeat(8);
const MOCK_RELAY_ENCODED = encodeURIComponent("ws://localhost:3000");
const SORT_STORAGE_KEY = `buzz-channel-sort.v1:${MOCK_PUBKEY}:${MOCK_RELAY_ENCODED}`;
function seedSortState(page: Page, groups: Record<string, string>) {
return page.addInitScript(
({ key, groups }) => {
window.localStorage.setItem(key, JSON.stringify({ version: 1, groups }));
},
{ key: SORT_STORAGE_KEY, groups },
);
}
async function openApp(page: Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
}
function streamNames(page: Page) {
return page
.getByTestId("stream-list")
.locator("[data-testid^='channel-']")
.evaluateAll((nodes) =>
nodes
.map((n) => n.getAttribute("data-testid") ?? "")
.filter(
(id) =>
!id.startsWith("channel-unread") &&
!id.startsWith("channel-working") &&
!id.startsWith("channel-dm-count"),
)
.map((id) => id.replace(/^channel-/, "")),
);
}
test.describe("per-group channel sort", () => {
test("01 — Channels group defaults to AZ", async ({ page }) => {
await installMockBridge(page);
await openApp(page);
const names = await streamNames(page);
const sorted = [...names].sort((a, b) => a.localeCompare(b));
expect(names).toEqual(sorted);
});
test("02 — sort trigger switches Channels to Recent and persists", async ({
page,
}) => {
await installMockBridge(page);
await openApp(page);
// Hover the Channels header to reveal the action cluster, then open the
// sort dropdown and choose Recent.
const streamList = page.getByTestId("stream-list");
await expect(streamList).toBeVisible();
await page.getByText("Channels", { exact: true }).hover();
const trigger = page.getByTestId("section-actions-channels");
await expect(trigger).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/01-channels-sort-ingress.png` });
await trigger.click();
// Sort is now a submenu flyout — open it before the radio items render.
await page.getByRole("menuitem", { name: "Sort" }).click();
await expect(
page.getByRole("menuitemradio", { name: "Recent" }),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/02-channels-sort-open.png` });
await page.getByRole("menuitemradio", { name: "Recent" }).click();
// Mock recency: all-replies (far future) > deep-history (1m) > general
// (5m) > agents (15m) > sales (30m) > engineering (42m) > design (120m),
// then no-activity channels alphabetically. The list is virtualized, so
// only assert on the rendered prefix.
await expect
.poll(async () => (await streamNames(page)).slice(0, 3))
.toEqual(["all-replies", "deep-history", "general"]);
const names = await streamNames(page);
const recencyOrder = [
"all-replies",
"deep-history",
"general",
"agents",
"sales",
"engineering",
"design",
];
const rendered = recencyOrder.filter((n) => names.includes(n));
expect(names.slice(0, rendered.length)).toEqual(rendered);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/03-channels-recent.png` });
// Persisted for this identity.
const stored = await page.evaluate((key) => {
return JSON.parse(window.localStorage.getItem(key) ?? "null");
}, SORT_STORAGE_KEY);
expect(stored).toEqual({ version: 1, groups: { channels: "recent" } });
// Survives reload.
await page.reload();
await expect(page.getByTestId("stream-list")).toBeVisible();
await expect
.poll(async () => (await streamNames(page)).slice(0, 2))
.toEqual(["all-replies", "deep-history"]);
});
test("03 — group preferences are independent (seeded Channels=recent leaves Forums AZ)", async ({
page,
}) => {
await seedSortState(page, { channels: "recent" });
await installMockBridge(page);
await openApp(page);
// Channels reflects the seeded Recent order…
await expect
.poll(async () => (await streamNames(page)).slice(0, 2))
.toEqual(["all-replies", "deep-history"]);
// …while Forums (unset) stays alphabetical.
const forumNames = await page
.getByTestId("forum-list")
.locator("[data-testid^='channel-']")
.evaluateAll((nodes) =>
nodes
.map((n) => n.getAttribute("data-testid") ?? "")
.filter(
(id) =>
!id.startsWith("channel-unread") &&
!id.startsWith("channel-working"),
)
.map((id) => id.replace(/^channel-/, "")),
);
const sortedForums = [...forumNames].sort((a, b) => a.localeCompare(b));
expect(forumNames).toEqual(sortedForums);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-independent-groups.png` });
});
test("04 — DM group has its own sort trigger", async ({ page }) => {
await installMockBridge(page);
await openApp(page);
const dmList = page.getByTestId("dm-list");
await expect(dmList).toBeVisible();
await page.getByText("Direct messages", { exact: true }).hover();
const trigger = page.getByTestId("section-actions-dms");
await expect(trigger).toBeVisible();
await trigger.click();
// Sort is now a submenu flyout — open it before the radio items render.
await page.getByRole("menuitem", { name: "Sort" }).click();
await expect(
page.getByRole("menuitemradio", { name: "AZ" }),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/05-dm-sort-open.png` });
await page.getByRole("menuitemradio", { name: "Recent" }).click();
const stored = await page.evaluate((key) => {
return JSON.parse(window.localStorage.getItem(key) ?? "null");
}, SORT_STORAGE_KEY);
expect(stored).toEqual({ version: 1, groups: { dms: "recent" } });
});
});
+106
View File
@@ -0,0 +1,106 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
const MOCK_PUBKEY = "deadbeef".repeat(8);
const ENGINEERING_CHANNEL_ID = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9";
const STAR_STORAGE_KEY = `buzz-channel-stars.v1:${MOCK_PUBKEY}`;
function seedStarState(
page: import("@playwright/test").Page,
channelId: string,
) {
return page.addInitScript(
({ key, id }) => {
localStorage.setItem(
key,
JSON.stringify({
version: 1,
channels: {
[id]: { starred: true, updatedAt: 1700000000 },
},
}),
);
},
{ key: STAR_STORAGE_KEY, id: channelId },
);
}
test.describe("channel starring", () => {
test("01 — context menu shows Star channel", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-engineering").click({ button: "right" });
const starItem = page.getByRole("menuitem", { name: "Star channel" });
await expect(starItem).toBeVisible();
await starItem.evaluate((el) =>
Promise.all(
el
.closest("[data-state]")
?.getAnimations()
.map((a) => a.finished) ?? [],
),
);
});
test("02 — starred channel appears in Starred section", async ({ page }) => {
await seedStarState(page, ENGINEERING_CHANNEL_ID);
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const starredList = page.getByTestId("starred-list");
await expect(starredList).toBeVisible();
await expect(starredList.getByTestId("channel-engineering")).toBeVisible();
});
test("03 — context menu shows Unstar channel when starred", async ({
page,
}) => {
await seedStarState(page, ENGINEERING_CHANNEL_ID);
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page
.getByTestId("starred-list")
.getByTestId("channel-engineering")
.click({ button: "right" });
const unstarItem = page.getByRole("menuitem", { name: "Unstar channel" });
await expect(unstarItem).toBeVisible();
await unstarItem.evaluate((el) =>
Promise.all(
el
.closest("[data-state]")
?.getAnimations()
.map((a) => a.finished) ?? [],
),
);
});
test("04 — starred channel is removed from the Channels group", async ({
page,
}) => {
await seedStarState(page, ENGINEERING_CHANNEL_ID);
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Exclusive behavior (Slack-style): the starred channel lives only in the
// Starred section and no longer appears in the default Channels group.
await expect(
page.getByTestId("starred-list").getByTestId("channel-engineering"),
).toBeVisible();
await expect(
page.getByTestId("stream-list").getByTestId("channel-engineering"),
).toHaveCount(0);
});
});
@@ -0,0 +1,169 @@
import { expect, test } from "@playwright/test";
import { parseChannelWindowResponse } from "@/features/messages/lib/channelWindowResponse";
import type { RelayEvent } from "@/shared/api/types";
import { installMockBridge } from "../helpers/bridge";
// Focused mock-mode regression for the get_channel_window bridge handler.
//
// The relay-mode parity gate (parity-ancestor-island) never exercises the mock
// branch (identity present). A rows-only mock branch broke every ordinary
// mock-mode channel open, because parseChannelWindowResponse requires exactly
// one kind-39006 bounds event and throws otherwise. This proves the mock branch
// now returns a parseable page in both positions of a two-page walk, and that
// the page-1/page-2 union of rows has no duplication and no loss.
//
// Uses the empty `random` channel (no pre-seeds) so union math is exact.
const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d";
const PAGE_CAP = 50; // getChannelWindowEvents default limitRows
const SEED_COUNT = 75; // > one page, so page-1 is full and page-2 holds the tail
async function invokeWindow(
page: import("@playwright/test").Page,
payload: Record<string, unknown>,
) {
return page.evaluate(
([channelId, cursor, limitRows]) =>
(window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ as unknown as WindowInvoke)(
"get_channel_window",
{ channelId, cursor, limitRows },
),
[payload.channelId, payload.cursor, payload.limitRows] as const,
);
}
type WindowInvoke = (
command: string,
payload?: Record<string, unknown>,
) => Promise<RelayEvent[]>;
test("mock-mode channel window pages parse with no dup or loss across page-1/page-2", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() =>
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
);
// Seed SEED_COUNT top-level messages, strictly increasing created_at so their
// relay order (created_at DESC, id ASC) is deterministic.
await page.evaluate(
({ seedCount }) => {
const base = 1_700_000_000;
for (let index = 0; index < seedCount; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: `paging ${index}`,
createdAt: base + index,
});
}
},
{ seedCount: SEED_COUNT },
);
// Page 1: head (no cursor).
const rawPage1 = await invokeWindow(page, {
channelId: RANDOM_CHANNEL_ID,
cursor: null,
limitRows: PAGE_CAP,
});
const page1 = parseChannelWindowResponse(rawPage1, RANDOM_CHANNEL_ID, null);
expect(page1.rows.length).toBe(PAGE_CAP);
expect(page1.hasMore).toBe(true);
expect(page1.nextCursor).not.toBeNull();
// Page 2: feed page-1's signed cursor back verbatim as the request cursor.
const cursor = page1.nextCursor;
if (!cursor) throw new Error("page-1 must expose a next cursor");
const rawPage2 = await invokeWindow(page, {
channelId: RANDOM_CHANNEL_ID,
cursor: { created_at: cursor.createdAt, event_id: cursor.eventId },
limitRows: PAGE_CAP,
});
const page2 = parseChannelWindowResponse(rawPage2, RANDOM_CHANNEL_ID, cursor);
expect(page2.rows.length).toBe(SEED_COUNT - PAGE_CAP);
expect(page2.hasMore).toBe(false);
expect(page2.nextCursor).toBeNull();
// Union: no duplication, no loss — exactly the SEED_COUNT distinct rows.
const ids = [
...page1.rows.map((row) => row.event.id),
...page2.rows.map((row) => row.event.id),
];
const unique = new Set(ids);
expect(unique.size).toBe(SEED_COUNT); // no loss
expect(ids.length).toBe(unique.size); // no duplication
});
test("mock-mode channel window includes summaries and the two-hop aux closure", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() =>
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
);
const seeded = await page.evaluate(() => {
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) throw new Error("mock emitter is not installed");
const root = emit({ channelName: "random", content: "window root" });
const reply = emit({
channelName: "random",
content: "window reply",
parentEventId: root.id,
});
const reaction = emit({
channelName: "random",
content: "🔥",
kind: 7,
extraTags: [["e", root.id]],
});
const edit = emit({
channelName: "random",
content: "edited window root",
kind: 40003,
extraTags: [["e", root.id]],
});
const rowDeletion = emit({
channelName: "random",
content: "",
kind: 5,
extraTags: [["e", root.id]],
});
const reactionDeletion = emit({
channelName: "random",
content: "",
kind: 5,
extraTags: [["e", reaction.id]],
});
return {
rootId: root.id,
replyId: reply.id,
auxIds: [reaction.id, edit.id, rowDeletion.id, reactionDeletion.id],
};
});
const raw = await invokeWindow(page, {
channelId: RANDOM_CHANNEL_ID,
cursor: null,
limitRows: PAGE_CAP,
});
const parsed = parseChannelWindowResponse(raw, RANDOM_CHANNEL_ID, null);
expect(parsed.rows.map((row) => row.event.id)).toEqual([seeded.rootId]);
expect(parsed.aux.map((event) => event.id)).toEqual(
expect.arrayContaining(seeded.auxIds),
);
expect(parsed.aux.map((event) => event.id)).not.toContain(seeded.replyId);
expect(parsed.rows[0].thread).toMatchObject({
replyCount: 1,
descendantCount: 1,
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,166 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
/**
* Cold-channel-switch longtask harness.
*
* This is the instrument the timeline-virtualization acceptance gate is defined
* against: it measures the main-thread blocking cost of the FIRST switch into a
* deep channel (600 seeded messages, windowed to the 300-row ceiling on cold
* load). main builds every windowed row synchronously on first mount, so that
* mount is the beachball; virtualization renders only the visible window, so the
* mount cost is bounded and independent of channel depth.
*
* WHY LONGTASKS, NOT LAYOUT METRICS: the felt jank is the main thread being
* blocked past the ~50ms frame-budget wall during the mount. PerformanceObserver
* `longtask` entries are exactly the >50ms main-thread tasks the browser itself
* flags — the honest, engine-level signal for "the UI froze". We report the
* LONGEST single longtask (the worst freeze) and the TOTAL longtask time across
* the switch window (the split-task guard axis — many medium tasks can hide the
* same total cost a single long one would show).
*
* WHY 4x CPU THROTTLE: headless Chromium on dev hardware is far faster than the
* target machines; 4x throttle via CDP brings the mount cost into a regime where
* a 300-row synchronous build actually crosses the frame-budget wall, so the
* measurement discriminates. The throttle and machine cancel in the B-vs-header
* delta gate, so absolute ms are not portable but the delta is.
*
* COLD = warm `general` first, then the FIRST switch into `deep-history`. A warm
* re-entry hits cached render state and does not reproduce the cold mount cost.
* Each of the 5 runs re-warms `general` so every deep-history switch is cold.
*
* SCOPE LIMIT: this measures Chromium main-thread longtasks under throttle. It
* does NOT measure the WKWebView compositor feel on the shipped Tauri shell —
* that is a separate real-wheel pass.
*
* Run it:
* pnpm build && npx playwright test --config=playwright.perf.config.ts \
* cold-switch-longtask.perf.ts
*/
const RUNS = 5;
const THROTTLE_RATE = 4;
type RunResult = { longest: number; total: number; count: number };
function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
}
test("MEASURE: cold-switch longtask cost into deep-history at the 300 ceiling", async ({
page,
}) => {
test.setTimeout(120_000);
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
// Arm a longtask observer that buffers into a window array we can read and
// reset per run. `buffered: true` catches tasks queued before the read.
await page.addInitScript(() => {
const store = window as unknown as { __LONGTASKS__?: number[] };
store.__LONGTASKS__ = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
store.__LONGTASKS__?.push(entry.duration);
}
}).observe({ type: "longtask", buffered: true });
});
// addInitScript only applies on the next navigation, so reload to arm it.
await page.reload();
await page.waitForFunction(
() =>
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
Array.isArray(
(window as unknown as { __LONGTASKS__?: number[] }).__LONGTASKS__,
),
);
const client = await page.context().newCDPSession(page);
await client.send("Emulation.setCPUThrottlingRate", { rate: THROTTLE_RATE });
const timeline = page.getByTestId("message-timeline");
const results: RunResult[] = [];
for (let run = 0; run < RUNS; run += 1) {
// Warm `general` so the deep-history switch that follows is a cold first
// entry, not a warm re-render of cached state.
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
// Clear the buffer immediately before the cold switch so only the switch's
// longtasks are attributed to this run.
await page.evaluate(() => {
(window as unknown as { __LONGTASKS__: number[] }).__LONGTASKS__ = [];
});
// The cold switch: first entry into the 600-message channel. The cold load
// windows to the newest 300 and mounts them.
await page.getByTestId("channel-deep-history").click();
await expect(page.getByTestId("chat-title")).toHaveText("deep-history");
await expect(
page.locator('[data-message-id^="mock-deep-history-"]').first(),
).toBeVisible();
// Let any post-mount longtasks (anchor settle, sticky handoff) flush before
// reading — they are part of the switch cost.
await page.waitForTimeout(300);
const tasks = await page.evaluate(
() =>
(window as unknown as { __LONGTASKS__: number[] }).__LONGTASKS__ ?? [],
);
results.push({
longest: tasks.length ? Math.max(...tasks) : 0,
total: tasks.reduce((sum, d) => sum + d, 0),
count: tasks.length,
});
}
await client.send("Emulation.setCPUThrottlingRate", { rate: 1 });
const longests = results.map((r) => r.longest);
const totals = results.map((r) => r.total);
const medianLongest = median(longests);
const minLongest = Math.min(...longests);
const maxLongest = Math.max(...longests);
const spread = maxLongest - minLongest;
const medianTotal = median(totals);
/* eslint-disable no-console */
console.log(
"\n=== COLD-SWITCH LONGTASK BASELINE (deep-history, 300 ceiling) ===",
);
console.log(`CPU throttle: ${THROTTLE_RATE}x`);
console.log(`runs: ${RUNS}`);
console.log(
`per-run longest-longtask (ms): [${longests.map((v) => v.toFixed(1)).join(", ")}]`,
);
console.log(
`per-run total-longtask (ms): [${totals.map((v) => v.toFixed(1)).join(", ")}]`,
);
console.log(
`per-run longtask count: [${results.map((r) => r.count).join(", ")}]`,
);
console.log(`MEDIAN longest-longtask: ${medianLongest.toFixed(1)}ms`);
console.log(
` spread (max - min): ${spread.toFixed(1)}ms (min ${minLongest.toFixed(1)}, max ${maxLongest.toFixed(1)})`,
);
console.log(`MEDIAN total-longtask-in-window: ${medianTotal.toFixed(1)}ms`);
console.log("(>50ms single task is a dropped-frame freeze the user feels)");
console.log(
"=================================================================\n",
);
/* eslint-enable no-console */
// Instrument, not a gate — confirm the switch actually exercised the mount
// under throttle (some longtask work happened on at least one run).
expect(results.some((r) => r.count > 0)).toBe(true);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,220 @@
import { expect, type Page, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const ORIGINAL_SHA = "a".repeat(64);
const EDITED_SHA = "b".repeat(64);
const ORIGINAL_URL = "https://example.com/e2e/draw-original.svg";
const EDITED_URL = "https://example.com/e2e/draw-edited.svg";
const PR_SNAPSHOT_DIR = "test-results/video-upload-photo-scope";
const ORIGINAL_DESCRIPTOR = {
url: ORIGINAL_URL,
sha256: ORIGINAL_SHA,
size: 1234,
type: "image/svg+xml",
uploaded: Math.floor(Date.now() / 1000),
dim: "320x200",
filename: "draw-original.svg",
};
const EDITED_DESCRIPTOR = {
url: EDITED_URL,
sha256: EDITED_SHA,
size: 2345,
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
dim: "320x200",
filename: "draw-original.png",
};
/**
* Serve deterministic same-size SVGs for both attachment URLs. These back
* the display <img> loads and the mock bridge's `fetch_media_bytes`
* handler (the editor exports via IPC bytes + blob: URL, so no CORS
* headers are needed). The CORS header is required only because the mock
* bridge's in-page `fetch()` of this cross-origin URL is CORS-mode —
* production fetches the bytes in Rust instead.
*/
async function installImageRoutes(page: Page) {
await page.route("https://example.com/e2e/draw-*.svg*", (route) => {
const fill = route.request().url().includes("edited")
? "#b3574a"
: "#4aa3df";
route.fulfill({
body: `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200" viewBox="0 0 320 200"><rect width="100%" height="100%" fill="${fill}"/></svg>`,
contentType: "image/svg+xml",
headers: { "access-control-allow-origin": "*" },
});
});
}
async function drawStrokeOnCanvas(page: Page) {
const canvas = page.getByTestId("composer-image-editor-canvas");
await expect(canvas).toBeVisible();
const box = await canvas.boundingBox();
if (!box) throw new Error("Expected drawing canvas to have a layout box");
const centerY = box.y + box.height / 2;
await page.mouse.move(box.x + box.width * 0.25, centerY);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.75, centerY, { steps: 8 });
await page.mouse.up();
}
test.beforeEach(async ({ page }) => {
await installImageRoutes(page);
await installMockBridge(page, {
uploadDescriptors: [ORIGINAL_DESCRIPTOR],
});
});
test("image annotation overlay and editor controls", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByRole("button", { name: "Attach file" }).click();
const composer = page.getByTestId("message-composer");
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
await composer.getByTestId("composer-media-attachment").hover();
await expect(page.getByTestId("composer-attachment-annotate")).toBeVisible();
await waitForAnimations(page);
await composer.screenshot({
path: `${PR_SNAPSHOT_DIR}/01-image-annotation-overlay.png`,
});
await page.getByTestId("composer-attachment-annotate").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(page.getByTestId("composer-attachment-edit")).toBeVisible();
await expect(page.getByTestId("composer-attachment-spoiler")).toBeVisible();
await waitForAnimations(page);
await dialog.screenshot({
path: `${PR_SNAPSHOT_DIR}/02-image-editor-controls.png`,
});
});
test("draw on an uploaded image, save replaces it, revert restores in place", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Attach the original image via the mocked paperclip flow.
await page.getByRole("button", { name: "Attach file" }).click();
const composer = page.getByTestId("message-composer");
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
// Open the composer lightbox.
await composer.getByTestId("composer-media-attachment").hover();
await page.getByTestId("composer-attachment-annotate").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.locator(`img[src="${ORIGINAL_URL}"]`)).toBeVisible();
// No revert affordance before any edit.
await expect(page.getByTestId("composer-attachment-revert")).toHaveCount(0);
// Enter canvas mode; Escape leaves canvas mode but keeps the dialog open.
await page.getByTestId("composer-attachment-edit").click();
await expect(page.getByTestId("composer-image-editor-canvas")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("composer-image-editor-canvas")).toHaveCount(0);
await expect(dialog).toBeVisible();
// Re-enter canvas mode and draw a stroke.
await page.getByTestId("composer-attachment-edit").click();
const saveButton = page.getByTestId("composer-image-editor-save");
await expect(saveButton).toBeDisabled();
await drawStrokeOnCanvas(page);
await expect(saveButton).toBeEnabled();
// The next mocked upload returns the annotated descriptor.
await page.evaluate((edited) => {
window.__BUZZ_E2E__ = {
...window.__BUZZ_E2E__,
mock: {
...window.__BUZZ_E2E__?.mock,
uploadDescriptors: [edited],
},
};
}, EDITED_DESCRIPTOR);
await saveButton.click();
// Saving closes the lightbox; the composer thumbnail now shows the
// annotated image.
await expect(dialog).toHaveCount(0);
await expect(composer.getByAltText("Attachment bbbb")).toBeVisible();
// The annotated PNG went through the real upload command.
const uploadCommandCount = await page.evaluate(
() =>
(
window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }
).__BUZZ_E2E_COMMANDS__?.filter(
(command) => command === "upload_media_bytes",
).length ?? 0,
);
expect(uploadCommandCount).toBe(1);
// Reopen the lightbox on the annotated attachment to revert.
await composer.getByTestId("composer-media-attachment").hover();
await page.getByTestId("composer-attachment-annotate").click();
await expect(dialog).toBeVisible();
await expect(dialog.locator(`img[src="${EDITED_URL}"]`)).toBeVisible();
// Revert swaps back to the original without closing the dialog.
await page.getByTestId("composer-attachment-revert").click();
await expect(dialog).toBeVisible();
await expect(dialog.locator(`img[src="${ORIGINAL_URL}"]`)).toBeVisible();
await expect(page.getByTestId("composer-attachment-revert")).toHaveCount(0);
// Closing the dialog shows the (restored) original thumbnail.
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
});
test("spoiler marking survives drawing on the attachment", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByRole("button", { name: "Attach file" }).click();
const composer = page.getByTestId("message-composer");
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
// Spoiler the attachment from its lightbox (media spoilers are
// per-attachment; the text spoiler control no longer affects media),
// then draw on it.
await composer.getByTestId("composer-media-attachment").hover();
await page.getByTestId("composer-attachment-annotate").click();
await page.getByTestId("composer-attachment-spoiler").click();
await page.keyboard.press("Escape");
await expect(composer.locator("[data-composer-media-spoiler]")).toBeVisible();
await composer.getByTestId("composer-media-attachment").hover();
await page.getByTestId("composer-attachment-annotate").click();
await page.getByTestId("composer-attachment-edit").click();
await drawStrokeOnCanvas(page);
await page.evaluate((edited) => {
window.__BUZZ_E2E__ = {
...window.__BUZZ_E2E__,
mock: {
...window.__BUZZ_E2E__?.mock,
uploadDescriptors: [edited],
},
};
}, EDITED_DESCRIPTOR);
await page.getByTestId("composer-image-editor-save").click();
// Saving closes the lightbox.
await expect(page.getByRole("dialog")).toHaveCount(0);
// The annotated replacement is still marked as a spoiler.
await expect(composer.getByAltText("Attachment bbbb")).toBeVisible();
await expect(composer.locator("[data-composer-media-spoiler]")).toBeVisible();
});
@@ -0,0 +1,179 @@
import { expect, test } from "@playwright/test";
import type { Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// ⌘K / Ctrl+K behaviour around the composer:
// - With composer text selected → open the link-edit dialog (the shortcut the
// formatting toolbar has always advertised on its link button).
// - With a caret inside an existing composer link → open the same dialog
// seeded with that link.
// - With an empty caret in the composer (no selection, no link) → fall
// through to the app-wide quick-search dialog.
// - With focus outside the composer → quick search, unchanged.
async function openGeneral(page: Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
}
test("⌘K with selected composer text opens the add-link dialog", async ({
page,
}) => {
await installMockBridge(page);
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("check out this link");
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("ControlOrMeta+k");
const dialog = page.getByRole("dialog", { name: "Add link" });
await expect(dialog).toBeVisible();
// Seeded with the selected text as the display value.
await expect(dialog.getByLabel("Display text")).toHaveValue(
"check out this link",
);
// Quick search must NOT have opened.
await expect(page.getByTestId("search-dialog-input")).toHaveCount(0);
});
test("⌘K with caret inside an existing link opens the edit-link dialog", async ({
page,
}) => {
await installMockBridge(page);
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
// Create a real link through the ⌘K flow first.
await input.pressSequentially("docs");
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("ControlOrMeta+k");
const addDialog = page.getByRole("dialog", { name: "Add link" });
await expect(addDialog).toBeVisible();
await addDialog.getByLabel("URL").fill("https://example.com");
await addDialog.getByRole("button", { name: "Save" }).click();
await expect(addDialog).toHaveCount(0);
await expect(input.locator('a[href="https://example.com"]')).toHaveText(
"docs",
);
// Click into the linked text to place the caret inside it. This must not
// surface contextual controls; editing stays accessible through ⌘K.
await input.locator('a[href="https://example.com"]').click();
await expect(page.getByRole("button", { name: "Edit link" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Unlink" })).toHaveCount(0);
await page.keyboard.press("ControlOrMeta+k");
const editDialog = page.getByRole("dialog", { name: "Edit link" });
await expect(editDialog).toBeVisible();
await expect(editDialog.getByLabel("URL")).toHaveValue("https://example.com");
await expect(page.getByTestId("search-dialog-input")).toHaveCount(0);
});
test("⌘K with an empty composer caret still opens quick search", async ({
page,
}) => {
await installMockBridge(page);
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await page.keyboard.press("ControlOrMeta+k");
await expect(page.getByTestId("search-dialog-input")).toBeVisible();
await expect(page.getByRole("dialog", { name: "Add link" })).toHaveCount(0);
});
test("⌘K with unselected composer text (caret only) opens quick search", async ({
page,
}) => {
await installMockBridge(page);
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("draft in progress");
await page.keyboard.press("ControlOrMeta+k");
await expect(page.getByTestId("search-dialog-input")).toBeVisible();
await expect(page.getByRole("dialog", { name: "Add link" })).toHaveCount(0);
});
test("macOS plain Ctrl+K still kill-lines in the composer", async ({
page,
}) => {
// Regression guard for the Emacs-style Ctrl-K binding: on macOS the
// primary-modifier check must reject Control so `macEmacsTextShortcuts`
// keeps kill-line, and neither the link dialog nor quick search may open.
test.skip(process.platform !== "darwin", "mac-only Emacs binding");
await installMockBridge(page);
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("kill this line");
// Emacs Ctrl-A → start of line (ProseMirror handles this natively on mac;
// "Home" is not reliable in headless Chromium).
await page.keyboard.press("Control+a");
await page.keyboard.press("Control+k");
await expect(input).not.toContainText("kill this line");
await expect(page.getByRole("dialog", { name: "Add link" })).toHaveCount(0);
await expect(page.getByTestId("search-dialog-input")).toHaveCount(0);
});
test("macOS Ctrl+A, Ctrl+E, and Ctrl+K stay within hard-break lines", async ({
page,
}) => {
test.skip(process.platform !== "darwin", "mac-only Emacs bindings");
await page.addInitScript(() => {
Object.defineProperty(navigator, "platform", { value: "MacIntel" });
});
await installMockBridge(page);
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("first");
await input.press("Shift+Enter");
await input.pressSequentially("middle");
await input.press("Shift+Enter");
await input.pressSequentially("last");
await expect(input.locator("br")).toHaveCount(2);
// Start/end movement applies to the third visual line, not the whole editor.
await page.keyboard.press("Control+a");
await input.pressSequentially("[");
await page.keyboard.press("Control+e");
await input.pressSequentially("]");
await expect(input).toHaveText("firstmiddle[last]");
// Kill only to this line's end, preserving both preceding lines.
await page.keyboard.press("Control+a");
await page.keyboard.press("Control+k");
await expect(input).toHaveText("firstmiddle");
await expect(input.locator("br")).toHaveCount(3);
// At end-of-line, kill the newline and join with the following line.
await page.keyboard.press("Control+b");
await page.keyboard.press("Control+k");
await expect(input).toHaveText("firstmiddle");
await expect(input.locator("br")).toHaveCount(1);
});
test("⌘K outside the composer opens quick search", async ({ page }) => {
await installMockBridge(page);
await openGeneral(page);
// Focus is on the page body — not the composer.
await page.getByTestId("chat-title").click();
await page.keyboard.press("ControlOrMeta+k");
await expect(page.getByTestId("search-dialog-input")).toBeVisible();
});
@@ -0,0 +1,654 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
async function openGeneral(page: Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
}
async function selectText(input: Locator, selectedText: string) {
await input.evaluate((element, text) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let offset = 0;
while (walker.nextNode()) {
const node = walker.currentNode;
const value = node.textContent ?? "";
const index = value.indexOf(text);
if (index >= 0) {
const range = document.createRange();
range.setStart(node, index);
range.setEnd(node, index + text.length);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
(element as HTMLElement).focus();
document.dispatchEvent(new Event("selectionchange"));
return;
}
offset += value.length;
}
throw new Error(
`Could not select "${text}" in composer after ${offset} characters`,
);
}, selectedText);
}
async function selectTextRange(
input: Locator,
firstText: string,
lastText: string,
) {
await input.evaluate(
(element, texts) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let first: Text | null = null;
let last: Text | null = null;
while (walker.nextNode()) {
const node = walker.currentNode as Text;
if (!first && node.data.includes(texts.firstText)) first = node;
if (node.data.includes(texts.lastText)) last = node;
}
if (!(first && last))
throw new Error("Could not find selection endpoints");
const range = document.createRange();
range.setStart(first, first.data.indexOf(texts.firstText));
range.setEnd(
last,
last.data.indexOf(texts.lastText) + texts.lastText.length,
);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
(element as HTMLElement).focus();
document.dispatchEvent(new Event("selectionchange"));
},
{ firstText, lastText },
);
}
async function dragSelectText(
page: Page,
input: Locator,
selectedText: string,
backward = false,
) {
const points = await input.evaluate((element, text) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
const node = walker.currentNode;
const value = node.textContent ?? "";
const index = value.indexOf(text);
if (index < 0) continue;
const startRange = document.createRange();
startRange.setStart(node, index);
startRange.setEnd(node, index + 1);
const startRect = startRange.getBoundingClientRect();
const endRange = document.createRange();
endRange.setStart(node, index + text.length - 1);
endRange.setEnd(node, index + text.length);
const endRect = endRange.getBoundingClientRect();
return {
start: {
x: startRect.left + 1,
y: startRect.top + startRect.height / 2,
},
end: {
x: endRect.right - 1,
y: endRect.top + endRect.height / 2,
},
};
}
throw new Error(`Could not locate "${text}" for mouse selection`);
}, selectedText);
const dragStart = backward ? points.end : points.start;
const dragEnd = backward ? points.start : points.end;
await page.mouse.move(dragStart.x, dragStart.y);
await page.mouse.down();
await page.mouse.move(dragEnd.x, dragEnd.y, { steps: 12 });
await page.mouse.up();
await expect
.poll(() => page.evaluate(() => window.getSelection()?.toString()))
.toBe(selectedText);
}
async function applySelectionFormat(
page: Page,
input: Locator,
label: "Bullet list" | "Code block" | "Ordered list" | "Quote",
collapseAfterMouseDown = false,
useMouseSelection = false,
) {
if (useMouseSelection) {
await dragSelectText(page, input, "selected");
} else {
await selectText(input, "selected");
}
const tray = page.getByTestId("selection-formatting-tray");
await expect(tray).toBeVisible();
const button = tray.getByRole("button", { name: label });
if (collapseAfterMouseDown) {
await button.evaluate((element, inputTestId) => {
element.addEventListener(
"mouseup",
() => {
const input = document.querySelector(
`[data-testid="${inputTestId}"]`,
);
if (!input) throw new Error("Composer input not found");
const range = document.createRange();
range.selectNodeContents(input);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
document.dispatchEvent(new Event("selectionchange"));
},
{ once: true },
);
}, "message-input");
}
await button.click();
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
async function applyCaretFormat(
page: Page,
label: "Bullet list" | "Code block" | "Ordered list" | "Quote",
) {
await page.getByRole("button", { name: "Toggle formatting" }).first().click();
await page.getByRole("button", { name: label, exact: true }).click();
}
for (const platform of [
{ name: "macOS", navigatorPlatform: "MacIntel", shortcut: "Meta+Shift+V" },
{
name: "Windows/Linux",
navigatorPlatform: "Win32",
shortcut: "Control+Shift+V",
},
]) {
test(`pastes rich clipboard content without formatting on ${platform.name}`, async ({
page,
}) => {
await page.addInitScript((navigatorPlatform) => {
Object.defineProperty(navigator, "platform", {
configurable: true,
value: navigatorPlatform,
});
}, platform.navigatorPlatform);
await page
.context()
.grantPermissions(["clipboard-read", "clipboard-write"], {
origin: "http://127.0.0.1:4173",
});
await openGeneral(page);
await page.evaluate(async () => {
await navigator.clipboard.write([
new ClipboardItem({
"text/html": new Blob(
[
'<p><strong>Bold</strong> and <a href="https://example.com">linked</a></p><ul><li>list item</li></ul>',
],
{ type: "text/html" },
),
"text/plain": new Blob(["Bold and linked\nlist item"], {
type: "text/plain",
}),
}),
]);
});
const input = page.getByTestId("message-input");
await input.click();
await page.keyboard.press(platform.shortcut);
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("read_clipboard_text");
await expect(input).toHaveText("Bold and linkedlist item");
await expect(input.locator("strong, a, ul, li")).toHaveCount(0);
await expect(input.locator(":scope > p")).toHaveText([
"Bold and linked",
"list item",
]);
});
}
for (const format of [
{ label: "Code block", selector: "pre" },
{ label: "Bullet list", selector: "ul" },
{ label: "Ordered list", selector: "ol" },
{ label: "Quote", selector: "blockquote" },
] as const) {
test(`${format.label} applies only to the selected composer text`, async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.fill("before selected after");
await applySelectionFormat(page, input, format.label);
await expect(input.locator(":scope > p").first()).toHaveText("before ");
await expect(input.locator(`:scope > ${format.selector}`)).toHaveText(
"selected",
);
await expect(input.locator(":scope > p").last()).toHaveText(" after");
await expect(input).toHaveText("before selected after");
});
test(`${format.label} starts at a collapsed caret on a new line`, async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("before");
await input.press("Shift+Enter");
await applyCaretFormat(page, format.label);
await input.pressSequentially("inside");
await expect(input.locator(":scope > p").first()).toHaveText("before");
await expect(input.locator(`:scope > ${format.selector}`)).toHaveText(
"inside",
);
});
test(`${format.label} at a collapsed caret formats only the caret's line`, async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("before");
await input.press("Shift+Enter");
await input.pressSequentially("target");
await input.press("Shift+Enter");
await input.pressSequentially("after");
// Collapse the caret into the middle line.
await selectText(input, "target");
await input.press("ArrowRight");
await applyCaretFormat(page, format.label);
await expect(input.locator(":scope > p").first()).toHaveText("before");
await expect(input.locator(`:scope > ${format.selector}`)).toHaveText(
"target",
);
await expect(input.locator(":scope > p").last()).toHaveText("after");
});
}
test("Code block uses the restored multiline selection after mouseup collapse", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("before");
await input.press("Shift+Enter");
await input.pressSequentially("selected");
await input.press("Shift+Enter");
await input.pressSequentially("after");
await applySelectionFormat(page, input, "Code block", true);
await expect(input.locator(":scope > p").first()).toHaveText("before");
await expect(input.locator(":scope > pre")).toHaveText("selected");
await expect(input.locator(":scope > p").last()).toHaveText("after");
});
for (const list of [
{ label: "Bullet list", selector: "ul" },
{ label: "Ordered list", selector: "ol" },
] as const) {
test(`selected hard-break lines become separate ${list.label.toLowerCase()} items`, async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("one");
await input.press("Shift+Enter");
await input.pressSequentially("two");
await input.press("Shift+Enter");
await input.pressSequentially("three");
await selectTextRange(input, "one", "three");
await page
.getByTestId("selection-formatting-tray")
.getByRole("button", { name: list.label })
.click();
const items = input.locator(`:scope > ${list.selector} > li`);
await expect(items).toHaveCount(3);
await expect(items).toHaveText(["one", "two", "three"]);
});
}
test("partial list-item selections snap to whole items for block formats", async ({
page,
}) => {
for (const format of [
"Code block",
"Bullet list",
"Ordered list",
"Quote",
] as const) {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("before");
await input.press("Shift+Enter");
await input.pressSequentially("first");
await input.press("Shift+Enter");
await input.pressSequentially("second");
await input.press("Shift+Enter");
await input.pressSequentially("after");
await selectTextRange(input, "before", "after");
await page
.getByTestId("selection-formatting-tray")
.getByRole("button", { name: "Bullet list" })
.click();
await selectTextRange(input, "irst", "seco");
await page
.getByTestId("selection-formatting-tray")
.getByRole("button", { name: format })
.click();
const structure = await input.locator(":scope > *").evaluateAll((nodes) =>
nodes.map((node) => ({
tag: node.tagName.toLowerCase(),
text: node.textContent,
items: Array.from(
node.querySelectorAll(":scope > li"),
(item) => item.textContent,
),
})),
);
const expected = {
"Code block": [
{ tag: "ul", text: "before", items: ["before"] },
{ tag: "pre", text: "first\nsecond", items: [] },
{ tag: "ul", text: "after", items: ["after"] },
],
"Bullet list": [
{
tag: "ul",
text: "beforefirstsecondafter",
items: ["before", "first", "second", "after"],
},
],
"Ordered list": [
{ tag: "ul", text: "before", items: ["before"] },
{ tag: "ol", text: "firstsecond", items: ["first", "second"] },
{ tag: "ul", text: "after", items: ["after"] },
],
Quote: [
{ tag: "ul", text: "before", items: ["before"] },
{ tag: "blockquote", text: "firstsecond", items: [] },
{ tag: "ul", text: "after", items: ["after"] },
],
}[format];
expect(structure).toEqual(expected);
await page.reload();
}
});
test("selected hard-break lines stay newline-separated in one code block", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("one");
await input.press("Shift+Enter");
await input.pressSequentially("two");
await input.press("Shift+Enter");
await input.pressSequentially("three");
await selectTextRange(input, "one", "three");
await page
.getByTestId("selection-formatting-tray")
.getByRole("button", { name: "Code block" })
.click();
await expect(input.locator(":scope > pre")).toHaveCount(1);
await expect(input.locator(":scope > pre")).toHaveText("one\ntwo\nthree");
await page.getByTestId("send-message").click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>;
}
).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content,
),
)
.toBe("```\none\ntwo\nthree\n```");
});
test("selected list items become one multiline code block and keep neighbors", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("before");
await input.press("Shift+Enter");
await input.pressSequentially("one");
await input.press("Shift+Enter");
await input.pressSequentially("two");
await input.press("Shift+Enter");
await input.pressSequentially("after");
await selectTextRange(input, "before", "after");
await page
.getByTestId("selection-formatting-tray")
.getByRole("button", { name: "Bullet list" })
.click();
await selectTextRange(input, "one", "two");
await page
.getByTestId("selection-formatting-tray")
.getByRole("button", { name: "Code block" })
.click();
await expect(input.locator(":scope > pre")).toHaveCount(1);
await expect(input.locator(":scope > pre")).toHaveText("one\ntwo");
await expect(input.locator(":scope > ul li")).toHaveText(["before", "after"]);
await page.getByTestId("send-message").click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>;
}
).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content,
),
)
.toBe("- before\n\n```\none\ntwo\n```\n\n- after");
});
test("caret-only block formatting serializes the prior draft unchanged", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("before");
await input.press("Shift+Enter");
await applyCaretFormat(page, "Bullet list");
await input.pressSequentially("item");
await page.getByTestId("send-message").click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>;
}
).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content,
),
)
.toBe("before\n\n- item");
});
test("block formatting preserves the lines around a selected composer line", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("before");
await input.press("Shift+Enter");
await input.pressSequentially("selected");
await input.press("Shift+Enter");
await input.pressSequentially("after");
await applySelectionFormat(page, input, "Bullet list");
await expect(input.locator(":scope > p").first()).toHaveText("before");
await expect(input.locator(":scope > ul")).toHaveText("selected");
await expect(input.locator(":scope > p").last()).toHaveText("after");
await page.getByTestId("send-message").click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>;
}
).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content,
),
)
.toBe("before\n\n- selected\n\nafter");
});
test("block formatting restores a selection collapsed by the toolbar interaction", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.fill("before selected after");
await applySelectionFormat(page, input, "Bullet list", true);
await expect(input.locator(":scope > p").first()).toHaveText("before ");
await expect(input.locator(":scope > ul")).toHaveText("selected");
await expect(input.locator(":scope > p").last()).toHaveText(" after");
});
test("block formatting only changes text selected with a native mouse drag", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.fill("before selected after");
await applySelectionFormat(page, input, "Bullet list", false, true);
await expect(input.locator(":scope > p").first()).toHaveText("before ");
await expect(input.locator(":scope > ul")).toHaveText("selected");
await expect(input.locator(":scope > p").last()).toHaveText(" after");
});
test("block formatting preserves a backward native selection", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.fill("before selected after");
await dragSelectText(page, input, "selected", true);
const tray = page.getByTestId("selection-formatting-tray");
await expect(tray).toBeVisible();
await tray.getByRole("button", { name: "Bullet list" }).click();
await expect(input.locator(":scope > ul")).toHaveText("selected");
await expect
.poll(() =>
page.evaluate(() => {
const selection = window.getSelection();
if (!(selection?.anchorNode && selection.focusNode)) return false;
const anchorRange = document.createRange();
anchorRange.setStart(selection.anchorNode, selection.anchorOffset);
anchorRange.collapse(true);
const focusRange = document.createRange();
focusRange.setStart(selection.focusNode, selection.focusOffset);
focusRange.collapse(true);
return (
anchorRange.compareBoundaryPoints(Range.START_TO_START, focusRange) >
0
);
}),
)
.toBe(true);
});
test("Buzz theme uses the primary color for the selection formatter", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.fill("before selected after");
await selectText(input, "selected");
const tray = page.getByTestId("selection-formatting-tray");
await expect(tray).toBeVisible();
const colors = await tray.evaluate((element) => {
const probe = document.createElement("span");
probe.style.backgroundColor = "hsl(var(--primary))";
probe.style.color = "hsl(var(--primary-foreground))";
document.body.appendChild(probe);
const probeStyles = getComputedStyle(probe);
const trayStyles = getComputedStyle(element);
const result = {
primaryBackground: probeStyles.backgroundColor,
primaryForeground: probeStyles.color,
trayBackground: trayStyles.backgroundColor,
trayForeground: trayStyles.color,
};
probe.remove();
return result;
});
expect(colors.trayBackground).toBe(colors.primaryBackground);
expect(colors.trayForeground).toBe(colors.primaryForeground);
});
@@ -0,0 +1,82 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
/**
* Composer tooltips set disableHoverableContent, so they must dismiss the
* instant the cursor leaves the trigger including when it slides onto the
* tooltip popup itself (Radix's default hoverable-content behavior would
* keep it open, camping it over the message editor).
*/
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
/** Hover the trigger, then slide the cursor onto the tooltip popup and
* assert the tooltip dismisses instead of persisting. */
async function expectTooltipDismissesOnLeave(
page: import("@playwright/test").Page,
trigger: import("@playwright/test").Locator,
tooltipName: string,
) {
await trigger.hover();
const tip = page.getByRole("tooltip", { name: tooltipName });
await expect(tip).toBeVisible();
// Slide off the trigger onto the tooltip popup.
const box = await tip.boundingBox();
if (!box) throw new Error("no tooltip box");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, {
steps: 5,
});
await expect(tip).toBeHidden();
}
test("composer toolbar tooltip dismisses when cursor leaves the trigger", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expectTooltipDismissesOnLeave(
page,
page.getByTestId("message-insert-mention"),
"Mention someone",
);
});
test("formatting sub-toolbar tooltip dismisses when cursor leaves the trigger", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Open the formatting sub-toolbar (Bold / Italic / lists / Quote …).
await page.getByRole("button", { name: "Toggle formatting" }).first().click();
const bold = page.getByRole("button", { name: "Bold" });
await expect(bold).toBeVisible();
// Tooltip text is "<label> (<shortcut>)" for items that carry a shortcut.
await expectTooltipDismissesOnLeave(page, bold, "Bold (⌘B)");
});
test("emoji picker tooltip dismisses when cursor leaves the trigger", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// The emoji button wraps its tooltip around a nested PopoverTrigger, so
// cover it separately from the plain toolbar buttons.
await expectTooltipDismissesOnLeave(
page,
page.getByTestId("composer-emoji-button"),
"Insert emoji",
);
});
@@ -0,0 +1,346 @@
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/config-bridge";
// Use well-known test pubkeys that map to distinct config surface fixtures
const GOOSE_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
const PRESPAWN_PUBKEY = TEST_IDENTITIES.bob.pubkey;
const RUNTIME_OVERRIDE_PUBKEY = TEST_IDENTITIES.outsider.pubkey;
// Synthetic agent whose config surface mixes four distinct provenance origins
// (matches PUBKEY_MULTI_ORIGIN in e2eBridge buildMockConfigSurface).
const MULTI_ORIGIN_PUBKEY =
"abc1230000000000000000000000000000000000000000000000000000000def";
const BUZZ_AGENT_PUBKEY =
"b0220000000000000000000000000000000000000000000000000000000000a9";
const MANAGED_AGENTS = [
{
pubkey: GOOSE_PUBKEY,
name: "Goose Agent",
status: "running" as const,
channelNames: ["agents"],
},
{
pubkey: PRESPAWN_PUBKEY,
name: "Pre-Spawn Agent",
status: "stopped" as const,
channelNames: ["agents"],
},
{
pubkey: RUNTIME_OVERRIDE_PUBKEY,
name: "Runtime Override Agent",
status: "running" as const,
channelNames: ["agents"],
},
{
pubkey: MULTI_ORIGIN_PUBKEY,
name: "Multi-Origin Agent",
status: "running" as const,
channelNames: ["agents"],
},
];
async function waitForInvokeBridge(page: import("@playwright/test").Page) {
await page.waitForFunction(
() => {
const tauriWindow = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
__TAURI_INTERNALS__?: { invoke?: unknown };
};
return (
typeof tauriWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" ||
typeof tauriWindow.__TAURI_INTERNALS__?.invoke === "function"
);
},
null,
{ timeout: 5_000 },
);
}
async function invokeMockCommand(
page: import("@playwright/test").Page,
command: string,
payload?: Record<string, unknown>,
): Promise<unknown> {
await waitForInvokeBridge(page);
return page.evaluate(
async ({ command: cmd, payload: pl }) => {
const tauriWindow = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
__TAURI_INTERNALS__?: {
invoke?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
};
const invoke =
tauriWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ??
tauriWindow.__TAURI_INTERNALS__?.invoke;
if (!invoke) throw new Error("Mock invoke bridge is unavailable.");
return invoke(cmd, pl);
},
{ command, payload },
);
}
async function activatePersonas(page: import("@playwright/test").Page) {
for (const id of ["builtin:fizz"]) {
await invokeMockCommand(page, "set_persona_active", { id, active: true });
}
}
/**
* Open the #agents channel and click an agent's avatar in the message list
* to open the profile side panel, then navigate to the Runtime tab.
*/
async function openAgentProfileFromChannel(
page: import("@playwright/test").Page,
agentName: string,
) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForInvokeBridge(page);
await activatePersonas(page);
// Navigate to the #agents channel
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
// Find the message row from this agent and click the avatar to open profile
const messageRow = page.getByTestId("message-row").filter({
has: page.getByText(agentName, { exact: false }),
});
await expect(messageRow.first()).toBeVisible({ timeout: 5_000 });
await messageRow.first().getByRole("button").first().click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible({ timeout: 10_000 });
// Click the Runtime tab to reveal the Configuration section.
await panel.getByRole("tab", { name: "Runtime" }).click();
// Wait for the first normalized config field ("Model") to appear.
const configAnchor = panel.getByText("Model").first();
await expect(configAnchor).toBeVisible({ timeout: 10_000 });
// Scroll the panel's internal scroll container to the bottom so the
// config section content is fully visible.
await configAnchor.scrollIntoViewIfNeeded();
await panel.evaluate((el) => {
// The scrollable container is the profileBody div with overflow-y-auto.
// Find it by checking which child actually scrolls.
const scrollable =
el.querySelector("[data-radix-scroll-area-viewport]") ??
Array.from(el.querySelectorAll("*")).find(
(child) => child.scrollHeight > child.clientHeight + 10,
) ??
el;
scrollable.scrollTop = scrollable.scrollHeight;
});
await panel.page().waitForTimeout(200);
return panel;
}
// Settle any in-flight animations before capture.
async function settleAnimations(panel: import("@playwright/test").Locator) {
await panel.evaluate((el) =>
Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)),
);
}
test.describe("config bridge screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
page.on("console", (msg) => {
if (msg.type() === "error") {
console.error("CONSOLE ERROR:", msg.text().slice(0, 500));
}
});
});
test("01 — folded config panel", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const panel = await openAgentProfileFromChannel(page, "Goose Agent");
// The folded config panel: provenance sentences inline under each value.
await expect(panel.getByText("Set in Buzz").first()).toBeVisible();
await settleAnimations(panel);
await panel.screenshot({ path: `${SHOTS}/01-folded-config-panel.png` });
});
test("02 — live runtime override", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const panel = await openAgentProfileFromChannel(
page,
"Runtime Override Agent",
);
// A runtimeOverride model shows the live model, the persona baseline as a
// NON-struck secondary value, and the "Live override" sentence.
await expect(
panel.getByText("Live override (this session only)"),
).toBeVisible();
await expect(panel.getByText("gpt-4o", { exact: true })).toBeVisible();
await settleAnimations(panel);
await panel.screenshot({
path: `${SHOTS}/02-live-runtime-override.png`,
});
});
test("03 — provenance sentences", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const panel = await openAgentProfileFromChannel(page, "Multi-Origin Agent");
// Multiple distinct provenance origins visible at once.
await expect(panel.getByText("Set in Buzz").first()).toBeVisible();
await expect(panel.getByText("Inherited from template")).toBeVisible();
await expect(
panel.getByText("From environment variable (GOOSE_MODE)"),
).toBeVisible();
await expect(
panel.getByText("From config file (~/.config/goose/config.yaml)").first(),
).toBeVisible();
await settleAnimations(panel);
await panel.screenshot({
path: `${SHOTS}/03-provenance-sentences.png`,
});
});
test("04 — pre-spawn state", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const panel = await openAgentProfileFromChannel(page, "Pre-Spawn Agent");
// ACP-only fields show "Available after agent starts" before spawn.
await expect(
panel.getByText("Available after agent starts").first(),
).toBeVisible();
await settleAnimations(panel);
await panel.screenshot({ path: `${SHOTS}/04-pre-spawn-state.png` });
});
test("05 — advanced flat list", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const panel = await openAgentProfileFromChannel(page, "Goose Agent");
// Advanced runtime fields render directly in the profile panel's flat list,
// grouped under their own "Advanced" header.
await expect(panel.getByText("active_provider")).toBeVisible();
await expect(panel.getByText("Advanced", { exact: true })).toHaveCount(1);
// MCP servers render once each under their group label.
await expect(panel.getByText("developer", { exact: true })).toHaveCount(1);
await expect(panel.getByText("MCP Servers", { exact: true })).toHaveCount(
1,
);
await settleAnimations(panel);
await panel.screenshot({ path: `${SHOTS}/05-advanced-expanded.png` });
});
test("06 — buzz-agent empty MCP servers", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: BUZZ_AGENT_PUBKEY,
name: "Buzz Agent",
status: "running" as const,
channelNames: ["agents"],
},
],
});
const panel = await openAgentProfileFromChannel(page, "Buzz Agent");
await expect(
panel.getByText("No custom servers configured", { exact: true }),
).toBeVisible();
await expect(panel.getByText("MCP Servers", { exact: true })).toHaveCount(
1,
);
});
test("07 — profile side panel — Configuration section", async ({ page }) => {
// charlie (554cef…) is the well-known test pubkey that the mock bridge
// seeds as a bot owned by the test viewer, so isBot + isOwner + managedAgent
// are all true — the Configuration section renders in the profile panel.
await installMockBridge(page, {
managedAgents: [
{
pubkey:
"554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea",
name: "Charlie",
status: "running" as const,
channelNames: ["agents"],
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForInvokeBridge(page);
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
// Click an agent avatar in a full message row to open the profile side panel.
await page
.getByTestId("message-row")
.filter({ has: page.locator('[data-testid^="message-avatar-"]') })
.last()
.getByRole("button")
.first()
.click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible({ timeout: 10_000 });
// The Configuration section lives inside the Runtime tab — click it first.
await panel.getByRole("tab", { name: "Runtime" }).click();
// Wait for the config section to render and scroll it into view so
// it is fully visible before capture.
const configAnchor = panel.getByText("Model").first();
await expect(configAnchor).toBeVisible({ timeout: 10_000 });
await configAnchor.scrollIntoViewIfNeeded();
// Scroll the panel's internal scroll container to the bottom so config
// fields are fully visible, not just the heading at the edge.
await panel.evaluate((el) => {
const scrollable =
el.querySelector("[data-radix-scroll-area-viewport]") ??
Array.from(el.querySelectorAll("*")).find(
(child) => child.scrollHeight > child.clientHeight + 10,
) ??
el;
scrollable.scrollTop = scrollable.scrollHeight;
});
await panel.page().waitForTimeout(200);
// Settle any in-flight animations before capture.
await panel.evaluate((el) =>
Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)),
);
await panel.screenshot({
path: `${SHOTS}/06-profile-side-panel-config.png`,
});
});
});
@@ -0,0 +1,214 @@
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/core-memory";
const OBSERVER_AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
const CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301";
const NOW = new Date("2025-06-15T12:00:00Z").toISOString();
const MANAGED_AGENTS = [
{
pubkey: OBSERVER_AGENT_PUBKEY,
name: "Observer Agent",
status: "running" as const,
channelNames: ["agents"],
},
];
const SYSTEM_PROMPT_WITH_CORE =
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n" +
"[System]\nYou are Observer Agent. You coordinate multi-agent workflows.\n\n" +
"[Agent Memory — core]\n" +
"I am Duncan — full-stack executor on the Buzz team.\n\n" +
"## Lessons Learned\n\n" +
"### Tag teammates on handoff — ALWAYS (CRITICAL)\n" +
"After completing a task, @mention the next person in the workflow.\n\n" +
"### Source claims require a fresh fetch (CRITICAL)\n" +
"Never quote repo source off a stale clone.";
async function waitForSeedHook(page: import("@playwright/test").Page) {
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function",
null,
{ timeout: 10_000 },
);
}
async function openObserverFeedPanel(
page: import("@playwright/test").Page,
agentPubkey: string,
) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForSeedHook(page);
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
const messageRow = page
.getByTestId("message-row")
.filter({ has: page.getByText("Observer Agent", { exact: false }) });
await expect(messageRow.first()).toBeVisible({ timeout: 8_000 });
await messageRow.first().getByRole("button").first().click();
const profilePanel = page.getByTestId("user-profile-panel");
await expect(profilePanel).toBeVisible({ timeout: 10_000 });
const activityBtn = page.getByTestId(
`user-profile-view-activity-${agentPubkey}`,
);
await expect(activityBtn).toBeVisible({ timeout: 5_000 });
await activityBtn.click();
const feedPanel = page.getByTestId("agent-session-thread-panel");
await expect(feedPanel).toBeVisible({ timeout: 10_000 });
return feedPanel;
}
async function seedObserverEvents(
page: import("@playwright/test").Page,
agentPubkey: string,
events: Array<{
seq: number;
timestamp: string;
kind: string;
agentIndex: number | null;
channelId: string | null;
sessionId: string | null;
turnId: string | null;
payload: unknown;
}>,
) {
await page.evaluate(
({ pubkey, evts }) => {
window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({
agentPubkey: pubkey,
events: evts,
});
},
{ pubkey: agentPubkey, evts: events },
);
await page.waitForTimeout(300);
}
async function settleAnimations(panel: import("@playwright/test").Locator) {
await panel.evaluate((el) =>
Promise.all(
el
.getAnimations({ subtree: true })
.filter((a) => {
const timing = a.effect?.getTiming();
return timing?.iterations !== Number.POSITIVE_INFINITY;
})
.map((a) => a.finished),
),
);
}
test.describe("core memory section screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error("PAGE ERROR:", err.message);
});
});
test("01-core-memory-collapsed", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: null,
payload: {
jsonrpc: "2.0",
id: 1,
method: "session/new",
params: { systemPrompt: SYSTEM_PROMPT_WITH_CORE },
},
},
]);
await expect(feedPanel.getByText("System prompt")).toBeVisible({
timeout: 5_000,
});
// Open the outer details element so the sections are visible.
await feedPanel.getByTestId("transcript-metadata-item").evaluate((el) => {
if (el.tagName === "DETAILS") (el as HTMLDetailsElement).open = true;
for (const details of el.querySelectorAll("details")) {
details.open = true;
}
});
// Wait for Core Memory section label to appear (collapsed by default).
await expect(feedPanel.getByText("Core Memory")).toBeVisible({
timeout: 5_000,
});
await settleAnimations(feedPanel);
// Crop to the transcript-metadata-item to show the sections tightly.
const metadataItem = feedPanel.getByTestId("transcript-metadata-item");
await metadataItem.screenshot({
path: `${SHOTS}/01-core-memory-collapsed.png`,
});
});
test("02-core-memory-expanded", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: null,
payload: {
jsonrpc: "2.0",
id: 1,
method: "session/new",
params: { systemPrompt: SYSTEM_PROMPT_WITH_CORE },
},
},
]);
await expect(feedPanel.getByText("System prompt")).toBeVisible({
timeout: 5_000,
});
await feedPanel.getByTestId("transcript-metadata-item").evaluate((el) => {
if (el.tagName === "DETAILS") (el as HTMLDetailsElement).open = true;
for (const details of el.querySelectorAll("details")) {
details.open = true;
}
});
// Expand all section accordions including Core Memory.
const sectionButtons = feedPanel
.getByTestId("transcript-metadata-item")
.getByTestId("transcript-prompt-context-sections")
.getByRole("button");
const allButtons = await sectionButtons.all();
expect(allButtons.length).toBeGreaterThan(0);
for (const btn of allButtons) {
await btn.click();
}
await expect(feedPanel.getByText("Core Memory")).toBeVisible({
timeout: 5_000,
});
await settleAnimations(feedPanel);
const metadataItem = feedPanel.getByTestId("transcript-metadata-item");
await metadataItem.screenshot({
path: `${SHOTS}/02-core-memory-expanded.png`,
});
});
});
+116
View File
@@ -0,0 +1,116 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
const SHORTCODE = "buzz";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
// The mock emoji sets point at example.com placeholder URLs that don't
// resolve, so the <img> would render broken. Serve a visible square glyph
// for any example.com emoji image so sizing/alignment can be asserted.
const SVG = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#22c55e"/>
<circle cx="16" cy="12" r="5" fill="#fef3c7"/>
<path d="M8 25c2-7 14-7 16 0" fill="#fef3c7"/>
</svg>`;
await page.route("https://example.com/e2e/**", (route) =>
route.fulfill({ contentType: "image/svg+xml", body: SVG }),
);
});
test("composer renders a custom emoji inline", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially(`shipping it :${SHORTCODE}:`);
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(1);
});
test("settings card splits My emoji from read-only Community emoji", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
await page.getByTestId("settings-nav-custom-emoji").click();
// The mock identity owns :buzz: (removable); :narf: belongs to another
// member (read-only, no trash button).
const card = page.getByTestId("settings-custom-emoji");
await expect(card.getByTestId("custom-emoji-mine")).toContainText(":buzz:");
const mine = card.getByTestId("custom-emoji-mine");
await expect(
mine.getByRole("button", { name: "Remove :buzz:" }),
).toBeVisible();
const community = card.getByTestId("custom-emoji-community");
await expect(community).toContainText(":narf:");
// No remove button for someone else's emoji.
await expect(
community.getByRole("button", { name: /^Remove :/ }),
).toHaveCount(0);
});
test("message list renders inline and emoji-only messages with Slack-style emoji sizing", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially(`inline :${SHORTCODE}: message`);
await page.getByTestId("send-message").click();
await input.click();
await input.pressSequentially(`:${SHORTCODE}: 😀 ❤️`);
await page.getByTestId("send-message").click();
const rows = page.getByTestId("message-row");
const inlineRow = rows
.filter({
has: page.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`),
hasText: "inline message",
})
.last();
const emojiOnlyRow = rows
.filter({
has: page.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`),
hasText: "😀 ❤️",
})
.first();
await expect(inlineRow).toBeVisible();
await expect(emojiOnlyRow).toBeVisible();
const inlineEmoji = inlineRow.locator(
`img[data-custom-emoji][alt=":${SHORTCODE}:"]`,
);
const emojiOnlyEmoji = emojiOnlyRow.locator(
`img[data-custom-emoji][alt=":${SHORTCODE}:"]`,
);
await expect
.poll(async () => (await inlineEmoji.boundingBox())?.height ?? 0)
.toBeGreaterThan(10);
await expect
.poll(async () => {
const inlineBox = await inlineEmoji.boundingBox();
const emojiOnlyBox = await emojiOnlyEmoji.boundingBox();
if (!inlineBox || !emojiOnlyBox || inlineBox.height === 0) {
return 0;
}
return emojiOnlyBox.height / inlineBox.height;
})
.toBeGreaterThan(1.8);
});
+670
View File
@@ -0,0 +1,670 @@
import { expect, test } from "@playwright/test";
import * as fs from "node:fs";
import * as path from "node:path";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
// Custom-emoji end-to-end guard.
//
// The composer renders a known `:shortcode:` as a real inline atom node
// (`img[data-custom-emoji]`) that selects/copies/deletes as one unit, while
// still serializing to `:shortcode:` on send. The message timeline renders the
// same shortcode as `img[data-custom-emoji]` via remarkCustomEmoji.
//
// The `:buzz:` shortcode lives in a member-authored kind:30030 set
// (d=`buzz:custom-emoji`) served by the mock bridge from two distinct
// pubkeys. `listCustomEmoji` reads every member's set over the relay WS and
// unions them (deduped by shortcode+url) into the community palette — which is
// live even in mock-bridge mode (the mock only intercepts Tauri commands), so
// this spec uses the simpler mock-bridge setup like messaging.spec.ts.
const SHORTCODE = "buzz";
const MOCK_MEDIA_PROXY_PORT = 54321;
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
) {
await expect
.poll(async () =>
page.evaluate(
({ ch }) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ??
false,
{ ch: channelName },
),
)
.toBe(true);
}
async function openGeneral(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
// The mock emoji sets point at example.com placeholder URLs. Fulfill them
// with a real image so visibility assertions still prove the emoji rendered
// as an actual, non-broken custom-emoji image.
await page.route("https://example.com/e2e/**", (route) =>
route.fulfill({
contentType: "image/svg+xml",
body: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32" fill="#22c55e"/></svg>',
}),
);
// Keep the reaction fixture deliberately non-square so the real renderer can
// prove object-contain letterboxes it inside the fixed square glyph box.
await page.route(
`http://127.0.0.1:${MOCK_MEDIA_PROXY_PORT}/media/**`,
(route) =>
route.fulfill({
contentType: "image/svg+xml",
body: '<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="#22c55e"/></svg>',
}),
);
});
test("typing a known :shortcode: renders an inline emoji node in the composer", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
// pressSequentially (not fill) so the node input rule fires on the final ":".
await input.pressSequentially(`:${SHORTCODE}:`);
const node = input.locator("img[data-custom-emoji]");
await expect(node).toHaveCount(1);
await expect(node).toHaveAttribute("alt", `:${SHORTCODE}:`);
await expect(node).toHaveAttribute("data-shortcode", SHORTCODE);
// The raw text must NOT linger alongside the node.
await expect(input).not.toContainText(`:${SHORTCODE}:`);
});
test("emoji autocomplete ranks an exact standard shortcode before a custom substring", async ({
page,
}, testInfo) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially(":joy");
const autocomplete = page.getByTestId("emoji-autocomplete");
await expect(autocomplete).toBeVisible();
const labels = await autocomplete.locator("button").allTextContents();
expect(labels[0]).toContain(":joy:");
expect(
labels.findIndex((label) => label.includes(":bufo_joy:")),
).toBeGreaterThan(0);
const screenshotDir = path.resolve(
"test-results/emoji-autocomplete-screenshots",
);
fs.mkdirSync(screenshotDir, { recursive: true });
const screenshotPath = path.join(
screenshotDir,
"joy-exact-before-custom-substring.png",
);
await waitForAnimations(page);
await autocomplete.screenshot({ path: screenshotPath });
await testInfo.attach("joy-exact-before-custom-substring", {
path: screenshotPath,
contentType: "image/png",
});
});
test("emoji autocomplete keeps semantic matches ahead of loose shortcode fallbacks", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially(":sad");
const autocomplete = page.getByTestId("emoji-autocomplete");
await expect(autocomplete).toBeVisible();
await expect(autocomplete.locator("button").first()).not.toContainText(
":sandwich:",
);
});
test("custom emoji deletes as a single unit (like a built-in emoji)", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially(`hi :${SHORTCODE}:`);
const node = input.locator("img[data-custom-emoji]");
await expect(node).toHaveCount(1);
// One backspace at the end removes the whole atom node, not a character of
// hidden text.
await input.press("Backspace");
await expect(node).toHaveCount(0);
await expect(input).toContainText("hi");
});
test("custom emoji round-trips through select-all + send to the timeline", async ({
page,
}) => {
await openGeneral(page);
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially(`:${SHORTCODE}:`);
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(1);
// Select-all then a single delete clears the node as one unit, proving it is
// part of the selectable document (the bug was the caret skipping it).
await input.press("ControlOrMeta+a");
await input.press("Backspace");
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(0);
// Re-enter and send: it must serialize to `:shortcode:` and re-render as an
// <img> in the timeline (remarkCustomEmoji), not as raw text.
await input.pressSequentially(`:${SHORTCODE}:`);
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(1);
await page.getByTestId("send-message").click();
const sentEmoji = page
.getByTestId("message-timeline")
.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`);
await expect(sentEmoji.last()).toBeVisible();
await sentEmoji.last().hover();
await expect(page.getByText(`:${SHORTCODE}:`).last()).toBeVisible();
// The composer clears after send.
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(0);
});
test("native emoji-only messages leave space below the author metadata", async ({
page,
}) => {
await openGeneral(page);
const nativeEmoji = "😜";
const input = page.getByTestId("message-input");
await input.click();
await page.keyboard.insertText(nativeEmoji);
await page.getByTestId("send-message").click();
const row = page
.getByTestId("message-row")
.filter({ hasText: nativeEmoji })
.last();
await expect(row).toBeVisible();
const author = row.getByText("npub1mock...", { exact: true });
const emojiBody = row.locator(".text-4xl").last();
await expect(author).toBeVisible();
await expect(emojiBody).toContainText(nativeEmoji);
await expect(emojiBody).toBeVisible();
await expect
.poll(async () => {
const authorBox = await author.boundingBox();
const emojiBodyBox = await emojiBody.boundingBox();
if (!authorBox || !emojiBodyBox) {
return Number.NEGATIVE_INFINITY;
}
return emojiBodyBox.y - (authorBox.y + authorBox.height);
})
.toBeGreaterThanOrEqual(4);
});
// Regression guard for custom-emoji REACTIONS.
//
// The bug (shipped in the custom-emoji launch, PR #816): the reaction renderer
// put the relay emoji URL straight into <img src> without going through
// rewriteRelayUrl(). WKWebView bypasses the VPN tunnel, so the direct relay URL gets a
// Cloudflare Access 403 and shows a broken image — even though the same emoji
// rendered fine inline in chat (that path rewrites). The chat path was covered
// by the tests above; the reaction path was not, which is why it slipped.
//
// This drives the real interactive react flow (hover -> Open reactions ->
// emoji-mart custom category) so it exercises the add_reaction Tauri command,
// then asserts the rendered reaction <img> src points at the loopback media
// proxy. On the pre-fix code the src would be the raw relay URL, so this test
// fails there — exactly the assertion that would have caught the bug.
//
// `:react:` is a relay-hosted fixture emoji (URL on the relay origin matching
// rewriteRelayUrl()'s /media/{64-hex}.{ext} pattern), and the mock bridge
// answers get_media_proxy_port with port 54321 so the rewrite resolves to a
// real 127.0.0.1 URL rather than the buzz-media:// fallback.
const REACTION_SHORTCODE = "react";
const SELECTED_ACTION_CLASS = /(^|\s)bg-secondary(\s|$)/;
// A seeded message in `general` with a real 64-hex id — the only reactable
// target in mock mode (getReactionTargetId() requires a 64-hex `e` tag, which
// user-sent mock messages don't have). Mirrors REACTION_TARGET_CONTENT in the
// bridge.
const REACTION_TARGET_CONTENT = "React to me with a custom emoji";
function reactionTargetRow(page: import("@playwright/test").Page) {
return page
.getByTestId("message-row")
.filter({ hasText: REACTION_TARGET_CONTENT })
.last();
}
function messageActionBar(row: import("@playwright/test").Locator) {
return row.locator("[data-testid^='message-action-bar-']");
}
function messageReactionTrigger(row: import("@playwright/test").Locator) {
return row.locator("[data-testid^='react-message-']");
}
async function quickReactionStorageContains(
page: import("@playwright/test").Page,
emoji: string,
) {
return page.evaluate((selectedEmoji) => {
for (let index = 0; index < window.localStorage.length; index += 1) {
const key = window.localStorage.key(index);
if (!key?.startsWith("buzz.quick-reaction-emojis.v1")) continue;
if (window.localStorage.getItem(key)?.includes(selectedEmoji)) {
return true;
}
}
return false;
}, emoji);
}
test("message quick reaction tray stays neutral after selecting a tray emoji", async ({
page,
}) => {
await openGeneral(page);
const row = reactionTargetRow(page);
await expect(row).toBeVisible();
await row.hover();
const quickReactionButton = row.getByRole("button", {
name: "React with :+1:",
});
await expect(quickReactionButton).toBeVisible();
await quickReactionButton.click();
await expect(row.getByLabel("Toggle 👍 reaction")).toBeVisible();
await row.hover();
await expect(quickReactionButton).not.toHaveAttribute("aria-pressed", "true");
await expect(quickReactionButton).not.toHaveClass(SELECTED_ACTION_CLASS);
await expect(messageReactionTrigger(row)).not.toHaveClass(
SELECTED_ACTION_CLASS,
);
});
test("reacting with a custom emoji renders via the loopback media proxy", async ({
page,
}) => {
await openGeneral(page);
// Reveal the hover action bar on the seeded reaction-target message, then
// open the reaction picker.
const row = reactionTargetRow(page);
await expect(row).toBeVisible();
await row.hover();
await row.getByLabel("Open reactions").click();
// emoji-mart renders inside a Shadow DOM web component. Search by shortcode
// to surface the custom emoji, then click it.
const picker = page.locator("em-emoji-picker");
await picker.locator("input[type='search']").fill(REACTION_SHORTCODE);
await picker
.getByRole("button", { name: `:${REACTION_SHORTCODE}:` })
.first()
.click();
// The reaction pill renders the custom emoji as an <img alt=":react:">. Its
// src must be the loopback proxy URL — proving rewriteRelayUrl() ran. A raw
// relay URL here is the bug.
const reactionPill = row.getByLabel(
`Toggle :${REACTION_SHORTCODE}: reaction`,
);
const reactionImg = reactionPill.locator(
`img[alt=':${REACTION_SHORTCODE}:']`,
);
await expect(reactionImg).toBeVisible();
await expect(reactionImg).toHaveAttribute(
"src",
new RegExp(
`^http://127\\.0\\.0\\.1:${MOCK_MEDIA_PROXY_PORT}/media/[\\da-f]{64}\\.png$`,
),
);
await expect(reactionPill).toHaveCSS("height", "28px");
await expect(reactionImg).toHaveCSS("height", "14px");
await expect(reactionImg).toHaveCSS("width", "14px");
await expect(reactionImg).toHaveCSS("object-fit", "contain");
await expect(reactionImg).toHaveCSS("transform", "none");
await expect
.poll(() =>
reactionImg.evaluate((image) => {
const imageRect = image.getBoundingClientRect();
const pillRect = image.closest("button")?.getBoundingClientRect();
if (!(image instanceof HTMLImageElement) || !pillRect) return null;
return {
naturalHeight: image.naturalHeight,
naturalWidth: image.naturalWidth,
topOffset: imageRect.top - pillRect.top,
};
}),
)
.toEqual({ naturalHeight: 20, naturalWidth: 40, topOffset: 7 });
await expect
.poll(() => quickReactionStorageContains(page, `:${REACTION_SHORTCODE}:`))
.toBe(true);
await expect(
messageActionBar(row).locator("button[title=':react:']"),
).toHaveCount(0);
await expect(messageReactionTrigger(row)).not.toHaveClass(
SELECTED_ACTION_CLASS,
);
const inlineAddReactionButton = row.getByLabel("Add reaction");
// The picker closes with the pointer/focus position depending on animation
// timing. Put the row into a deterministic idle state before checking the
// pill's pre-existing hidden behavior.
await page.mouse.move(0, 0);
await page.evaluate(() => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
});
await expect
.poll(() =>
inlineAddReactionButton.evaluate((button) => {
return getComputedStyle(button).opacity;
}),
)
.toBe("0");
await expect
.poll(() =>
inlineAddReactionButton.evaluate((button) => {
const rect = button.getBoundingClientRect();
return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
}),
)
.toBe("40x28");
await expect
.poll(() =>
inlineAddReactionButton.evaluate((button) => {
return getComputedStyle(button).transitionProperty;
}),
)
.not.toContain("width");
await row.hover();
await expect(inlineAddReactionButton).toBeVisible();
await expect
.poll(() =>
inlineAddReactionButton.evaluate((button) => {
const rect = button.getBoundingClientRect();
return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
}),
)
.toBe("40x28");
// Toggle the reaction back off: click the pill, which fires remove_reaction
// -> emits a kind:5 deletion targeting the reaction event. The pill must
// disappear. Guards the mock-bridge deletion path: the reaction event needs a
// 64-hex id, because the timeline only honors deletions whose `e` tag is
// 64-hex (getDeletionTargets). A 32-hex reaction id leaves a stale pill here.
await reactionPill.click();
await expect(reactionImg).toHaveCount(0);
});
// Edit-flow regression guards.
//
// Two bugs lived on the edit path and were invisible to the send-only specs
// above:
// Bug 1 — opening a message that contains a custom emoji for editing showed
// the literal `:shortcode:` text in the composer instead of the inline
// image. The node only materialized via the live input rule; loading via
// `setContent` (how edit-open seeds the composer) left it as text because
// the customEmoji node had no markdown parse rule.
// Bug 2 — adding a custom emoji while editing, then saving, shipped a bare
// `:shortcode:` because the edit-save path didn't attach NIP-30 emoji tags
// (the send path does). Without those tags the renderer can't resolve the
// shortcode → literal text in the timeline.
//
// These drive the real interactive edit flow (More actions → Edit message →
// save) so they exercise the `edit_message` Tauri command end to end. The mock
// bridge mirrors the real relay: it emits a kind:40003 edit event carrying the
// emoji tags, and the timeline overlays it via applyEditTagOverlay.
async function openMessageEditor(
page: import("@playwright/test").Page,
rowText: string,
) {
const row = page
.getByTestId("message-row")
.filter({ hasText: rowText })
.last();
await expect(row).toBeVisible();
await row.hover();
await row.getByLabel("More actions").click();
await page.getByRole("menuitem", { name: "Edit message" }).click();
// The composer enters edit mode (shows the edit-target banner).
await expect(page.getByTestId("edit-target")).toBeVisible();
}
test("editing a message with a custom emoji shows the image, not the shortcode (Bug 1)", async ({
page,
}) => {
await openGeneral(page);
// Send our own message containing a custom emoji so it is editable.
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially(`edit-bug1 :${SHORTCODE}:`);
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(1);
await page.getByTestId("send-message").click();
await expect(
page
.getByTestId("message-timeline")
.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`)
.last(),
).toBeVisible();
// Open it for editing. The composer loads via setContent — the path the
// markdown parse rule fixes. The known shortcode must render as the inline
// node, NOT as literal `:buzz:` text.
await openMessageEditor(page, "edit-bug1");
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(1);
await expect(input.locator("img[data-custom-emoji]")).toHaveAttribute(
"alt",
`:${SHORTCODE}:`,
);
// The raw shortcode text must NOT linger in the editor.
await expect(input).not.toContainText(`:${SHORTCODE}:`);
});
test("adding a custom emoji while editing keeps the image after save (Bug 2)", async ({
page,
}) => {
await openGeneral(page);
// Send a plain message we'll edit to add an emoji to.
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("edit-bug2 plain");
await page.getByTestId("send-message").click();
const row = page
.getByTestId("message-row")
.filter({ hasText: "edit-bug2" })
.last();
await expect(row).toBeVisible();
// No emoji yet.
await expect(row.locator("img[data-custom-emoji]")).toHaveCount(0);
// Edit it: append a custom emoji, then save.
await openMessageEditor(page, "edit-bug2");
await input.click();
await input.pressSequentially(` :${SHORTCODE}:`);
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(1);
await page.getByTestId("send-message").click();
// After the edit round-trips through edit_message → kind:40003 (with emoji
// tags) → applyEditTagOverlay, the timeline must render the emoji as an
// <img>, not a bare `:buzz:`. The pre-fix edit path shipped no emoji tags,
// so this row would show literal text and fail here.
await expect(
row.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`),
).toBeVisible();
await expect(row).not.toContainText(`:${SHORTCODE}:`);
});
// System-message reaction guard. The original bug in this PR: system messages
// (joins, topic changes, etc.) couldn't take reactions. The seeded kind:40099
// join event renders via SystemMessageRow, which now carries the reaction
// affordance. This drives the real react flow on a system row and asserts the
// pill appears — the surface the fix targeted.
test("a system message accepts a custom-emoji reaction", async ({ page }) => {
await openGeneral(page);
const row = page.getByTestId("system-message-row").first();
await expect(row).toBeVisible();
await row.hover();
await row.getByLabel("Open reactions").click();
const picker = page.locator("em-emoji-picker");
await picker.locator("input[type='search']").fill(REACTION_SHORTCODE);
await picker
.getByRole("button", { name: `:${REACTION_SHORTCODE}:` })
.first()
.click();
const reactionImg = row
.getByLabel(`Toggle :${REACTION_SHORTCODE}: reaction`)
.locator(`img[alt=':${REACTION_SHORTCODE}:']`);
await expect(reactionImg).toBeVisible();
});
test("emoji picker search input has spellcheck, autocorrect, and autocapitalize disabled", async ({
page,
}) => {
await openGeneral(page);
// Open the reaction picker on the seeded reactable message.
const row = reactionTargetRow(page);
await expect(row).toBeVisible();
await row.hover();
await row.getByLabel("Open reactions").click();
// Wait for the picker to be visible, then read the shadow-root input attributes.
const picker = page.locator("em-emoji-picker");
await expect(picker.locator("input[type='search']")).toBeVisible();
const attrs = await picker.evaluate((el: Element) => {
const input = (
el as HTMLElement & { shadowRoot: ShadowRoot }
).shadowRoot?.querySelector<HTMLInputElement>('input[type="search"]');
if (!input) throw new Error("search input not found in shadow root");
return {
spellcheck: input.spellcheck,
autocorrect: input.getAttribute("autocorrect"),
autocapitalize: input.getAttribute("autocapitalize"),
};
});
expect(attrs.spellcheck).toBe(false);
expect(attrs.autocorrect).toBe("off");
expect(attrs.autocapitalize).toBe("off");
});
// Regression guard for PR #1438: after the spellcheck fix, the shadow-DOM
// traversal must also own autofocus so Radix's focus-scope can't steal it.
// Will reported that opening the picker required a manual click before typing.
test("emoji picker search input is focused immediately on open (no manual click needed)", async ({
page,
}) => {
await openGeneral(page);
// Open the reaction picker — it passes autoFocus, so the input must receive
// focus via our shadow traversal before the user interacts.
const row = reactionTargetRow(page);
await expect(row).toBeVisible();
await row.hover();
await row.getByLabel("Open reactions").click();
const picker = page.locator("em-emoji-picker");
await expect(picker.locator("input[type='search']")).toBeVisible();
// The search input must be the active element inside the shadow root.
// If Radix's focus-scope wins instead, shadowRoot.activeElement is the
// popover host (or null), not the input — this assertion catches that.
const isFocused = await picker.evaluate((el: Element) => {
const host = el as HTMLElement & { shadowRoot: ShadowRoot };
const input = host.shadowRoot?.querySelector<HTMLInputElement>(
'input[type="search"]',
);
if (!input) return false;
return host.shadowRoot?.activeElement === input;
});
expect(isFocused).toBe(true);
});
test("composer emoji picker search input is focused immediately on open", async ({
page,
}) => {
await openGeneral(page);
// Open the composer emoji picker via the toolbar button.
await page.getByTestId("composer-emoji-button").click();
const picker = page.locator("em-emoji-picker");
await expect(picker.locator("input[type='search']")).toBeVisible();
// The search input must be the active element inside the shadow root so
// the user can type immediately without a manual click.
const isFocused = await picker.evaluate((el: Element) => {
const host = el as HTMLElement & { shadowRoot: ShadowRoot };
const input = host.shadowRoot?.querySelector<HTMLInputElement>(
'input[type="search"]',
);
if (!input) return false;
return host.shadowRoot?.activeElement === input;
});
expect(isFocused).toBe(true);
// Pick an emoji and verify focus returns to the composer message input,
// not left stranded in the now-closed popover. The insertEmoji handler
// calls editor.chain().focus() before setIsEmojiPickerOpen(false), so
// the Tiptap editor owns focus before Radix's onCloseAutoFocus fires.
await picker.getByRole("button", { name: "😀" }).first().click();
await expect(page.getByTestId("message-input")).toBeFocused();
});
test("composer emoji picker restores editor focus on Escape dismiss", async ({
page,
}) => {
await openGeneral(page);
// Open the composer emoji picker via the toolbar button.
await page.getByTestId("composer-emoji-button").click();
const picker = page.locator("em-emoji-picker");
await expect(picker.locator("input[type='search']")).toBeVisible();
// Dismiss via Escape without selecting an emoji. The onClose callback
// in ComposerEmojiPicker must restore focus to the editor so the user
// can keep typing without an extra click.
await page.keyboard.press("Escape");
await expect(page.getByTestId("message-input")).toBeFocused();
});
+415
View File
@@ -0,0 +1,415 @@
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { seedActiveIdentity } from "../helpers/onboarding";
// Community deep links that arrive before machine onboarding complete are
// drained from Rust into a persisted transaction and acknowledged immediately.
// Invite claiming waits until setup finishes and the final identity is known.
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
const COMMUNITY_ONBOARDING_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
const TRANSACTION_STORAGE_KEY = "buzz-community-onboarding-transaction.v1";
const COMMUNITY_RELAY_URL = "wss://hive.example.com";
const PENDING_JOIN_LINK = {
id: "dl-join-1",
kind: "join" as const,
relayUrl: "wss://hive.example.com",
code: "abc.def",
};
const PENDING_CONNECT_LINK = {
id: "dl-connect-1",
kind: "connect" as const,
relayUrl: "wss://hive.example.com",
code: null,
};
const PENDING_ADD_COMMUNITY_LINK = {
id: "dl-add-community-1",
kind: "add-community" as const,
relayUrl: "wss://acme.communities.buzz.xyz",
code: null,
name: "Acme Team",
};
const SECOND_PENDING_ADD_COMMUNITY_LINK = {
id: "dl-add-community-2",
kind: "add-community" as const,
relayUrl: "wss://beta.communities.buzz.xyz",
code: null,
name: "Beta Team",
};
test("join deep link is acknowledged without claiming before setup", async ({
page,
}) => {
let claimCalls = 0;
await page.route("**/api/invites/claim", async (route) => {
claimCalls++;
await route.abort();
});
await installMockBridge(
page,
{ pendingCommunityDeepLinks: [PENDING_JOIN_LINK] },
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
const gate = page.getByTestId("pending-invite-gate");
await expect(gate).toBeVisible();
await expect(
page.getByRole("heading", { name: "Opening community link" }),
).toBeVisible();
await page.getByTestId("pending-invite-continue").click();
await expect(gate).toHaveCount(0);
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
expect(claimCalls).toBe(0);
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
TRANSACTION_STORAGE_KEY,
),
)
.toContain('"stage":"claiming"');
});
test("connect deep link shows a static acknowledgment during setup", async ({
page,
}) => {
// No invite code means nothing to confirm against the relay — the gate
// acknowledges the link and waits for the user instead of auto-advancing.
await installMockBridge(
page,
{ pendingCommunityDeepLinks: [PENDING_CONNECT_LINK] },
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
const gate = page.getByTestId("pending-invite-gate");
await expect(gate).toBeVisible();
await expect(
page.getByRole("heading", { name: "Opening community link" }),
).toBeVisible();
await expect(gate).toContainText("hive");
// Continue setup dismisses the gate but keeps the transaction: the
// connect resumes in CommunityOnboardingFlow after machine setup.
await page.getByTestId("pending-invite-continue").click();
await expect(gate).toHaveCount(0);
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
TRANSACTION_STORAGE_KEY,
),
)
.toContain('"acknowledged":true');
});
test("add-community deep link starts onboarding when no community is configured", async ({
page,
}) => {
// profileReadError forces the fallback path (error → profile step), so the
// test asserts pre-existing-profile behavior without the default mock
// identity's has_profile_event:true triggering the skip.
await installMockBridge(
page,
{
pendingCommunityDeepLinks: [PENDING_ADD_COMMUNITY_LINK],
profileReadError: "no-kind-0",
},
{ skipCommunitySeed: true },
);
await page.goto("/");
await expect(page.getByTestId("community-onboarding-flow")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toBeVisible();
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
TRANSACTION_STORAGE_KEY,
),
)
.toContain('"source":"add-community"');
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
TRANSACTION_STORAGE_KEY,
),
)
.toContain('"communityName":"Acme Team"');
});
test("add-community deep link skips profile step when identity has an existing kind:0 profile", async ({
page,
}) => {
// The default mock identity (deadbeef...) is pre-seeded with
// has_profile_event:true. The skip should fire on connecting → clear the
// transaction entirely, never showing the profile step.
await installMockBridge(
page,
{ pendingCommunityDeepLinks: [PENDING_ADD_COMMUNITY_LINK] },
{ skipCommunitySeed: true },
);
await page.goto("/");
// Onboarding flow must disappear — the skip cleared the transaction.
await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0);
// handleCommunityOnboardingConnect already added the community when the
// transaction reached "connecting", so the app lands in the full UI.
await expect(page.getByTestId("sidebar-profile-avatar-button")).toBeVisible();
});
test("add-community deep link opens one editable prefill and acknowledges the queue", async ({
page,
}) => {
await installMockBridge(
page,
{ pendingCommunityDeepLinks: [PENDING_ADD_COMMUNITY_LINK] },
{ seedPreviewFeatures: true },
);
await page.goto("/");
await expect(
page.getByRole("heading", { name: "Join an existing community" }),
).toBeVisible();
const communityInput = page.getByLabel("Community URL or invite link");
await expect(communityInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.relayUrl);
await expect(page.getByLabel("Name")).toHaveCount(0);
await page.getByRole("button", { name: "Close" }).click();
await expect(
page.getByRole("heading", { name: "Join an existing community" }),
).toHaveCount(0);
await page.getByTestId("sidebar-profile-avatar-button").click();
await page.getByTestId("community-switcher").click();
await page.getByRole("menuitem", { name: "Add a community" }).click();
await page.getByTestId("add-community-join").click();
await expect(communityInput).toHaveValue("");
const acknowledgements = await page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
(entry) => entry.command === "acknowledge_pending_community_deep_link",
),
);
expect(acknowledgements).toEqual([
{
command: "acknowledge_pending_community_deep_link",
payload: { id: PENDING_ADD_COMMUNITY_LINK.id },
},
]);
});
test("queued add-community links open and acknowledge one at a time", async ({
page,
}) => {
await installMockBridge(
page,
{
pendingCommunityDeepLinks: [
PENDING_ADD_COMMUNITY_LINK,
SECOND_PENDING_ADD_COMMUNITY_LINK,
],
},
{ seedPreviewFeatures: true },
);
await page.goto("/");
const communityInput = page.getByLabel("Community URL or invite link");
await expect(communityInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.relayUrl);
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? [])
.filter(
(entry) =>
entry.command === "acknowledge_pending_community_deep_link",
)
.map((entry) => entry.payload),
),
)
.toEqual([{ id: PENDING_ADD_COMMUNITY_LINK.id }]);
await page.getByRole("button", { name: "Close" }).click();
await expect(communityInput).toHaveValue(
SECOND_PENDING_ADD_COMMUNITY_LINK.relayUrl,
);
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? [])
.filter(
(entry) =>
entry.command === "acknowledge_pending_community_deep_link",
)
.map((entry) => entry.payload),
),
)
.toEqual([
{ id: PENDING_ADD_COMMUNITY_LINK.id },
{ id: SECOND_PENDING_ADD_COMMUNITY_LINK.id },
]);
});
test("deleted public starter channels do not strand community onboarding", async ({
page,
}) => {
const starterError =
"starter channels created but metadata not yet available";
await seedActiveIdentity(page, TEST_IDENTITIES.tyler);
await page.addInitScript(
({ pubkey, relayUrl, storageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
storageKey,
JSON.stringify({
id: "txn-deleted-starters-1",
source: "deep-link-join",
stage: "team-intro",
relayUrl,
communityName: "hive",
communityId: "e2e-default-community",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{
pubkey: COMMUNITY_ONBOARDING_PUBKEY,
relayUrl: COMMUNITY_RELAY_URL,
storageKey: TRANSACTION_STORAGE_KEY,
},
);
await installMockBridge(
page,
{ ensureStarterChannelsErrors: [starterError] },
{ relayWsUrl: COMMUNITY_RELAY_URL, skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByRole("button", { name: "Take me to Buzz" }).click();
await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0);
await expect(page).toHaveURL(/#\/channels\/[^/]+$/);
await expect(page.getByTestId("chat-title")).toContainText("Welcome");
await expect(page.getByText(starterError)).toHaveCount(0);
expect(
await page.evaluate(
() =>
window.__BUZZ_E2E_COMMANDS__?.filter(
(command) => command === "ensure_starter_channels",
).length ?? 0,
),
).toBe(1);
});
test("required Welcome creation failure keeps community onboarding open", async ({
page,
}) => {
const welcomeError = "Channel creation is not permitted.";
await seedActiveIdentity(page, TEST_IDENTITIES.tyler);
await page.addInitScript(
({ pubkey, relayUrl, storageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
storageKey,
JSON.stringify({
id: "txn-welcome-failure-1",
source: "deep-link-join",
stage: "team-intro",
relayUrl,
communityName: "hive",
communityId: "e2e-default-community",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{
pubkey: COMMUNITY_ONBOARDING_PUBKEY,
relayUrl: COMMUNITY_RELAY_URL,
storageKey: TRANSACTION_STORAGE_KEY,
},
);
await installMockBridge(
page,
{ createChannelErrors: [welcomeError] },
{ relayWsUrl: COMMUNITY_RELAY_URL, skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByRole("button", { name: "Take me to Buzz" }).click();
await expect(page.getByTestId("community-onboarding-flow")).toBeVisible();
await expect(page.getByText(`${welcomeError} Try again.`)).toBeVisible();
await expect(
page.getByRole("button", { name: "Take me to Buzz" }),
).toBeEnabled();
await expect(page.getByTestId("chat-title")).toHaveCount(0);
});
test("persisted deep-link invite hands off to Joining after machine onboarding", async ({
page,
}) => {
// Deterministic claim failure (no real relay behind the mock bridge): the
// spec asserts the handoff reaches the "Joining …" claiming screen, not
// that the claim itself succeeds.
await page.route("**/api/invites/claim", (route) => route.abort());
await page.addInitScript(
({ pubkey, storageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
storageKey,
JSON.stringify({
id: "txn-deep-link-1",
source: "deep-link-join",
stage: "claiming",
relayUrl: "wss://hive.example.com",
inviteCode: "abc.def",
communityName: "hive",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{ pubkey: DEFAULT_MOCK_PUBKEY, storageKey: TRANSACTION_STORAGE_KEY },
);
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
// Machine onboarding is complete, so the transaction owns the screen.
await expect(page.getByTestId("community-onboarding-flow")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Joining hive" }),
).toBeVisible();
await expect(page.getByTestId("pending-invite-gate")).toHaveCount(0);
// The claim was attempted and its failure surfaced with a Retry.
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
});
@@ -0,0 +1,117 @@
import { expect, test } from "@playwright/test";
import { finalizeEvent } from "nostr-tools/pure";
import { hexToBytes } from "@noble/hashes/utils.js";
import { installRelayBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { assertRelaySeeded } from "../helpers/seed";
const isCi = Boolean(process.env.CI);
const relaySeedHookTimeoutMs = isCi ? 90_000 : 30_000;
const RELAY_HTTP_URL =
process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000";
// setup-desktop-test-data.sh: uuid5(NAMESPACE_DNS, "buzz.channel.dm.alice-tyler")
const ALICE_TYLER_DM_CHANNEL_ID = "5a9c064e-0411-5242-ae6b-0363ba99b8e6";
async function getLoggedNotifications(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const win = window as Window & {
__BUZZ_E2E_NOTIFICATIONS__?: Array<{
body: string | null;
title: string;
}>;
};
return win.__BUZZ_E2E_NOTIFICATIONS__ ?? [];
});
}
/**
* Publishes a REAL signed DM message from alice through the relay ingest
* path. Like every DM send in the product, it carries a recipient `p` tag
* (see messageMentionPubkeys) which is exactly what makes the event match
* both the live DM subscription and the home-feed mention query.
*/
async function publishAliceDm(content: string) {
const event = finalizeEvent(
{
kind: 9,
content,
tags: [
["h", ALICE_TYLER_DM_CHANNEL_ID],
["p", TEST_IDENTITIES.tyler.pubkey],
],
created_at: Math.floor(Date.now() / 1000),
},
hexToBytes(TEST_IDENTITIES.alice.privateKey),
);
const response = await fetch(`${RELAY_HTTP_URL}/events`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Pubkey": event.pubkey,
},
body: JSON.stringify(event),
});
if (!response.ok) {
throw new Error(
`POST /events failed (${response.status}): ${await response.text()}`,
);
}
}
test.beforeAll(async () => {
test.setTimeout(relaySeedHookTimeoutMs);
await assertRelaySeeded();
});
test("an incoming DM produces exactly one desktop notification", async ({
page,
}) => {
await installRelayBridge(page, "tyler");
// Deterministically wait for the home feed's initial mention query
// (kinds 9/... + #p filter) to complete before publishing: the feed
// dedupe-seen set must be initialized, otherwise the duplicate is
// accidentally swallowed as "initial backlog" and the repro goes flaky.
const feedInitialized = page.waitForResponse(
(response) =>
response.url().includes("/query") &&
(response.request().postData() ?? "").includes('"#p"'),
{ timeout: 15_000 },
);
await page.goto("/");
// Wait until the DM channel is loaded — live subscriptions (channel + home
// feed mention) are established once the channel list resolves.
await expect(page.getByTestId("dm-list")).toContainText("alice-tyler");
await feedInitialized;
// Small buffer for the feed effect (seen-set initialization) to run.
await page.waitForTimeout(1_000);
const message = `dm dedupe probe ${Date.now()}`;
await publishAliceDm(message);
// Wait for the DM toast to arrive.
await expect
.poll(async () => (await getLoggedNotifications(page)).length, {
timeout: 15_000,
})
.toBeGreaterThan(0);
// Give the duplicate (home-feed mention path) time to fire — it arrives via
// the onLiveMention → feed refetch round trip, which lags the WS toast.
await page.waitForTimeout(5_000);
const notifications = await getLoggedNotifications(page);
expect(
notifications,
`expected exactly one notification for a single DM, got: ${JSON.stringify(notifications)}`,
).toHaveLength(1);
// The survivor must be the live WebSocket DM toast (titled with the DM
// channel name), not the home-feed mention duplicate ("… mentioned you in …").
expect(notifications[0].title).toBe("alice-tyler");
});
@@ -0,0 +1,73 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import {
installMockBridge,
openNewMessagePage,
TEST_IDENTITIES,
} from "../helpers/bridge";
const SHOTS = "test-results/dm-new-message";
test("captures the new-message loading skeleton", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [],
relayAgents: [],
userSearchDelayMs: 10_000,
});
await page.goto("/");
await openNewMessagePage(page);
const search = page.getByTestId("new-dm-search");
await search.fill("Alex");
await expect(search).toHaveValue("Alex");
await expect(page.getByTestId("new-dm-loading")).toBeVisible();
await expect(
page.locator("[data-testid^='new-dm-result-']:visible"),
).toHaveCount(0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/01-search-loading.png` });
});
test("captures selected recipients with the picker open", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await openNewMessagePage(page);
for (const identity of [TEST_IDENTITIES.charlie, TEST_IDENTITIES.bob]) {
await page.getByTestId("new-dm-search").fill(identity.username);
await page.getByTestId(`new-dm-result-${identity.pubkey}`).click();
}
await expect(
page.locator("button[data-testid^='new-dm-selected-']"),
).toHaveCount(2);
const search = page.getByTestId("new-dm-search");
await search.click();
await expect(page.getByTestId("new-message-recipient-popover")).toBeVisible();
await waitForAnimations(page);
await expect(page.getByTestId("new-message-recipient-popover")).toBeVisible();
await page.screenshot({ path: `${SHOTS}/02-selected-recipients.png` });
});
test("captures the enabled first-message composer", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await openNewMessagePage(page);
await page.getByTestId("new-dm-search").fill("charlie");
await page
.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
await page.getByTestId("new-dm-search").press("Escape");
await expect(page.getByTestId("new-message-recipient-popover")).toBeHidden();
await page
.getByTestId("message-input")
.fill("Hey Charlie — want to start a new thread?");
await expect(page.getByTestId("send-message")).toBeEnabled();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/03-first-message-compose.png` });
});
@@ -0,0 +1,300 @@
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/doctor-cta";
// Channel ID for the seeded #general mock channel.
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
// Tyler is a managed agent seed in the mock bridge — used as the nudge sender.
const AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
const AGENT_NAME = "Tyler Agent";
/** Settle CSS / Web Animations before capture. */
async function settleAnimations(page: import("@playwright/test").Page) {
await page.evaluate(() =>
Promise.all(document.getAnimations().map((a) => a.finished)),
);
}
/**
* Wait for the Tauri invoke mock to be available, then call a mock command.
* Mirrors the helper used in config-bridge-screenshots.spec.ts.
*/
async function invokeMockCommand(
page: import("@playwright/test").Page,
command: string,
payload?: Record<string, unknown>,
): Promise<unknown> {
await page.waitForFunction(
() => {
const w = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
__TAURI_INTERNALS__?: { invoke?: unknown };
};
return (
typeof w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" ||
typeof w.__TAURI_INTERNALS__?.invoke === "function"
);
},
null,
{ timeout: 5_000 },
);
return page.evaluate(
async ({ command: cmd, payload: pl }) => {
const w = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
__TAURI_INTERNALS__?: {
invoke?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
};
const invoke =
w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ?? w.__TAURI_INTERNALS__?.invoke;
if (!invoke) throw new Error("Mock invoke bridge is unavailable.");
return invoke(cmd, pl);
},
{ command, payload },
);
}
/**
* Build a `buzz:config-nudge` sentinel body from a requirements array.
* Mirrors the format nudge_body() in setup_mode.rs produces.
*/
function makeNudgeSentinel(
agentName: string,
agentPubkey: string,
requirements: unknown[],
): string {
const payload = JSON.stringify({
agent_name: agentName,
agent_pubkey: agentPubkey,
requirements,
});
return `**${agentName}** needs configuration before it can respond.\n\n\`\`\`buzz:config-nudge\n${payload}\n\`\`\``;
}
/**
* Inject a nudge message from the agent into #general, then navigate to the
* channel so the card renders in the message list.
*/
async function injectNudgeAndNavigate(
page: import("@playwright/test").Page,
content: string,
): Promise<void> {
await invokeMockCommand(page, "send_managed_agent_channel_message", {
agentPubkey: AGENT_PUBKEY,
channelId: GENERAL_CHANNEL_ID,
content,
});
// Navigate to #general — the injected message is live-pushed so the card
// renders in the current message list.
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
}
test.describe("doctor CTA nudge card screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
page.on("console", (msg) => {
if (msg.type() === "error") {
console.error("CONSOLE ERROR:", msg.text().slice(0, 500));
}
});
});
/**
* 01 pure cli_login card (all requirements are cli_login, availability=available):
* Tooling is installed but needs login Doctor has no auth functionality
* and would be a misleading dead-end. The card is purely informational:
* no trigger, no CTA, no pointer/hover affordance. The inline copy
* ("run `claude auth login` to authenticate") already tells the user
* the exact command to run.
*/
test("01-cli-login-available-informational", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "running" as const,
channelNames: ["general"],
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const content = makeNudgeSentinel(AGENT_NAME, AGENT_PUBKEY, [
{
surface: "cli_login",
probe_args: ["claude"],
setup_copy: "run `claude auth login` to authenticate",
availability: "available",
},
]);
await injectNudgeAndNavigate(page, content);
// Wait for the nudge card to render.
const card = page.locator("[data-config-nudge]").last();
await expect(card).toBeVisible({ timeout: 10_000 });
// Auth-only card is informational — no runtime settings CTA anywhere.
await expect(card.getByText("Open Agent runtimes →")).toHaveCount(0);
await card.scrollIntoViewIfNeeded();
await settleAnimations(page);
await card.screenshot({
path: `${SHOTS}/01-cli-login-available-informational.png`,
});
});
/**
* 02 not_installed state: neither adapter nor CLI found.
* Card shows "claude isn't installed" copy + an Agent runtimes CTA.
*/
test("02-cli-login-not-installed-state", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped" as const,
channelNames: ["general"],
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const content = makeNudgeSentinel(AGENT_NAME, AGENT_PUBKEY, [
{
surface: "cli_login",
probe_args: ["claude"],
setup_copy: "install Claude Code",
availability: "not_installed",
},
]);
await injectNudgeAndNavigate(page, content);
const card = page.locator("[data-config-nudge]").last();
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(card.getByText(/isn't installed/)).toBeVisible();
await card.scrollIntoViewIfNeeded();
await settleAnimations(page);
await card.screenshot({
path: `${SHOTS}/02-cli-login-not-installed-state.png`,
});
});
/**
* 03 mixed card: one cli_login (adapter_missing) + one env_key requirement.
* Each requirement row owns its CTA, right-aligned to a shared edge:
* the cli_login row opens Agent runtimes and the env_key row shows
* "Edit Agent →", both at the same x (vertically aligned).
*/
test("03-mixed-requirements-inline-doctor-cta", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped" as const,
channelNames: ["general"],
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const content = makeNudgeSentinel(AGENT_NAME, AGENT_PUBKEY, [
{
surface: "cli_login",
probe_args: ["claude"],
setup_copy: "install the Claude Code ACP adapter",
availability: "adapter_missing",
},
{
surface: "env_key",
key: "ANTHROPIC_API_KEY",
},
]);
await injectNudgeAndNavigate(page, content);
const card = page.locator("[data-config-nudge]").last();
await expect(card).toBeVisible({ timeout: 10_000 });
// Mixed card: cli_login opens Agent runtimes; env_key opens Edit Agent.
await expect(card.getByText("Open Agent runtimes →")).toBeVisible();
// Both per-row CTAs share the same right edge (vertically aligned).
await expect(card.getByText("Edit Agent →", { exact: true })).toBeVisible();
await card.scrollIntoViewIfNeeded();
await settleAnimations(page);
await card.screenshot({
path: `${SHOTS}/03-mixed-requirements-inline-doctor-cta.png`,
});
});
/**
* 04 cli_missing state: ACP adapter present but underlying CLI absent.
* Shows "claude CLI is missing" copy.
*/
test("04-cli-login-cli-missing-state", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped" as const,
channelNames: ["general"],
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const content = makeNudgeSentinel(AGENT_NAME, AGENT_PUBKEY, [
{
surface: "cli_login",
probe_args: ["claude"],
setup_copy: "install the Claude CLI",
availability: "cli_missing",
},
]);
await injectNudgeAndNavigate(page, content);
const card = page.locator("[data-config-nudge]").last();
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(card.getByText(/CLI is missing/)).toBeVisible();
await card.scrollIntoViewIfNeeded();
await settleAnimations(page);
await card.screenshot({
path: `${SHOTS}/04-cli-login-cli-missing-state.png`,
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/drafts-all-fix";
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8);
type MockFeedWindow = Window & {
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: Record<string, unknown>) => void;
};
test.use({ viewport: { width: 1280, height: 720 } });
test("Inbox All hides drafts while the Drafts filter keeps them", async ({
page,
}) => {
const draftKey = `channel:${GENERAL_CHANNEL_ID}`;
await page.addInitScript(
({ draftStoreKey, draftStorageKey, channelId }) => {
const timestamp = new Date().toISOString();
window.localStorage.setItem(
draftStoreKey,
JSON.stringify({
[draftStorageKey]: {
channelId,
content: "Draft that must stay out of the All view",
createdAt: timestamp,
pendingImeta: [],
selectionEnd: 41,
selectionStart: 41,
spoileredAttachmentUrls: [],
status: "active",
updatedAt: timestamp,
},
}),
);
},
{
channelId: GENERAL_CHANNEL_ID,
draftStorageKey: draftKey,
draftStoreKey: `buzz-drafts.v2:ws://localhost:3000:${MOCK_IDENTITY_PUBKEY}`,
},
);
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(() => {
const win = window as MockFeedWindow;
return typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function";
});
const messageId = "drafts-all-fix-message";
await page.evaluate(
({ channelId, currentPubkey, messageId: id, senderPubkey }) => {
const pushFeedItem = (window as MockFeedWindow)
.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!pushFeedItem) throw new Error("Mock feed helper is not installed.");
pushFeedItem({
category: "mention",
channel_id: channelId,
channel_name: "general",
channel_type: "stream",
content: "Regular message that belongs in All",
created_at: Math.floor(Date.now() / 1_000),
id,
kind: 9,
pubkey: senderPubkey,
tags: [
["h", channelId],
["p", currentPubkey],
],
});
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: MOCK_IDENTITY_PUBKEY,
messageId,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
// All view: the message is present, the draft row is not.
await expect(page.getByTestId(`home-inbox-item-${messageId}`)).toBeVisible();
await expect(page.getByTestId(`home-all-drafts-${draftKey}`)).toHaveCount(0);
await waitForAnimations(page);
await page
.getByTestId("home-inbox")
.screenshot({ path: `${SHOTS}/01-all-view-no-drafts.png` });
// Drafts filter: the draft is still listed.
await page.getByTestId("inbox-filter-trigger").click();
await page.getByRole("menuitemradio", { name: "Drafts" }).click();
await page.keyboard.press("Escape");
const panel = page.getByTestId("home-inbox-drafts");
await expect(panel).toBeVisible();
await expect(
panel.getByText("Draft that must stay out of the All view"),
).toBeVisible();
await waitForAnimations(page);
await page
.getByTestId("home-inbox")
.screenshot({ path: `${SHOTS}/02-drafts-filter-still-lists.png` });
});
@@ -0,0 +1,443 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/drafts";
// Mock bridge default pubkey — must match DEFAULT_MOCK_PUBKEY in bridge.ts
const MOCK_PUBKEY = "deadbeef".repeat(8);
const DRAFT_STORE_KEY = `buzz-drafts.v1:${MOCK_PUBKEY}`;
// Channel IDs from the mock bridge seed data
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301";
// Fixed timestamps for deterministic rendering
const CREATED_AT_1 = "2026-07-01T10:00:00.000Z";
const CREATED_AT_2 = "2026-07-02T14:30:00.000Z";
type StoredDraftState = {
content: string;
selectionStart: number;
selectionEnd: number;
channelId: string;
createdAt: string;
updatedAt: string;
pendingImeta: unknown[];
spoileredAttachmentUrls: string[];
status: "active" | "sent";
};
type StoredDrafts = Record<string, StoredDraftState>;
/** Active drafts: text draft in #general, image-only draft in #agents, long text draft. */
const ACTIVE_DRAFTS: StoredDrafts = {
[`channel:${GENERAL_CHANNEL_ID}`]: {
content:
"Hey team — I've been working on the new onboarding flow. Check out the latest mockups when you get a chance!",
selectionStart: 107,
selectionEnd: 107,
channelId: GENERAL_CHANNEL_ID,
createdAt: CREATED_AT_1,
updatedAt: CREATED_AT_1,
pendingImeta: [],
spoileredAttachmentUrls: [],
status: "active",
},
[`channel:${AGENTS_CHANNEL_ID}`]: {
// Image-only draft — exercises the "1 attachment" fallback in getDraftPreview
content: "",
selectionStart: 0,
selectionEnd: 0,
channelId: AGENTS_CHANNEL_ID,
createdAt: CREATED_AT_2,
updatedAt: CREATED_AT_2,
pendingImeta: [
{
url: "https://example.com/screenshot.png",
sha256: "abc123",
size: 204800,
type: "image/png",
dim: "1280x900",
},
],
spoileredAttachmentUrls: [],
status: "active",
},
};
/**
* Patch the mock community to include the pubkey so initDraftStore gets the
* correct pubkey on app startup. The community is seeded by installMockBridge
* without a pubkey field; this addInitScript runs after that seed (init
* scripts execute in registration order) and adds it.
*/
async function patchCommunityPubkey(page: import("@playwright/test").Page) {
await page.addInitScript(
({ pubkey }) => {
const raw = window.localStorage.getItem("buzz-communities");
const communities = raw
? (JSON.parse(raw) as Array<Record<string, unknown>>)
: [];
if (communities[0]) {
communities[0].pubkey = pubkey;
window.localStorage.setItem(
"buzz-communities",
JSON.stringify(communities),
);
}
},
{ pubkey: MOCK_PUBKEY },
);
}
/** Seed draft localStorage before page load via addInitScript. */
async function seedDraftStore(
page: import("@playwright/test").Page,
drafts: StoredDrafts,
) {
await page.addInitScript(
({ storeKey, value }) => {
window.localStorage.setItem(storeKey, JSON.stringify(value));
},
{ storeKey: DRAFT_STORE_KEY, value: drafts },
);
}
/** Navigate to `/`, wait for inbox, then select the Drafts filter. */
async function openDraftsPanel(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("home-inbox")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("inbox-filter-trigger").click();
await page.getByRole("menuitemradio", { name: "Drafts" }).click();
// Dismiss the dropdown so it doesn't obscure the panel assertions.
await page.keyboard.press("Escape");
const panel = page.getByTestId("home-inbox-drafts");
await expect(panel).toBeVisible({ timeout: 8_000 });
return panel;
}
test.describe("drafts screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
page.on("console", (msg) => {
if (msg.type() === "error") {
console.error("CONSOLE ERROR:", msg.text().slice(0, 500));
}
});
});
test("01 — drafts section populated", async ({ page }) => {
await installMockBridge(page);
await patchCommunityPubkey(page);
await seedDraftStore(page, ACTIVE_DRAFTS);
const panel = await openDraftsPanel(page);
// Both active draft rows should be visible
const draftRows = panel.locator("[data-testid^='home-draft-item-']");
await expect(draftRows).toHaveCount(2, { timeout: 6_000 });
// The text draft row shows content
await expect(
panel.getByText(
"Hey team — I've been working on the new onboarding flow.",
),
).toBeVisible({ timeout: 5_000 });
// The image-only draft shows the attachment fallback
await expect(panel.getByText("1 attachment")).toBeVisible({
timeout: 5_000,
});
// Section heading should be "DRAFTS"
await expect(panel.getByText("Drafts", { exact: true })).toBeVisible();
const detail = page.getByTestId("home-inbox-draft-detail");
await expect(detail).toBeVisible();
await expect(detail.getByText("1 attachment")).toBeVisible();
await expect(detail.getByText("You", { exact: true })).toBeVisible();
await expect(detail.getByText("Draft", { exact: true })).toBeVisible();
await detail.locator("article").hover();
await expect(
detail.getByTestId("home-inbox-draft-action-bar"),
).toBeVisible();
await waitForAnimations(page);
await page.getByTestId("home-inbox").screenshot({
path: `${SHOTS}/01-drafts-section-populated.png`,
});
});
test("02 — narrow draft list opens the detail view", async ({ page }) => {
await page.setViewportSize({ width: 520, height: 900 });
await installMockBridge(page);
await patchCommunityPubkey(page);
await seedDraftStore(page, ACTIVE_DRAFTS);
const panel = await openDraftsPanel(page);
const draftRow = panel.locator(
`[data-testid='home-draft-item-channel:${GENERAL_CHANNEL_ID}']`,
);
await draftRow.getByRole("button", { name: /View draft/ }).click();
const detail = page.getByTestId("home-inbox-draft-detail");
await expect(detail).toBeVisible();
await expect(panel).not.toBeVisible();
await detail.getByRole("button", { name: "Back to drafts list" }).click();
await expect(panel).toBeVisible();
await expect(detail).not.toBeVisible();
});
test("03 — hover actions visible", async ({ page }) => {
await installMockBridge(page);
await patchCommunityPubkey(page);
await seedDraftStore(page, ACTIVE_DRAFTS);
const panel = await openDraftsPanel(page);
// Wait for the text draft row
const textDraftRow = panel.locator(
`[data-testid='home-draft-item-channel:${GENERAL_CHANNEL_ID}']`,
);
await expect(textDraftRow).toBeVisible({ timeout: 6_000 });
// Hover to reveal action buttons
await textDraftRow.hover();
// All three action buttons should become visible on hover
const openDraftBtn = textDraftRow.getByRole("button", {
name: "Open draft",
exact: true,
});
const sendMessageBtn = textDraftRow.getByRole("button", {
name: "Send message",
exact: true,
});
const deleteDraftBtn = textDraftRow.getByRole("button", {
name: "Delete draft",
});
await expect(openDraftBtn).toBeVisible({ timeout: 4_000 });
await expect(sendMessageBtn).toBeVisible({ timeout: 4_000 });
await expect(deleteDraftBtn).toBeVisible({ timeout: 4_000 });
await waitForAnimations(page);
await panel.screenshot({ path: `${SHOTS}/03-hover-actions.png` });
});
test("04 — empty state", async ({ page }) => {
await installMockBridge(page);
// No draft seed → empty state
const panel = await openDraftsPanel(page);
// Empty state: FileText icon + "No drafts" text
await expect(panel.getByText("No drafts")).toBeVisible({ timeout: 5_000 });
await waitForAnimations(page);
await panel.screenshot({ path: `${SHOTS}/04-empty-state.png` });
});
test("05 — thread-draft send confirm dialog", async ({ page }) => {
// Regression test for IMPORTANT #1: thread-reply draft Send navigates to
// the correct channel and passes the autoSend key so the thread composer
// (not the main composer) arms the auto-submit.
await installMockBridge(page);
await patchCommunityPubkey(page);
// A fixed fake root event ID — in the mock bridge get_event is unhandled
// so useDraftRootStatus will map it to `error` (not `deleted`), keeping
// the draft sendable.
const THREAD_ROOT_ID =
"aaaa1111bbbb2222cccc3333dddd4444aaaa1111bbbb2222cccc3333dddd4444";
const THREAD_DRAFT_KEY = `thread:${THREAD_ROOT_ID}`;
await page.addInitScript(
({ storeKey, value }) => {
window.localStorage.setItem(storeKey, JSON.stringify(value));
},
{
storeKey: DRAFT_STORE_KEY,
value: {
[THREAD_DRAFT_KEY]: {
content: "Thread reply draft content",
selectionStart: 26,
selectionEnd: 26,
channelId: GENERAL_CHANNEL_ID,
createdAt: CREATED_AT_1,
updatedAt: CREATED_AT_1,
pendingImeta: [],
spoileredAttachmentUrls: [],
status: "active",
},
} satisfies StoredDrafts,
},
);
const panel = await openDraftsPanel(page);
// The thread draft row should appear.
const draftRow = panel.locator(
`[data-testid='home-draft-item-${THREAD_DRAFT_KEY}']`,
);
await expect(draftRow).toBeVisible({ timeout: 8_000 });
// "Thread deleted" label must NOT appear — root status is `error` (optimistic).
await expect(panel.getByText("Thread deleted")).not.toBeVisible();
// Hover to reveal the three action buttons.
await draftRow.hover();
const sendBtn = draftRow.getByRole("button", {
name: "Send message",
exact: true,
});
await expect(sendBtn).toBeVisible({ timeout: 4_000 });
// Click "Send message" — confirm dialog should appear.
await sendBtn.click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 4_000 });
await expect(dialog.getByText("Send message")).toBeVisible();
// Confirm dialog body names the channel.
await expect(dialog.getByText(/general/i)).toBeVisible();
await waitForAnimations(page);
await panel.screenshot({
path: `${SHOTS}/05-thread-draft-send-dialog.png`,
});
// Click Send — dialog closes and we navigate to the channel with ?autoSend.
await dialog.getByRole("button", { name: "Send", exact: true }).click();
await expect(dialog).not.toBeVisible({ timeout: 4_000 });
// URL must include all three params set by DraftsPanel.handleConfirmSend:
// ?messageId=<rootId> — scroll-targets the root message in the timeline
// ?threadRootId=<rootId> — opens the thread panel for this root
// ?autoSend=thread:<rootId> — arms the thread composer's once-only guard
//
// Integration boundary: the mock bridge does not support get_event or
// message send, so ChannelRouteScreen.fetchRouteTargetEvents fails silently,
// threadHeadMessage stays null, and the MessageThreadPanel (and its composer)
// never mount. The auto-submit effect and the actual send are NOT assertable
// in this E2E shard — they are covered by MessageComposerAutoSend.test.mjs
// (key-match guard) and MessageComposerDraftImagePersist.test.mjs (full
// composer mount path).
await expect(page).toHaveURL(new RegExp(`messageId=${THREAD_ROOT_ID}`), {
timeout: 6_000,
});
await expect(page).toHaveURL(new RegExp(`threadRootId=${THREAD_ROOT_ID}`));
await expect(page).toHaveURL(
new RegExp(`autoSend=${encodeURIComponent(THREAD_DRAFT_KEY)}`),
);
});
test("06 — active-draft count appears only on the Drafts filter", async ({
page,
}) => {
// Draft counts stay beside the Drafts option instead of decorating the
// overall Inbox filter trigger. Two drafts make the count explicit.
await installMockBridge(page);
await patchCommunityPubkey(page);
await seedDraftStore(page, ACTIVE_DRAFTS);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("home-inbox")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId("inbox-draft-badge")).toHaveCount(0);
await expect(page.getByTestId("inbox-filter-trigger")).toHaveAttribute(
"aria-label",
"Filter inbox: All. 2 active drafts",
);
// Open the filter dropdown so the badge-option is visible too.
await page.getByTestId("inbox-filter-trigger").click();
const dropdownBadge = page.getByTestId("inbox-draft-badge-option");
await expect(dropdownBadge).toBeVisible({ timeout: 4_000 });
await expect(dropdownBadge).toHaveText("2");
await waitForAnimations(page);
// Capture the Inbox header and the dropdown-only count.
await page.getByTestId("home-inbox").screenshot({
path: `${SHOTS}/06-draft-badge.png`,
});
// Dismiss the dropdown cleanly.
await page.keyboard.press("Escape");
});
test("07 — thread-deleted state (orphaned thread-reply draft)", async ({
page,
}) => {
// A thread-reply draft whose root event is definitively deleted:
// `useDraftRootStatus` maps it to `deleted`, showing the "Thread deleted"
// label, greying the row, and disabling Open/Send.
const DELETED_ROOT_ID =
"dead0000dead0000dead0000dead0000dead0000dead0000dead0000dead0000";
const THREAD_DRAFT_KEY = `thread:${DELETED_ROOT_ID}`;
await installMockBridge(page, { deletedEventIds: [DELETED_ROOT_ID] });
await patchCommunityPubkey(page);
await seedDraftStore(page, {
[THREAD_DRAFT_KEY]: {
content: "Planning to follow up on the discussion from last week.",
selectionStart: 52,
selectionEnd: 52,
channelId: GENERAL_CHANNEL_ID,
createdAt: CREATED_AT_1,
updatedAt: CREATED_AT_1,
pendingImeta: [],
spoileredAttachmentUrls: [],
status: "active",
},
});
const panel = await openDraftsPanel(page);
// The orphaned draft row should render.
const draftRow = panel.locator(
`[data-testid='home-draft-item-${THREAD_DRAFT_KEY}']`,
);
await expect(draftRow).toBeVisible({ timeout: 8_000 });
// "Thread deleted" badge must appear once the root-status query resolves.
const orphanLabel = panel.getByTestId(
`home-draft-orphaned-label-${THREAD_DRAFT_KEY}`,
);
await expect(orphanLabel).toBeVisible({ timeout: 8_000 });
// Hover the row to confirm Open and Send are disabled.
await draftRow.hover();
// Both the open and send buttons are labelled "Thread deleted" when orphaned.
const disabledBtns = draftRow.getByRole("button", {
name: "Thread deleted",
});
await expect(disabledBtns).toHaveCount(2, { timeout: 4_000 });
// Delete is still enabled.
await expect(
draftRow.getByRole("button", { name: "Delete draft" }),
).toBeVisible({ timeout: 4_000 });
await waitForAnimations(page);
await panel.screenshot({
path: `${SHOTS}/07-thread-deleted-state.png`,
});
});
});
+212
View File
@@ -0,0 +1,212 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/edit-agent-run-on";
/**
* The exact persisted shape of a real kubernetes agent created through the
* app ("Loni" in managed-agents.json, traced by Sami in review): eight keys,
* `inactivity_seconds` a JSON number, everything else strings, and the
* optional `service_account` ABSENT no schema default means the create
* flow never seeds it, so honest fixtures omit it too.
*/
const KUBERNETES_CONFIG = {
context: "docker-desktop",
cpu_limit: "1",
cpu_request: "1",
image:
"ghcr.io/block/buzz-sprig:sha-6530b58@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76",
inactivity_seconds: 7200,
memory_limit: "1Gi",
memory_request: "1Gi",
namespace: "buzz-agents-gfq7aq",
};
async function openEditDialog(
page: import("@playwright/test").Page,
agentName: string,
) {
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page
.getByRole("button", { name: `${agentName} agent profile` })
.click();
await page.getByTestId("user-profile-edit-agent").click();
await expect(page.getByTestId("edit-agent-dialog")).toBeVisible();
}
test("editing a kubernetes agent shows its saved run-on settings", async ({
page,
}) => {
const agent = TEST_IDENTITIES.charlie;
await installMockBridge(page, {
managedAgents: [
{
pubkey: agent.pubkey,
name: "Remote Helper",
status: "running",
channelNames: ["general"],
respondTo: "owner-only",
backend: {
type: "provider",
id: "kubernetes",
config: KUBERNETES_CONFIG,
},
},
],
});
await openEditDialog(page, "Remote Helper");
const runOn = page.getByTestId("edit-agent-run-on");
await expect(runOn).toBeVisible();
await expect(runOn.getByTestId("edit-agent-run-on-location")).toHaveText(
"kubernetes",
);
// Every saved field renders as a labeled row with its stored value.
await expect(runOn.getByTestId("edit-agent-run-on-namespace")).toContainText(
"buzz-agents-gfq7aq",
);
await expect(runOn.getByTestId("edit-agent-run-on-context")).toContainText(
"docker-desktop",
);
await expect(runOn.getByTestId("edit-agent-run-on-image")).toContainText(
"ghcr.io/block/buzz-sprig",
);
await expect(
runOn.getByTestId("edit-agent-run-on-cpu_request"),
).toContainText("CPU request");
await expect(
runOn.getByTestId("edit-agent-run-on-inactivity_seconds"),
).toContainText("7200");
// Real records omit the optional service_account (no schema default): the
// section must show only what was saved, never synthesize a row for it.
await expect(
runOn.getByTestId("edit-agent-run-on-service_account"),
).toHaveCount(0);
// The section explains immutability instead of pretending to be a form.
await expect(runOn).toContainText("can't be changed afterwards");
await runOn.scrollIntoViewIfNeeded();
await waitForAnimations(page);
await page
.getByTestId("edit-agent-dialog")
.screenshot({ path: `${SHOTS}/kubernetes-run-on.png` });
});
test("editing a local agent names this computer, with no config rows", async ({
page,
}) => {
const agent = TEST_IDENTITIES.tyler;
await installMockBridge(page, {
managedAgents: [
{
pubkey: agent.pubkey,
name: "Local Helper",
status: "stopped",
channelNames: ["general"],
respondTo: "owner-only",
backend: { type: "local" },
},
],
});
await openEditDialog(page, "Local Helper");
const runOn = page.getByTestId("edit-agent-run-on");
await expect(runOn.getByTestId("edit-agent-run-on-location")).toHaveText(
"This computer",
);
await expect(runOn.getByTestId("edit-agent-run-on-namespace")).toHaveCount(0);
await runOn.scrollIntoViewIfNeeded();
await waitForAnimations(page);
await page
.getByTestId("edit-agent-dialog")
.screenshot({ path: `${SHOTS}/local-run-on.png` });
});
test("a blox agent's single saved field renders with a humanized label", async ({
page,
}) => {
// The other real provider shape on developer machines: blox records
// persist exactly one key, exercising provider-generic humanization.
const agent = TEST_IDENTITIES.bob;
await installMockBridge(page, {
managedAgents: [
{
pubkey: agent.pubkey,
name: "Blox Helper",
status: "running",
channelNames: ["general"],
respondTo: "owner-only",
backend: {
type: "provider",
id: "blox",
config: { workstation_name: "tlongwell-sprout-home" },
},
},
],
});
await openEditDialog(page, "Blox Helper");
const runOn = page.getByTestId("edit-agent-run-on");
await expect(runOn.getByTestId("edit-agent-run-on-location")).toHaveText(
"blox",
);
const row = runOn.getByTestId("edit-agent-run-on-workstation_name");
await expect(row).toContainText("Workstation name");
await expect(row).toContainText("tlongwell-sprout-home");
await runOn.scrollIntoViewIfNeeded();
await waitForAnimations(page);
await page
.getByTestId("edit-agent-dialog")
.screenshot({ path: `${SHOTS}/blox-run-on.png` });
});
test("secret-shaped keys from an untrusted provider render redacted", async ({
page,
}) => {
const agent = TEST_IDENTITIES.charlie;
await installMockBridge(page, {
managedAgents: [
{
pubkey: agent.pubkey,
name: "Future Provider Agent",
status: "running",
channelNames: ["general"],
respondTo: "owner-only",
backend: {
type: "provider",
id: "some-future-provider",
config: {
endpoint: "https://provider.example",
api_token: "tok-do-not-show",
},
},
},
],
});
await openEditDialog(page, "Future Provider Agent");
const runOn = page.getByTestId("edit-agent-run-on");
await expect(runOn.getByTestId("edit-agent-run-on-endpoint")).toContainText(
"https://provider.example",
);
const tokenRow = runOn.getByTestId("edit-agent-run-on-api_token");
await expect(tokenRow).toContainText("••••••••");
await expect(tokenRow).not.toContainText("tok-do-not-show");
// The raw secret must not appear anywhere in the dialog.
await expect(page.getByTestId("edit-agent-dialog")).not.toContainText(
"tok-do-not-show",
);
await runOn.scrollIntoViewIfNeeded();
await waitForAnimations(page);
await page
.getByTestId("edit-agent-dialog")
.screenshot({ path: `${SHOTS}/redacted-run-on.png` });
});
+409
View File
@@ -0,0 +1,409 @@
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const BAKED_DEFAULTS = [
{ key: "BUZZ_AGENT_PROVIDER", value: "anthropic", masked: false },
{
key: "BUZZ_AGENT_MODEL",
value: "claude-opus-4-8",
masked: false,
},
{ key: "BUZZ_AGENT_THINKING_EFFORT", value: "high", masked: false },
{ key: "ANTHROPIC_API_KEY", value: "sk-ant-baked-test", masked: true },
];
// Edit-agent dialog coverage (Phase 1B.3b-pre). Written against TODAY'S
// EditAgentDialog, before the B3b re-host, so the re-host is guarded by a
// pre-existing spec rather than one written alongside it.
//
// Mock-boundary caveat: the e2eBridge `update_managed_agent` handler echoes
// name/model/systemPrompt/envVars/respondTo/respondToAllowlist into the
// mock store — it does NOT
// model the diff-based partial-update wire semantics (change-detected-or-omit,
// tri-state provider, harnessOverride derivation), and it ignores
// agentCommand/harnessOverride entirely. This spec therefore pins UI behavior
// (open → edit → save → persisted in UI), not wire semantics. The inherit
// toggle is not reachable here at all (see the routing pin below) — its
// behavior is covered by B3b's component-level pinning test (inherit-toggle
// → gate → submit); wire semantics stay component-test territory
// (personaRuntimeModel.test.mjs).
// Tyler's pubkey maps to gooseSurface in the mock bridge (runtimeId "goose"),
// which supports LLM provider selection — same seed the readiness-screenshot
// spec uses for its edit-dialog shot.
const AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
const AGENT_NAME = "Tyler Agent";
const PERSONA_ID = "persona-edit-e2e";
/**
* Open the Edit Agent dialog for the seeded managed agent via the profile
* panel (agents view agent card Edit quick action) EditAgentDialog's
* only mount path.
*/
async function openEditDialog(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("open-agents-view").click();
const agentButton = page.getByRole("button", {
name: `${AGENT_NAME} agent profile`,
});
await expect(agentButton).toBeVisible({ timeout: 10_000 });
await agentButton.click();
await expect(page.getByTestId("user-profile-panel")).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("user-profile-edit-agent").click();
await expect(page.getByTestId("edit-agent-dialog")).toBeVisible({
timeout: 10_000,
});
// Provider field visible = runtime catalog loaded and form settled.
await expect(page.locator("#edit-agent-llm-provider")).toBeVisible({
timeout: 10_000,
});
}
/**
* Pick an option from a PersonaDropdownField (menu-based, not a native
* <select> Create's fields are selects, Edit's are not).
*/
async function pickDropdownOption(
page: import("@playwright/test").Page,
triggerId: string,
optionName: string | RegExp,
) {
await page.locator(`#${triggerId}`).click();
await page.getByRole("menuitemradio", { name: optionName }).click();
}
test.describe("agent definition dialog", () => {
test("owner-only-access build shows disabled agent access with an explanation", async ({
page,
}) => {
await installMockBridge(page, {
ownerOnlyAccessBuild: true,
bakedBuildEnv: BAKED_DEFAULTS,
});
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Advanced", exact: true }).click();
await expect(dialog.getByTestId("agent-respond-to")).toBeVisible();
await expect(dialog.locator("#agent-respond-to")).toBeDisabled();
await expect(dialog.locator("#agent-respond-to")).toContainText(
"Only me (default)",
);
await expect(
dialog.getByTestId("agent-respond-to-disabled-reason"),
).toHaveText("This build disallows changing this setting.");
});
});
test.describe("edit agent dialog", () => {
test("owner-only-access build shows a disabled owner-only access control with an explanation", async ({
page,
}) => {
await installMockBridge(page, {
ownerOnlyAccessBuild: true,
bakedBuildEnv: BAKED_DEFAULTS,
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped",
channelNames: ["agents"],
respondTo: "anyone",
},
],
});
await openEditDialog(page);
const accessControl = page.getByTestId("agent-respond-to");
await expect(accessControl).toBeVisible();
await expect(page.locator("#agent-respond-to")).toBeDisabled();
await expect(page.locator("#agent-respond-to")).toContainText(
"Only me (default)",
);
await expect(
page.getByTestId("agent-respond-to-disabled-reason"),
).toHaveText("This build disallows changing this setting.");
});
test("OSS build keeps the managed-agent access control", async ({ page }) => {
await installMockBridge(page, {
bakedBuildEnv: BAKED_DEFAULTS,
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped",
channelNames: ["agents"],
},
],
});
await openEditDialog(page);
await expect(page.getByTestId("agent-respond-to")).toBeVisible();
});
test("edits the agent name and persists it across a dialog reopen", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped",
channelNames: ["agents"],
},
],
});
await openEditDialog(page);
const nameInput = page.locator("#edit-agent-name");
await expect(nameInput).toHaveValue(AGENT_NAME);
await nameInput.fill("Tyler Agent Renamed");
await page.getByTestId("edit-agent-dialog-submit").click();
await expect(page.getByTestId("edit-agent-dialog")).not.toBeVisible();
// Reopen: the dialog re-reads the managed-agents store, proving the save
// survived the dialog lifecycle rather than living in local state. (The
// panel HEADER is not asserted — it renders the relay profile name, which
// the update path does not touch.)
await page.getByTestId("user-profile-edit-agent").click();
await expect(page.locator("#edit-agent-name")).toHaveValue(
"Tyler Agent Renamed",
{ timeout: 10_000 },
);
});
test("changes the model via custom entry and persists it", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped",
channelNames: ["agents"],
},
],
});
await openEditDialog(page);
// Pick a provider so model discovery has a scope, then set a custom model.
await pickDropdownOption(page, "edit-agent-llm-provider", "Anthropic");
await pickDropdownOption(page, "edit-agent-model", "Custom model...");
await page.locator("#edit-agent-custom-model").fill("claude-opus-4-5");
// Anthropic requires a credential before save unlocks.
await page.getByLabel("Anthropic API Key").fill("sk-test-edit-agent-e2e");
const submit = page.getByTestId("edit-agent-dialog-submit");
await expect(submit).toBeEnabled({ timeout: 10_000 });
await submit.click();
await expect(page.getByTestId("edit-agent-dialog")).not.toBeVisible();
await page.getByTestId("user-profile-edit-agent").click();
await expect(page.getByTestId("edit-agent-dialog")).toBeVisible({
timeout: 10_000,
});
// Custom model round-trips: the reopened dialog shows it in the custom
// input (the discovered-model lists don't contain it).
await expect(page.locator("#edit-agent-custom-model")).toHaveValue(
"claude-opus-4-5",
{ timeout: 10_000 },
);
});
test("keeps the custom command visible without opening Advanced", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped",
channelNames: ["agents"],
},
],
});
await openEditDialog(page);
const advanced = page.getByRole("button", {
name: "Advanced",
exact: true,
});
await expect(advanced).toHaveAttribute("aria-expanded", "false");
await pickDropdownOption(page, "edit-agent-runtime", "Custom command");
await expect(page.locator("#edit-agent-command")).toBeVisible();
await expect(advanced).toHaveAttribute("aria-expanded", "false");
});
test("marks a missing advanced credential without opening Advanced", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped",
channelNames: ["agents"],
},
],
});
await openEditDialog(page);
const advanced = page.getByRole("button", {
name: "Advanced",
exact: true,
});
await expect(advanced).toHaveAttribute("aria-expanded", "false");
await pickDropdownOption(page, "edit-agent-llm-provider", "Databricks v2");
await expect(advanced).toHaveAttribute("aria-expanded", "false");
await expect(
page.getByTestId("edit-agent-advanced-required-badge"),
).toHaveText("Required");
await expect(page.getByTestId("edit-agent-dialog-submit")).toBeDisabled();
await advanced.click();
await expect(page.getByLabel("Value for DATABRICKS_HOST")).toBeVisible();
});
test("shows baked defaults in the instance editor", async ({ page }) => {
await installMockBridge(page, {
bakedBuildEnv: BAKED_DEFAULTS,
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped",
channelNames: ["agents"],
},
],
});
await openEditDialog(page);
await expect(page.locator("#edit-agent-llm-provider")).toHaveText(
"Anthropic (inherited from build)",
);
await expect(page.locator("#edit-agent-model")).toHaveText(
"Inherit build default (claude-opus-4-8)",
);
const defaults = page.getByTestId("agent-ai-defaults-notice");
await expect(
defaults.getByText("Anthropic", { exact: true }),
).toBeVisible();
await expect(
defaults.getByText("claude-opus-4-8", { exact: true }),
).toBeVisible();
});
test("explicit global defaults override baked labels in the instance editor", async ({
page,
}) => {
await installMockBridge(page, {
bakedBuildEnv: BAKED_DEFAULTS,
globalAgentConfig: {
provider: "anthropic",
model: "claude-opus-4-5",
env_vars: { BUZZ_AGENT_THINKING_EFFORT: "low" },
},
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped",
channelNames: ["agents"],
},
],
});
await openEditDialog(page);
await expect(page.locator("#edit-agent-llm-provider")).toHaveText(
"Use agent defaults (anthropic)",
);
await expect(page.locator("#edit-agent-model")).toHaveText(
"Use agent defaults (claude-opus-4-5)",
);
const defaults = page.getByTestId("agent-ai-defaults-notice");
await expect(
defaults.getByText("Anthropic", { exact: true }),
).toBeVisible();
await expect(
defaults.getByText("claude-opus-4-5", { exact: true }),
).toBeVisible();
});
test("profile Edit routes persona-linked agents to the definition editor", async ({
page,
}) => {
// Routing pin for handleEditAgent (UserProfilePanel): when the agent has
// a resolvable non-built-in persona, the Edit quick action opens the
// DEFINITION editor (persona dialog), not EditAgentDialog. The instance
// editor (and its inherit-runtime toggle) is reachable for persona-linked
// agents only via the requestOpenEditAgent event (ConfigNudgeCard) — no
// plain UI path — so its inherit-toggle behavior is covered by B3b's
// component-level pinning test, not e2e.
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
personaId: PERSONA_ID,
status: "stopped",
channelNames: ["agents"],
},
],
personas: [
{
id: PERSONA_ID,
displayName: "Edit E2E Persona",
systemPrompt: "You are the edit-agent e2e persona.",
},
],
});
await page.goto("/");
await page.getByTestId("open-agents-view").click();
// Persona-linked agents render grouped under the persona's card name.
const agentButton = page.getByRole("button", {
name: "Edit E2E Persona agent profile",
});
await expect(agentButton).toBeVisible({ timeout: 10_000 });
await agentButton.click();
await expect(page.getByTestId("user-profile-panel")).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("user-profile-edit-agent").click();
// Definition editor opens; the instance editor does not.
await expect(page.getByTestId("persona-dialog")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId("edit-agent-dialog")).not.toBeVisible();
// And it is the persona's record that's being edited.
await expect(page.locator("#persona-display-name")).toHaveValue(
"Edit E2E Persona",
);
});
});
+120
View File
@@ -0,0 +1,120 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// The mock identity's own pre-seeded message in #general (authored by
// DEFAULT_MOCK_IDENTITY.pubkey in e2eBridge.ts). Editing/deleting one's own
// message is exactly Sam's workflow: "delete a message by clearing its edit."
const OWN_MESSAGE_ID = "mock-general-welcome";
const ORIGINAL_CONTENT = "Welcome to #general";
// Open the more-actions menu for a message row and wait for the menu to mount.
async function openMoreActionsMenu(
page: import("@playwright/test").Page,
messageId: string,
) {
const row = page.locator(`[data-message-id="${messageId}"]`);
await row.hover();
await page.getByTestId(`more-actions-${messageId}`).click();
await expect(page.locator('[role="menuitem"]').first()).toBeVisible({
timeout: 5_000,
});
}
// Enter edit mode for a message, clear it to empty, and submit — the gesture
// that triggers the empty-edit delete confirmation.
async function submitEmptyEdit(
page: import("@playwright/test").Page,
messageId: string,
) {
await openMoreActionsMenu(page, messageId);
await page.getByTestId(`edit-message-${messageId}`).click();
await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 });
// Edit mode sets the editor content via Tiptap's async transaction pipeline;
// wait for it to populate before we clear it.
const input = page.getByTestId("message-input");
await expect(input).not.toBeEmpty({ timeout: 5_000 });
await input.click();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.press("Backspace");
await expect(input).toBeEmpty();
await page.keyboard.press("Enter");
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
});
test("clearing an edit to empty prompts to delete, then deletes on confirm", async ({
page,
}) => {
const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`);
await expect(row).toBeVisible({ timeout: 10_000 });
await submitEmptyEdit(page, OWN_MESSAGE_ID);
// The same "Delete message?" confirmation the Delete menu action shows — an
// empty edit is routed through it, not silently deleted.
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 10_000 });
await expect(dialog).toContainText("Delete message?");
// Edit mode stays active while the dialog is open — it exits only on confirm.
await expect(page.getByTestId("edit-target")).toBeVisible();
// Confirm → the message row is removed and edit mode has exited.
await dialog.getByRole("button", { name: "Delete" }).click();
await expect(dialog).toBeHidden({ timeout: 5_000 });
await expect(page.getByTestId("edit-target")).toBeHidden();
await expect(row).toBeHidden({ timeout: 5_000 });
});
test("cancelling the empty-edit delete keeps the message", async ({ page }) => {
const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`);
await expect(row).toBeVisible({ timeout: 10_000 });
await submitEmptyEdit(page, OWN_MESSAGE_ID);
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 10_000 });
// Cancel → nothing is deleted, the original message survives, and the user is
// left in edit mode (the editing session is preserved, not discarded).
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect(dialog).toBeHidden({ timeout: 5_000 });
await expect(page.getByTestId("edit-target")).toBeVisible();
await expect(row).toBeVisible();
await expect(page.getByTestId("message-timeline")).toContainText(
ORIGINAL_CONTENT,
);
});
test("a non-empty edit still edits and never deletes", async ({ page }) => {
const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`);
await expect(row).toBeVisible({ timeout: 10_000 });
await openMoreActionsMenu(page, OWN_MESSAGE_ID);
await page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`).click();
await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 });
const input = page.getByTestId("message-input");
await expect(input).not.toBeEmpty({ timeout: 5_000 });
const editedContent = `Edited, not deleted ${Date.now()}`;
await input.click();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.type(editedContent);
await page.keyboard.press("Enter");
// No delete confirmation, edit mode exits, the row survives with new text.
await expect(page.getByRole("alertdialog")).toHaveCount(0);
await expect(page.getByTestId("edit-target")).toBeHidden({ timeout: 10_000 });
await expect(row).toBeVisible();
await expect(page.getByTestId("message-timeline")).toContainText(
editedContent,
);
await expect(page.getByTestId("message-timeline")).not.toContainText(
ORIGINAL_CONTENT,
);
});
+653
View File
@@ -0,0 +1,653 @@
import { expect, test } from "@playwright/test";
import type { Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css";
async function openMoreActionsMenu(page: Page, messageId: string) {
const row = page.locator(`[data-message-id="${messageId}"]`);
await row.hover();
await page.getByTestId(`more-actions-${messageId}`).click();
await expect(page.locator('[role="menuitem"]').first()).toBeVisible({
timeout: 5_000,
});
}
// Exercises the generic file-attachment UI contract end-to-end through the
// mock Tauri bridge: paperclip upload → composer chip → send → FileCard in the
// timeline. This guards the frontend wiring (the riskiest, previously
// untested path). It does NOT prove the real relay store/serve round-trip —
// that lives in the Rust media + relay tests.
test.beforeEach(async ({ page }) => {
await installMockBridge(page, {
deferredComposerUploads: true,
uploadDescriptors: [
{
url: `https://mock.relay/media/${"a".repeat(64)}.pdf`,
sha256: "a".repeat(64),
size: 12345,
type: "application/pdf",
uploaded: Math.floor(Date.now() / 1000),
filename: "quarterly-report.pdf",
},
],
});
});
async function chooseQuarterlyReport(page: Page) {
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByRole("button", { name: "Attach file" }).click(),
]);
await chooser.setFiles({
buffer: Buffer.from("quarterly report"),
mimeType: "application/pdf",
name: "quarterly-report.pdf",
});
}
async function chooseLargeVideo(page: Page) {
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByRole("button", { name: "Attach file" }).click(),
]);
await chooser.setFiles({
buffer: Buffer.alloc(16 * 1024 * 1024, 1),
mimeType: "video/mp4",
name: "large-video.mp4",
});
}
async function choosePhoto(page: Page) {
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByRole("button", { name: "Attach file" }).click(),
]);
await chooser.setFiles({
buffer: Buffer.from("photo"),
mimeType: "image/png",
name: "photo.png",
});
}
test("photos upload before Send without a queued spoiler control", async ({
page,
}) => {
await page.goto("/");
await page.evaluate(() => {
const e2e = (
window as Window & {
__BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } };
}
).__BUZZ_E2E__;
if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000;
});
await page.getByTestId("channel-general").click();
await choosePhoto(page);
await expect(page.getByTestId("upload-progress")).toBeVisible();
await expect(page.getByTestId("composer-queued-video-spoiler")).toHaveCount(
0,
);
// Photos upload immediately, so they are in neither `pendingImeta` nor the
// queued list until the upload lands: Send stays blocked so the message
// cannot publish without the attachment.
await expect(page.getByTestId("send-message")).toBeDisabled();
await expect(page.getByTestId("upload-progress")).toHaveCount(0, {
timeout: 5_000,
});
await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0);
await expect(page.getByTestId("send-message")).toBeEnabled();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("upload_media_bytes_raw");
});
test("opening edit during an immediate photo upload preserves the draft", async ({
page,
}) => {
await page.goto("/");
await page.evaluate(() => {
const e2e = (
window as Window & {
__BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } };
}
).__BUZZ_E2E__;
if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000;
});
await page.getByTestId("channel-general").click();
await choosePhoto(page);
await expect(page.getByTestId("upload-progress")).toBeVisible();
await openMoreActionsMenu(page, "mock-general-welcome");
await page.getByTestId("edit-message-mock-general-welcome").click();
// Edit entry is rejected while the compacted draft cannot represent the
// reserved upload slot. The upload remains current and lands in the draft.
await expect(page.getByTestId("edit-target")).toHaveCount(0);
await expect(page.getByTestId("upload-progress")).toBeVisible();
await expect(page.getByTestId("upload-progress")).toHaveCount(0, {
timeout: 5_000,
});
await expect(page.getByTestId("message-composer")).toContainText(
"quarterly-report.pdf",
);
// Once settled, the same edit action enters edit mode normally.
await openMoreActionsMenu(page, "mock-general-welcome");
await page.getByTestId("edit-message-mock-general-welcome").click();
await expect(page.getByTestId("edit-target")).toBeVisible();
});
test("upload a file and see a FileCard in the timeline", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Non-video files keep the established immediate-upload behavior.
await chooseQuarterlyReport(page);
// The composer shows a chip with the original filename.
await expect(page.getByTestId("message-composer")).toContainText(
"quarterly-report.pdf",
);
// Send the (attachment-only) message.
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
// A FileCard renders in the timeline: a button carrying the filename. It
// downloads via the native `download_file` command (HTTP inside the app's
// tunnel + save dialog), NOT a plain `<a download>` link — a bare link
// escapes the webview to the OS browser and hits a corporate CDN page.
const card = page.getByTestId("file-card").last();
await expect(card).toBeVisible();
await expectCornerRadiusPx(card, 16);
await expectSmoothCorners(card);
await expect(card).toContainText("quarterly-report.pdf");
await card.click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("download_file");
});
test("sends immediately and keeps upload progress across channels", async ({
page,
}) => {
await page.goto("/");
await page.evaluate(() => {
const e2e = (
window as Window & {
__BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } };
}
).__BUZZ_E2E__;
if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000;
});
await page.getByTestId("channel-general").click();
await chooseLargeVideo(page);
await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0);
await expect(page.getByTestId("composer-video-spoiler")).toHaveCount(0);
const queuedSpoiler = page.getByTestId("composer-queued-video-spoiler");
// Revealed on hover rather than removed from the DOM: the control stays
// focusable so keyboard users can reach it, but is transparent and
// click-through until the thumbnail is hovered or focused.
await expect(queuedSpoiler).toHaveCSS("opacity", "0");
await expect(queuedSpoiler).toHaveCSS("pointer-events", "none");
await page.getByTestId("composer-queued-media-attachment").hover();
await expect(queuedSpoiler).toBeVisible();
await expect(queuedSpoiler).toHaveCSS("opacity", "1");
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-composer")).not.toContainText(
"large-video.mp4",
);
await expect(page.getByTestId("composer-upload-progress")).toBeVisible();
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(page.getByTestId("composer-upload-progress")).toBeVisible();
await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0, {
timeout: 5_000,
});
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("file-card").last()).toContainText(
"quarterly-report.pdf",
);
});
test("shows upload feedback before transferring a large file", async ({
page,
}) => {
await page.goto("/");
await page.evaluate(() => {
const e2e = (
window as Window & {
__BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } };
}
).__BUZZ_E2E__;
if (e2e?.mock) e2e.mock.uploadDelayMs = 5_000;
});
await page.getByTestId("channel-general").click();
await chooseLargeVideo(page);
const progress = page.getByTestId("composer-upload-progress");
await Promise.all([
page.getByTestId("send-message").click(),
expect(progress).toBeVisible({ timeout: 800 }),
]);
await expect(progress).toHaveAttribute("aria-label", "Preparing");
await expect(page.getByTestId("composer-upload-spinner")).toBeVisible();
await expect(page.getByTestId("composer-upload-percentage")).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{
command: string;
payload: { rawByteLength?: number } | null;
}>;
}
).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [],
),
)
.toContainEqual({
command: "upload_media_bytes_raw",
payload: { rawByteLength: 16 * 1024 * 1024 },
});
const uploadId = "background-media-upload-0-0";
await page.evaluate(async (id) => {
await window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PHASE__?.({
id,
phase: "processing-video",
});
}, uploadId);
await expect(progress).toHaveAttribute("aria-label", "Processing");
await waitForAnimations(page);
const processingPhaseBox = await page
.getByTestId("composer-upload-phase")
.boundingBox();
const processingStatusBox = await page
.getByTestId("composer-upload-status")
.boundingBox();
expect(processingPhaseBox).not.toBeNull();
expect(processingStatusBox).not.toBeNull();
expect(
(processingStatusBox?.x ?? 0) -
((processingPhaseBox?.x ?? 0) + (processingPhaseBox?.width ?? 0)),
).toBeGreaterThanOrEqual(3);
await expect(page.getByTestId("composer-upload-spinner")).toBeVisible();
await expect(page.getByTestId("composer-upload-percentage")).toHaveCount(0);
await page.evaluate(async (id) => {
await window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PHASE__?.({
id,
phase: "uploading",
});
await window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PROGRESS__?.({
id,
sent: 42,
total: 100,
});
}, uploadId);
await expect(progress).toHaveAttribute("aria-label", "Uploading 42%");
await waitForAnimations(page);
await expect(page.getByTestId("composer-upload-spinner")).toHaveCount(0);
await expect(page.getByTestId("composer-upload-percentage")).toHaveText(
"42%",
);
await page.getByTestId("composer-upload-cancel").click();
});
test("canceling a background upload prevents the message from publishing", async ({
page,
}) => {
await page.goto("/");
await page.evaluate(() => {
const e2e = (
window as Window & {
__BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } };
}
).__BUZZ_E2E__;
if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000;
});
await page.getByTestId("channel-general").click();
await chooseLargeVideo(page);
await page.getByTestId("send-message").click();
await page.getByTestId("composer-upload-cancel").click();
await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0);
await page.waitForTimeout(1_100);
await expect(page.getByTestId("file-card")).toHaveCount(0);
});
test("upload progress floats above the dock and lifts Jump to latest", async ({
page,
}) => {
await page.goto("/");
await page.evaluate(() => {
const e2e = (
window as Window & {
__BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } };
}
).__BUZZ_E2E__;
if (e2e?.mock) e2e.mock.uploadDelayMs = 2_000;
});
await page.getByTestId("channel-deep-history").click();
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await timeline.evaluate((element) => {
element.scrollTop = Math.max(500, element.scrollHeight / 2);
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
const jumpToLatest = page.getByTestId("message-scroll-to-latest");
await expect(jumpToLatest).toBeVisible();
const restingBox = await jumpToLatest.boundingBox();
await chooseLargeVideo(page);
await page.getByTestId("send-message").click();
const uploadMotion = page.getByTestId("composer-upload-progress-motion");
await expect(uploadMotion).toBeVisible();
await timeline.evaluate((element) => {
element.scrollTop = Math.max(500, element.scrollHeight / 2);
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await expect(jumpToLatest).toBeVisible();
await page.waitForTimeout(250);
const [uploadBox, dockBackdropBox, liftedBox] = await Promise.all([
uploadMotion.boundingBox(),
page.getByTestId("composer-dock-backdrop").boundingBox(),
jumpToLatest.boundingBox(),
]);
expect(restingBox).not.toBeNull();
expect(uploadBox).not.toBeNull();
expect(dockBackdropBox).not.toBeNull();
expect(liftedBox).not.toBeNull();
expect((dockBackdropBox?.y ?? 0) + 1).toBeGreaterThanOrEqual(
(uploadBox?.y ?? 0) + (uploadBox?.height ?? 0),
);
expect((liftedBox?.y ?? 0) + (liftedBox?.height ?? 0)).toBeLessThanOrEqual(
uploadBox?.y ?? 0,
);
expect(liftedBox?.y ?? 0).toBeLessThan((restingBox?.y ?? 0) - 10);
await page.getByTestId("composer-upload-cancel").click();
});
test("dropping a file on the channel column attaches it to the composer", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const dataTransfer = await page.evaluateHandle(() => {
const transfer = new DataTransfer();
transfer.items.add(
new File(["quarterly report"], "quarterly-report.pdf", {
type: "application/pdf",
}),
);
return transfer;
});
const dropZone = page.getByTestId("channel-drop-zone");
await dropZone.dispatchEvent("dragenter", { dataTransfer });
const overlay = dropZone.getByTestId("drop-zone-overlay");
const label = dropZone.getByTestId("drop-zone-label");
await expect(overlay).toBeVisible();
await expect(label).toContainText("Drop files to upload");
const [dropZoneBox, overlayBox, overlayStyles, stacking] = await Promise.all([
dropZone.boundingBox(),
overlay.boundingBox(),
page.evaluate(() => {
const overlayElement = document.querySelector<HTMLElement>(
'[data-testid="drop-zone-overlay"]',
);
const contentSurface = document.querySelector<HTMLElement>(
"[data-buzz-content-surface]",
);
if (!(overlayElement && contentSurface)) return null;
const overlayStyle = getComputedStyle(overlayElement);
return {
backdropFilter: overlayStyle.backdropFilter,
containerRadius: getComputedStyle(contentSurface).borderRadius,
overlayRadius: overlayStyle.borderRadius,
};
}),
page.evaluate(() => {
const overlayElement = document.querySelector<HTMLElement>(
'[data-testid="drop-zone-overlay"]',
);
const composerOverlayElement = document.querySelector<HTMLElement>(
'[data-testid="channel-composer-overlay"]',
);
if (!(overlayElement && composerOverlayElement)) return null;
return {
composer: Number.parseInt(
getComputedStyle(composerOverlayElement).zIndex,
10,
),
dropZone: Number.parseInt(getComputedStyle(overlayElement).zIndex, 10),
};
}),
]);
expect(overlayBox).toEqual(dropZoneBox);
expect(overlayStyles).not.toBeNull();
expect(overlayStyles?.overlayRadius).toBe(overlayStyles?.containerRadius);
expect(overlayStyles?.backdropFilter).toContain("blur");
expect(stacking).not.toBeNull();
expect(stacking?.dropZone).toBeGreaterThan(stacking?.composer ?? 0);
await dropZone.dispatchEvent("drop", { dataTransfer });
await expect(page.getByTestId("message-composer")).toContainText(
"quarterly-report.pdf",
);
});
for (const theme of ["buzz", "buzz-dark", "github-light", "github-dark"]) {
test(`drop prompt has accessible text contrast in ${theme}`, async ({
page,
}) => {
await page.goto("/");
await page.evaluate((selectedTheme) => {
window.localStorage.setItem("buzz-theme", selectedTheme);
}, theme);
await page.reload();
await page.getByTestId("channel-general").click();
const dataTransfer = await page.evaluateHandle(() => {
const transfer = new DataTransfer();
transfer.items.add(
new File(["contrast check"], "contrast-check.txt", {
type: "text/plain",
}),
);
return transfer;
});
const dropZone = page.getByTestId("channel-drop-zone");
await dropZone.dispatchEvent("dragenter", { dataTransfer });
const contrastRatio = await dropZone
.getByTestId("drop-zone-label")
.evaluate((element) => {
const parseRgb = (value: string) =>
(value.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number);
const luminance = (color: number[]) =>
color
.map((channel) => {
const value = channel / 255;
return value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4;
})
.reduce(
(sum, channel, index) =>
sum + channel * [0.2126, 0.7152, 0.0722][index],
0,
);
const style = getComputedStyle(element);
const foreground = luminance(parseRgb(style.color));
const background = luminance(parseRgb(style.backgroundColor));
return (
(Math.max(foreground, background) + 0.05) /
(Math.min(foreground, background) + 0.05)
);
});
expect(contrastRatio).toBeGreaterThanOrEqual(4.5);
});
}
test("forum posts emit a FileCard for generic attachments, not a broken image", async ({
page,
}) => {
// Regression guard for the ForumComposer bug: it used to hand-build content
// as `![image](url)` for every non-video attachment (and omit the `filename`
// imeta tag), so a PDF posted in a forum rendered as a broken inline image
// and lost its label. The fix routes forum/notes posts through the same
// `buildOutgoingMessage` builder as chat. This test would fail (no FileCard)
// if ForumComposer ever drifts back to hand-building media markdown.
await page.goto("/");
// "watercooler" is a seeded forum the mock identity is a member of.
await page.getByTestId("channel-watercooler").click();
// Open the new-post composer ("Start a new post...").
await page.getByRole("button", { name: "Start a new post..." }).click();
// Paperclip → mocked pick_and_upload_media returns the PDF descriptor.
await page.getByRole("button", { name: "Attach file" }).click();
// Submit the (attachment-only) forum post.
await page.getByTestId("send-message").click();
// The post renders through the shared Markdown component as a FileCard —
// a button carrying the filename that downloads via the native
// `download_file` command — NOT an inline image and NOT a bare link.
const card = page.getByTestId("file-card");
await expect(card).toBeVisible();
await expect(card).toContainText("quarterly-report.pdf");
await card.click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("download_file");
});
test("a queued attachment can be removed without a mouse", async ({ page }) => {
// Regression: the queued remove badge is revealed on hover, but hiding it
// with `display: none` made it unfocusable, leaving keyboard-only users no
// way to drop a queued video before sending.
await page.goto("/");
await page.getByTestId("channel-general").click();
await chooseLargeVideo(page);
const queued = page.getByTestId("composer-queued-media-attachment");
await expect(queued).toBeVisible();
const remove = queued.getByRole("button", { name: "Remove attachment" });
// Focusable while transparent — this is what `display: none` prevented.
await remove.focus();
await expect(remove).toBeFocused();
// Focus reveals it, so the user can see what they are about to activate.
await expect(remove).toHaveCSS("opacity", "1");
await page.keyboard.press("Enter");
await expect(queued).toHaveCount(0);
await expect(page.getByTestId("message-composer")).not.toContainText(
"large-video.mp4",
);
});
test("an uploaded attachment's remove button is named and keyboard-operable", async ({
page,
}) => {
// Companion to the queued case: these badges are now in the tab order, so
// every icon-only remove button needs an accessible name a screen reader can
// read. Images and non-media files render through different branches, so
// both are checked here.
await installMockBridge(page, {
deferredComposerUploads: true,
uploadDescriptors: [
{
url: `https://mock.relay/media/${"b".repeat(64)}.png`,
sha256: "b".repeat(64),
size: 2048,
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
dim: "320x200",
filename: "photo.png",
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
const composer = page.getByTestId("message-composer");
const remove = composer.getByRole("button", { name: "Remove attachment" });
// Image attachment (MediaAttachmentItem).
await choosePhoto(page);
await expect(composer.getByTestId("composer-media-attachment")).toBeVisible();
await expect(remove).toHaveCount(1);
await remove.focus();
await expect(remove).toBeFocused();
await expect(remove).toHaveCSS("opacity", "1");
await page.keyboard.press("Enter");
await expect(composer.getByTestId("composer-media-attachment")).toHaveCount(
0,
);
});
test("a non-media attachment's remove button is named and keyboard-operable", async ({
page,
}) => {
// The file-card branch renders its own remove badge, so it needs the same
// accessible name as the image and queued ones.
await page.goto("/");
await page.getByTestId("channel-general").click();
await chooseQuarterlyReport(page);
const composer = page.getByTestId("message-composer");
await expect(composer).toContainText("quarterly-report.pdf");
const remove = composer.getByRole("button", { name: "Remove attachment" });
await expect(remove).toHaveCount(1);
await remove.focus();
await expect(remove).toBeFocused();
await expect(remove).toHaveCSS("opacity", "1");
await page.keyboard.press("Enter");
await expect(composer).not.toContainText("quarterly-report.pdf");
});
@@ -0,0 +1,104 @@
#!/usr/bin/env node
/** Deterministic ACP fixture for agents-everywhere live tests. */
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { createInterface } from "node:readline";
const exec = promisify(execFile);
const wakeDelayMs = Number.parseInt(
process.env.BUZZ_E2E_FAKE_ACP_WAKE_MS ?? "0",
10,
);
if (!Number.isFinite(wakeDelayMs) || wakeDelayMs < 0) {
throw new Error("BUZZ_E2E_FAKE_ACP_WAKE_MS must be a non-negative integer");
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
const textFromPrompt = (params) =>
(params?.prompt ?? [])
.filter((part) => part?.type === "text" && typeof part.text === "string")
.map((part) => part.text)
.join("\n");
let sessionCounter = 0;
const input = createInterface({
input: process.stdin,
crlfDelay: Number.POSITIVE_INFINITY,
});
for await (const line of input) {
if (!line.trim()) continue;
const request = JSON.parse(line);
if (request.id === undefined || typeof request.method !== "string") continue;
switch (request.method) {
case "initialize":
if (wakeDelayMs > 0) await sleep(wakeDelayMs);
write({
jsonrpc: "2.0",
id: request.id,
result: { protocolVersion: 2, agentCapabilities: {} },
});
break;
case "session/new":
sessionCounter += 1;
write({
jsonrpc: "2.0",
id: request.id,
result: { sessionId: `fake-session-${sessionCounter}` },
});
break;
case "session/prompt": {
const prompt = textFromPrompt(request.params);
const ids = [...prompt.matchAll(/\bAE-ID:([A-Za-z0-9._:-]+)\b/g)].map(
(match) => match[1],
);
const ack = `AE-ACK:${ids.join(",")}`;
write({
jsonrpc: "2.0",
method: "session/update",
params: {
sessionId: request.params?.sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: ack },
},
},
});
const channel = prompt.match(/^Channel: .+ \(#([^)]+)\)$/m)?.[1];
const replyTo = prompt.match(/--reply-to ([0-9a-f]{64})/)?.[1];
const cli = process.env.BUZZ_E2E_CLI_BIN;
if (cli && channel) {
const args = [
"messages",
"send",
"--channel",
channel,
"--content",
ack,
];
if (replyTo) args.push("--reply-to", replyTo);
await exec(cli, args, { env: process.env });
}
write({
jsonrpc: "2.0",
id: request.id,
result: { stopReason: "end_turn" },
});
break;
}
case "session/cancel":
write({ jsonrpc: "2.0", id: request.id, result: {} });
break;
default:
write({
jsonrpc: "2.0",
id: request.id,
error: {
code: -32601,
message: `Unsupported fixture method: ${request.method}`,
},
});
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,179 @@
import { test, expect } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
const SHOTS = "test-results/byoh-after";
/**
* AFTER screenshots for the consolidated Harnesses surface same catalog
* fixture as byoh-before-screenshots.spec.ts so the before/after pair is a
* true apples-to-apples comparison.
*/
const CATALOG = [
{
id: "buzz-agent",
label: "Buzz Agent",
avatar_url: "",
availability: "available",
command: "buzz-agent",
binary_path: "/usr/local/bin/buzz-agent",
default_args: [],
mcp_command: "buzz-dev-mcp",
install_hint: "",
install_instructions_url: "https://github.com/block/buzz",
can_auto_install: false,
underlying_cli_path: null,
node_required: false,
auth_status: { status: "not_applicable" },
},
{
id: "claude",
label: "Claude Code",
avatar_url: "",
availability: "available",
command: "claude-agent-acp",
binary_path: "/usr/local/bin/claude-agent-acp",
default_args: [],
mcp_command: null,
install_hint: "",
install_instructions_url:
"https://github.com/agentclientprotocol/claude-agent-acp",
can_auto_install: true,
underlying_cli_path: "/usr/local/bin/claude",
node_required: false,
auth_status: { status: "logged_in" },
},
{
id: "codex",
label: "Codex",
avatar_url: "",
availability: "adapter_missing",
command: null,
binary_path: null,
default_args: [],
mcp_command: null,
install_hint: "Install the Codex ACP adapter via npm.",
install_instructions_url:
"https://github.com/agentclientprotocol/codex-acp",
can_auto_install: true,
underlying_cli_path: "/usr/local/bin/codex",
node_required: false,
auth_status: { status: "unknown" },
},
{
id: "cursor",
label: "Cursor",
avatar_url: "",
availability: "available",
command: "cursor-agent",
binary_path: "/usr/local/bin/cursor-agent",
default_args: ["acp"],
mcp_command: null,
install_hint:
"Buzz talks to Cursor through the cursor-agent CLI's ACP mode.",
install_instructions_url: "https://cursor.com/downloads",
can_auto_install: false,
underlying_cli_path: null,
node_required: false,
auth_status: { status: "not_applicable" },
source: "preset",
},
{
id: "omp",
label: "Oh My Pi",
avatar_url: "",
availability: "not_installed",
command: null,
binary_path: null,
default_args: ["acp"],
mcp_command: null,
install_hint:
"Buzz talks to Oh My Pi through its CLI's ACP mode (omp acp).",
install_instructions_url: "https://github.com/can1357/oh-my-pi",
can_auto_install: false,
underlying_cli_path: null,
node_required: false,
auth_status: { status: "not_applicable" },
source: "preset",
},
{
id: "opencode",
label: "OpenCode",
avatar_url: "",
availability: "not_installed",
command: null,
binary_path: null,
default_args: ["acp"],
mcp_command: null,
install_hint:
"Buzz talks to OpenCode through its CLI's ACP mode (opencode acp).",
install_instructions_url: "https://opencode.ai/docs",
can_auto_install: false,
underlying_cli_path: null,
node_required: false,
auth_status: { status: "not_applicable" },
source: "preset",
},
{
id: "my-custom",
label: "My Custom Harness",
avatar_url: "",
availability: "available",
command: "my-acp",
binary_path: "/usr/local/bin/my-acp",
default_args: [],
mcp_command: null,
install_hint: "",
install_instructions_url: "",
can_auto_install: false,
underlying_cli_path: null,
node_required: false,
auth_status: { status: "not_applicable" },
source: "custom",
},
];
test("after: consolidated harnesses panel + catalog dialog", async ({
page,
}) => {
test.setTimeout(60_000);
await page.setViewportSize({ width: 1280, height: 2400 });
await installMockBridge(page, { acpRuntimesCatalog: CATALOG });
await page.goto("/", { waitUntil: "domcontentloaded" });
await openSettings(page, "agents");
await expect(page.getByTestId("settings-harnesses")).toBeVisible({
timeout: 10_000,
});
await page.waitForTimeout(700);
// 1. The consolidated panel — ready rows only, no inert controls.
await page.getByTestId("settings-harnesses").screenshot({
path: `${SHOTS}/after-harnesses-panel.png`,
});
// 2. Add-harnesses catalog — needs-setup entry auto-selected in detail.
await page.getByTestId("harness-add-button").click();
await expect(page.getByTestId("harness-catalog-dialog")).toBeVisible();
await page.waitForTimeout(500);
await page.getByTestId("harness-catalog-dialog").screenshot({
path: `${SHOTS}/after-catalog-needs-setup.png`,
});
// 3. Ready entry detail (Ready state, no install action). Ready entries
// sit in the "Installed" accordion, collapsed by default — expand it.
await page.getByTestId("harness-catalog-section-installed").click();
await page.getByTestId("harness-catalog-list-item-claude").click();
await page.waitForTimeout(300);
await page.getByTestId("harness-catalog-dialog").screenshot({
path: `${SHOTS}/after-catalog-ready-detail.png`,
});
// 4. Custom harness form — all fields inline.
await page.getByTestId("harness-catalog-list-item-custom").click();
await expect(page.getByTestId("custom-harness-form")).toBeVisible();
await page.waitForTimeout(300);
await page.getByTestId("harness-catalog-dialog").screenshot({
path: `${SHOTS}/after-catalog-custom-form.png`,
});
});
@@ -0,0 +1,690 @@
/**
* E2E spec for the consolidated Harnesses settings surface + Add-harnesses
* catalog dialog.
*
* Covers:
* - Ready preset gets a row in "Your harnesses"; needs-setup preset does NOT
* - Needs-setup preset appears in the Add-harnesses catalog with status,
* curated description, docs link, and setup action
* - Catalog search filters the list
* - Toggling install does not reorder rows (stable order)
* - Add custom harness via catalog (name+command save row appears)
* - Advanced disclosure hides ID/args/env/docs/hint until opened
* - Edit preserves env vars (round-trip through definitionEnv boundary)
* - Same-ID edit replaces entry (no duplicate row)
* - Rename removes old row and shows new row
* - Delete success removes row; delete failure shows inline error
* - PATH badge: custom harness row shows Detected when available
* - Preset rows render bundled logos, never initials
* - Onboarding navigate: setup-page "More harnesses" click Settings Agents (F8)
*/
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { passThroughBackupStep } from "../helpers/onboarding";
// ── Shared catalog fixtures ───────────────────────────────────────────────────
/** Hermes preset with availability "available" — earns a Your-harnesses row. */
const HERMES_AVAILABLE = {
id: "hermes",
label: "Hermes",
avatar_url: "",
availability: "available",
command: "hermes-acp",
binary_path: "/usr/local/bin/hermes-acp",
default_args: [],
mcp_command: null,
install_hint: "Buzz talks to Hermes Agent through its hermes-acp command.",
install_instructions_url: "https://hermes-agent.nousresearch.com",
can_auto_install: false,
requires_external_cli: true,
underlying_cli_path: null,
node_required: false,
auth_status: { status: "unknown" },
source: "preset",
} as const;
/** OpenClaw preset "not_installed" + no auto-install — catalog-only. */
const OPENCLAW_NOT_INSTALLED = {
id: "openclaw",
label: "OpenClaw",
avatar_url: "",
availability: "not_installed",
command: "openclaw",
binary_path: null,
default_args: ["acp"],
mcp_command: null,
install_hint:
"Buzz talks to OpenClaw through its ACP mode (openclaw acp), which relies on the OpenClaw Gateway daemon. Follow the setup guide to install both.",
install_instructions_url: "https://docs.openclaw.ai/start/getting-started",
can_auto_install: false,
requires_external_cli: true,
underlying_cli_path: null,
node_required: false,
auth_status: { status: "unknown" },
source: "preset",
} as const;
/** Cursor preset deliberately has NO bundled logo (brand assets not
* licensed for redistribution). Must render the terminal glyph, never
* initials. */
const CURSOR_AVAILABLE = {
id: "cursor",
label: "Cursor",
avatar_url: "",
availability: "available",
command: "cursor-agent",
binary_path: "/usr/local/bin/cursor-agent",
default_args: [],
mcp_command: null,
install_hint: "Buzz talks to Cursor through the cursor-agent CLI's ACP mode.",
install_instructions_url: "https://cursor.com/cli",
can_auto_install: false,
requires_external_cli: true,
underlying_cli_path: null,
node_required: false,
auth_status: { status: "unknown" },
source: "preset",
} as const;
/** Custom harness entry already persisted — always gets a row. */
function makeCustomEntry(
overrides: {
id?: string;
label?: string;
command?: string;
availability?: "available" | "not_installed";
definition_env?: Record<string, string>;
} = {},
) {
return {
id: overrides.id ?? "my-custom-agent",
label: overrides.label ?? "My Custom Agent",
avatar_url: "",
availability: overrides.availability ?? "not_installed",
command: overrides.command ?? "my-custom-acp",
binary_path:
overrides.availability === "available"
? "/usr/local/bin/my-custom-acp"
: null,
default_args: [],
mcp_command: null,
install_hint: "",
install_instructions_url: "",
can_auto_install: false,
requires_external_cli: true,
underlying_cli_path: null,
node_required: false,
auth_status: { status: "unknown" },
source: "custom",
definition_env: overrides.definition_env,
};
}
// ── Navigation helpers ────────────────────────────────────────────────────────
/**
* Open Settings Agents through the normal UI path.
* CI serves the app as a static SPA; direct navigation to /settings 404s
* before the client router starts.
*/
async function openHarnessSettings(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
await page.getByTestId("settings-nav-agents").click();
await expect(page.getByTestId("settings-harnesses")).toBeVisible({
timeout: 10_000,
});
}
/** Open the Add-harnesses catalog dialog from the settings surface. */
async function openCatalog(page: import("@playwright/test").Page) {
await page.getByTestId("harness-add-button").click();
await expect(page.getByTestId("harness-catalog-dialog")).toBeVisible();
}
/** Fill the custom harness form (all fields render inline). */
async function fillHarnessForm(
page: import("@playwright/test").Page,
values: {
label: string;
id?: string;
command: string;
env?: Array<{ key: string; value: string }>;
},
) {
await page.fill("#ch-label", values.label);
await page.fill("#ch-command", values.command);
if (values.id !== undefined) {
await page.fill("#ch-id", values.id);
}
for (const pair of values.env ?? []) {
await page.getByRole("button", { name: "Add env var" }).click();
// Fill last appended row.
const keyInputs = page.locator('input[placeholder="KEY"]');
const valInputs = page.locator('input[placeholder="value"]');
await keyInputs.last().fill(pair.key);
await valInputs.last().fill(pair.value);
}
}
// ── Your harnesses vs catalog split ──────────────────────────────────────────
test.describe("your harnesses split", () => {
test("ready preset gets a row; needs-setup preset is catalog-only", async ({
page,
}) => {
await installMockBridge(page, {
acpRuntimesCatalog: [HERMES_AVAILABLE, OPENCLAW_NOT_INSTALLED],
});
await openHarnessSettings(page);
// Ready preset row with a Ready status chip.
const hermesRow = page.getByTestId("doctor-runtime-hermes");
await expect(hermesRow).toBeVisible();
await expect(page.getByTestId("doctor-runtime-ready-hermes")).toHaveText(
"Ready",
);
// Needs-setup preset must NOT render a row (and thus no Install button).
await expect(page.getByTestId("doctor-runtime-openclaw")).toHaveCount(0);
await expect(
page.getByTestId("doctor-runtime-install-openclaw"),
).toHaveCount(0);
});
test("needs-setup preset appears in catalog with status, description, docs and setup action", async ({
page,
}) => {
await installMockBridge(page, {
acpRuntimesCatalog: [HERMES_AVAILABLE, OPENCLAW_NOT_INSTALLED],
});
await openHarnessSettings(page);
await openCatalog(page);
// Needs-setup entries sort first, so OpenClaw is auto-selected.
await expect(
page.getByTestId("harness-catalog-list-item-openclaw"),
).toBeVisible();
const detail = page.getByTestId("harness-catalog-detail-pane");
await expect(detail).toContainText("OpenClaw");
// Status chip.
await expect(
page.getByTestId("harness-catalog-status-openclaw"),
).toHaveText("CLI needed");
// Curated one-liner (first-party-sourced category sentence).
await expect(detail).toContainText(
"A personal AI assistant that runs on your own devices.",
);
// Operational setup hint from runtime state.
await expect(detail).toContainText("OpenClaw Gateway daemon");
// Primary setup action under the header (no auto-install → setup guide).
await expect(
page.getByTestId("harness-catalog-setup-openclaw"),
).toBeVisible();
// Technical details are visible by default.
const technical = page.getByTestId("harness-catalog-technical-openclaw");
await expect(technical).toBeVisible();
await expect(technical).toContainText("openclaw");
await expect(technical).toContainText("acp");
});
test("ready preset shows in catalog as Ready with no install action", async ({
page,
}) => {
await installMockBridge(page, {
acpRuntimesCatalog: [HERMES_AVAILABLE, OPENCLAW_NOT_INSTALLED],
});
await openHarnessSettings(page);
await openCatalog(page);
// Ready entries live in the "Installed" accordion, collapsed by default.
await expect(
page.getByTestId("harness-catalog-list-item-hermes"),
).toHaveCount(0);
await expect(
page.getByTestId("harness-catalog-section-installed-count"),
).toHaveText("1");
await page.getByTestId("harness-catalog-section-installed").click();
await page.getByTestId("harness-catalog-list-item-hermes").click();
const detail = page.getByTestId("harness-catalog-detail-pane");
await expect(detail).toContainText("Ready");
await expect(
page.getByTestId("harness-catalog-install-hermes"),
).toHaveCount(0);
await expect(page.getByTestId("harness-catalog-setup-hermes")).toHaveCount(
0,
);
});
test("catalog Update for an outdated adapter requires confirmation before installing", async ({
page,
}) => {
// Wes's review blocker: Add runtimes → Setup → Update must honor the
// same machine-wide replacement confirmation the runtime row shows —
// never mutate straight from the catalog CTA.
await installMockBridge(page, {
acpRuntimesCatalog: [
HERMES_AVAILABLE,
{
...OPENCLAW_NOT_INSTALLED,
availability: "adapter_outdated",
binary_path: "/usr/local/bin/openclaw",
can_auto_install: true,
},
],
});
await openHarnessSettings(page);
await openCatalog(page);
const installCalls = () =>
page.evaluate(
() =>
(
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? []
).filter((command) => command === "install_acp_runtime").length,
);
await page.getByTestId("harness-catalog-list-item-openclaw").click();
await expect(
page.getByTestId("harness-catalog-status-openclaw"),
).toHaveText("Update needed");
const updateButton = page.getByTestId("harness-catalog-install-openclaw");
await expect(updateButton).toHaveText("Update");
// Cancel path: clicking Update opens the warning, no mutation fires.
await updateButton.click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toContainText("Update OpenClaw adapter?");
await expect(dialog).toContainText(
"This replaces the machine-wide openclaw adapter.",
);
// Generic runtimes must never get Codex's package copy.
await expect(dialog).not.toContainText("codex-acp");
expect(await installCalls()).toBe(0);
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect(dialog).toHaveCount(0);
expect(await installCalls()).toBe(0);
// Confirm path: exactly one install fires after confirmation.
await updateButton.click();
await page.getByTestId("harness-catalog-confirm-update-openclaw").click();
await expect(page.getByRole("alertdialog")).toHaveCount(0);
await expect.poll(installCalls).toBe(1);
// No duplicate mutation after the flow settles.
expect(await installCalls()).toBe(1);
});
test("catalog search filters the list", async ({ page }) => {
await installMockBridge(page, {
acpRuntimesCatalog: [
HERMES_AVAILABLE,
OPENCLAW_NOT_INSTALLED,
CURSOR_AVAILABLE,
],
});
await openHarnessSettings(page);
await openCatalog(page);
await page.getByTestId("harness-catalog-search").fill("claw");
await expect(
page.getByTestId("harness-catalog-list-item-openclaw"),
).toBeVisible();
await expect(
page.getByTestId("harness-catalog-list-item-hermes"),
).toHaveCount(0);
await expect(
page.getByTestId("harness-catalog-list-item-cursor"),
).toHaveCount(0);
// The custom-harness entry stays available regardless of the filter.
await expect(
page.getByTestId("harness-catalog-list-item-custom"),
).toBeVisible();
});
});
// ── Preset logos in Your harnesses rows ──────────────────────────────────────
test("harness rows render bundled preset logos, not initials", async ({
page,
}) => {
await installMockBridge(page, {
acpRuntimesCatalog: [HERMES_AVAILABLE, CURSOR_AVAILABLE],
});
await openHarnessSettings(page);
// Preset rows must show the same bundled logo the catalog uses
// (PRESET_LOGOS via RuntimeIcon), even though presets emit an empty
// avatar_url (the no-remote-icon security line).
const hermesLogo = page.getByTestId("doctor-runtime-logo-hermes");
await expect(hermesLogo).toBeVisible();
await expect(hermesLogo.locator("img")).toHaveAttribute(
"src",
"/harness-logos/hermes.png",
);
// Cursor renders its inline SVG mark (RUNTIME_MARKS, CC0 simple-icons
// path) — an svg, never an img or initials.
const cursorLogo = page.getByTestId("doctor-runtime-logo-cursor");
await expect(cursorLogo).toBeVisible();
await expect(cursorLogo.locator("svg")).toBeVisible();
await expect(cursorLogo.locator("img")).not.toBeVisible();
await expect(cursorLogo).not.toContainText("C");
});
// ── Custom harness add (via catalog) ─────────────────────────────────────────
test.describe("add custom harness", () => {
test("catalog custom form saves and row appears in Your harnesses", async ({
page,
}) => {
await installMockBridge(page, {
acpRuntimesCatalog: [HERMES_AVAILABLE, OPENCLAW_NOT_INSTALLED],
});
await openHarnessSettings(page);
// No custom rows yet.
await expect(
page.getByTestId("doctor-runtime-my-custom-agent"),
).not.toBeVisible();
await openCatalog(page);
await page.getByTestId("harness-catalog-list-item-custom").click();
await expect(page.getByTestId("custom-harness-form")).toBeVisible();
// All fields render inline.
await expect(page.locator("#ch-label")).toBeVisible();
await expect(page.locator("#ch-command")).toBeVisible();
await expect(page.locator("#ch-id")).toBeVisible();
await fillHarnessForm(page, {
label: "My Custom Agent",
command: "my-custom-acp",
});
// ID auto-derives from the label.
await expect(page.locator("#ch-id")).toHaveValue("my-custom-agent");
await page
.getByTestId("custom-harness-form")
.getByRole("button", { name: "Save", exact: true })
.click();
// Dialog closes; row appears in Your harnesses.
await expect(page.getByTestId("harness-catalog-dialog")).not.toBeVisible();
await expect(
page.getByTestId("doctor-runtime-my-custom-agent"),
).toBeVisible({ timeout: 5_000 });
});
test("edit preserves env vars (definitionEnv round-trip)", async ({
page,
}) => {
await installMockBridge(page, {
acpRuntimesCatalog: [
HERMES_AVAILABLE,
OPENCLAW_NOT_INSTALLED,
makeCustomEntry({ definition_env: { MY_API_KEY: "sk-test" } }),
],
});
await openHarnessSettings(page);
// Open edit form for the existing custom entry via the ••• menu.
await expect(
page.getByTestId("doctor-runtime-my-custom-agent"),
).toBeVisible();
await page.getByTestId("doctor-runtime-menu-my-custom-agent").click();
await page.getByTestId("custom-harness-edit-my-custom-agent").click();
await expect(page.getByTestId("custom-harness-form")).toBeVisible();
// Existing env vars must be pre-populated.
await expect(page.locator('input[placeholder="KEY"]').first()).toHaveValue(
"MY_API_KEY",
);
await expect(
page.locator('input[placeholder="value"]').first(),
).toHaveValue("sk-test");
});
test("same-ID edit replaces row — no duplicate", async ({ page }) => {
await installMockBridge(page, {
acpRuntimesCatalog: [
HERMES_AVAILABLE,
OPENCLAW_NOT_INSTALLED,
makeCustomEntry({ label: "V1 Label" }),
],
});
await openHarnessSettings(page);
// Edit, keep same ID, change label.
await page.getByTestId("doctor-runtime-menu-my-custom-agent").click();
await page.getByTestId("custom-harness-edit-my-custom-agent").click();
await expect(page.getByTestId("custom-harness-form")).toBeVisible();
await page.fill("#ch-label", "V2 Label");
await page
.getByTestId("custom-harness-form")
.getByRole("button", { name: "Save", exact: true })
.click();
// Exactly one row with the same ID; label updated.
const rows = page.locator('[data-testid="doctor-runtime-my-custom-agent"]');
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText("V2 Label");
});
test("rename removes old row and inserts new row", async ({ page }) => {
await installMockBridge(page, {
acpRuntimesCatalog: [
HERMES_AVAILABLE,
OPENCLAW_NOT_INSTALLED,
makeCustomEntry({ id: "old-harness", label: "Old" }),
],
});
await openHarnessSettings(page);
await page.getByTestId("doctor-runtime-menu-old-harness").click();
await page.getByTestId("custom-harness-edit-old-harness").click();
await expect(page.getByTestId("custom-harness-form")).toBeVisible();
await fillHarnessForm(page, {
label: "New Harness",
id: "new-harness",
command: "my-custom-acp",
});
await page
.getByTestId("custom-harness-form")
.getByRole("button", { name: "Save", exact: true })
.click();
// Old row gone; new row present.
await expect(
page.getByTestId("doctor-runtime-old-harness"),
).not.toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId("doctor-runtime-new-harness")).toBeVisible({
timeout: 5_000,
});
});
});
// ── Delete flow ───────────────────────────────────────────────────────────────
test.describe("delete custom harness", () => {
test("delete success removes the row", async ({ page }) => {
await installMockBridge(page, {
acpRuntimesCatalog: [
HERMES_AVAILABLE,
OPENCLAW_NOT_INSTALLED,
makeCustomEntry(),
],
});
await openHarnessSettings(page);
// Enter confirm-delete mode via the ••• menu.
await page.getByTestId("doctor-runtime-menu-my-custom-agent").click();
await page.getByTestId("custom-harness-delete-my-custom-agent").click();
// Blast-radius warning + confirm button must appear.
await expect(
page.getByTestId("custom-harness-delete-warning-my-custom-agent"),
).toBeVisible();
await expect(
page.getByTestId("custom-harness-delete-confirm-my-custom-agent"),
).toBeVisible();
await page
.getByTestId("custom-harness-delete-confirm-my-custom-agent")
.click();
// Row disappears after successful delete.
await expect(
page.getByTestId("doctor-runtime-my-custom-agent"),
).not.toBeVisible({ timeout: 5_000 });
});
test("delete failure shows inline error and keeps the row", async ({
page,
}) => {
await installMockBridge(page, {
acpRuntimesCatalog: [
HERMES_AVAILABLE,
OPENCLAW_NOT_INSTALLED,
makeCustomEntry(),
],
deleteCustomHarnessError: "permission denied: could not remove file",
});
await openHarnessSettings(page);
await page.getByTestId("doctor-runtime-menu-my-custom-agent").click();
await page.getByTestId("custom-harness-delete-my-custom-agent").click();
await page
.getByTestId("custom-harness-delete-confirm-my-custom-agent")
.click();
// Error text visible; row still present.
await expect(
page.getByText("permission denied: could not remove file"),
).toBeVisible({ timeout: 5_000 });
await expect(
page.getByTestId("doctor-runtime-my-custom-agent"),
).toBeVisible();
});
});
// ── Custom harness row readiness ─────────────────────────────────────────────
test("available custom harness row shows a Ready chip", async ({ page }) => {
await installMockBridge(page, {
acpRuntimesCatalog: [
HERMES_AVAILABLE,
OPENCLAW_NOT_INSTALLED,
makeCustomEntry({ availability: "available" }),
],
});
await openHarnessSettings(page);
const row = page.getByTestId("doctor-runtime-my-custom-agent");
await expect(row).toBeVisible();
await expect(
page.getByTestId("doctor-runtime-ready-my-custom-agent"),
).toHaveText("Ready");
});
test("not-ready custom harness row shows status, no install action", async ({
page,
}) => {
await installMockBridge(page, {
acpRuntimesCatalog: [
HERMES_AVAILABLE,
OPENCLAW_NOT_INSTALLED,
makeCustomEntry(),
],
});
await openHarnessSettings(page);
const row = page.getByTestId("doctor-runtime-my-custom-agent");
await expect(row).toBeVisible();
await expect(
page.getByTestId("doctor-runtime-status-my-custom-agent"),
).toHaveText("CLI needed");
// A row that setup can't fix with one click renders no Install button (and
// is not ready).
await expect(
page.getByTestId("doctor-runtime-install-my-custom-agent"),
).toHaveCount(0);
await expect(
page.getByTestId("doctor-runtime-ready-my-custom-agent"),
).toHaveCount(0);
});
// ── F8: onboarding navigate-after-complete ────────────────────────────────────
//
// Verifies the parent-owned route intent introduced in B-8:
// 1. User reaches the machine-onboarding setup page.
// 2. Clicks "More harnesses" (onboarding-setup-more-harnesses).
// 3. App completes onboarding and immediately navigates to Settings → Agents.
//
// This test exercises the real App.tsx effect that gates router.navigate() on
// machine.stage === "ready", which the pure-logic tests in
// postOnboardingNav.test.mjs cannot cover (they simulate the predicate, not
// the real render path).
test("onboarding setup More-harnesses click navigates to Settings → Agents", async ({
page,
}) => {
// Start with a fresh machine (no machine-onboarding-complete flag).
// skipCommunitySeed: true so the user goes through machine onboarding.
// skipOnboardingSeed: true so the community/identity banner doesn't appear.
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
// Seed a community stamped with a *foreign* pubkey. This is the only shape
// that satisfies both preconditions of this test at once:
// - machine onboarding must still run, so the community must NOT vouch for
// the active identity (migrateMachineOnboardingCompletion only accepts a
// community whose recorded pubkey matches — see machineOnboarding.ts:70).
// - after onboarding completes, useCommunityInit must NOT report
// needsSetup, or App.tsx:499 renders WelcomeSetup instead of the router
// and the navigation lands on a screen that has no settings tree.
// The default seed vouches (it uses the active pubkey) and skipping it
// entirely leaves zero communities, so neither default gets there.
await page.addInitScript(() => {
const communityId = "e2e-default-community";
window.localStorage.setItem(
"buzz-communities",
JSON.stringify([
{
id: communityId,
name: "E2E Test",
relayUrl: "ws://127.0.0.1:7777",
pubkey: "f".repeat(64),
addedAt: new Date().toISOString(),
},
]),
);
window.localStorage.setItem("buzz-active-community-id", communityId);
});
await page.goto("/");
// Reach setup by creating a new identity key and continuing past the
// created-key page without opening the optional backup options.
await page.getByRole("button", { name: "Create a new identity key" }).click();
await passThroughBackupStep(page);
// Now on the setup page.
await expect(
page.getByRole("heading", { name: "Set up your agent harnesses" }),
).toBeVisible({ timeout: 10_000 });
// Click the "More harnesses" link — fires navigateToAgentSettings.
await page.getByTestId("onboarding-setup-more-harnesses").click();
// After onboarding completes + router mounts, the app must land on
// Settings → Agents (consolidated harnesses section visible).
await expect(page.getByTestId("settings-harnesses")).toBeVisible({
timeout: 15_000,
});
});
@@ -0,0 +1,207 @@
import { spawn, type ChildProcess } from "node:child_process";
import { createWriteStream } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
export type RelayPorts = { main: number; health: number; metrics: number };
export type RelaySpec = {
name: string;
ports: RelayPorts;
databaseUrl: string;
redisUrl: string;
};
type OwnedProcess = { name: string; child: ChildProcess; logPath: string };
export class TwoRelayHarness {
readonly root: string;
readonly relays: readonly RelaySpec[];
private readonly processes: OwnedProcess[] = [];
get ownedPids(): number[] {
return this.processes.flatMap(({ child }) =>
child.pid ? [child.pid] : [],
);
}
private constructor(root: string, relays: readonly RelaySpec[]) {
this.root = root;
this.relays = relays;
}
static async create(relays: readonly RelaySpec[]) {
return new TwoRelayHarness(
await mkdtemp(join(tmpdir(), "buzz-ae-e2e-")),
relays,
);
}
async startRelays(binary = process.env.BUZZ_E2E_RELAY_BIN) {
if (!binary)
throw new Error("BUZZ_E2E_RELAY_BIN is required for the live gate");
await Promise.all(
this.relays.map((relay) => this.startRelay(binary, relay)),
);
}
/**
* SIGTERM one relay by name and wait for it to exit on its own. Unlike
* `stop()`, this never escalates to SIGKILL: the relay's graceful drain
* (readiness 503 5s grace 1012 close broadcast listener drain) is
* exactly what restart tests exercise, so a forced kill would invalidate
* the run. Throws if the process does not exit within `timeoutMs`.
*/
async terminateRelayGracefully(name: string, timeoutMs = 45_000) {
const owned = [...this.processes]
.reverse()
.find(
(candidate) =>
candidate.name === name &&
candidate.child.exitCode === null &&
candidate.child.signalCode === null,
);
if (!owned) throw new Error(`no live relay process named ${name}`);
const exited = new Promise<void>((resolveExit) =>
owned.child.once("exit", () => resolveExit()),
);
this.signal(owned.child, "SIGTERM");
if (
!(await Promise.race([exited.then(() => true), delay(timeoutMs, false)]))
) {
throw new Error(
`${name} did not exit within ${timeoutMs}ms of SIGTERM — graceful drain is stuck`,
);
}
}
/** Start a fresh process for a relay spec on its original ports. */
async restartRelay(name: string, binary = process.env.BUZZ_E2E_RELAY_BIN) {
if (!binary)
throw new Error("BUZZ_E2E_RELAY_BIN is required for the live gate");
const relay = this.relays.find((candidate) => candidate.name === name);
if (!relay) throw new Error(`no relay spec named ${name}`);
await this.startRelay(binary, relay);
}
async startAcp(
name: string,
relayWsUrl: string,
privateKey: string,
extraEnv: NodeJS.ProcessEnv = {},
) {
const binary = process.env.BUZZ_E2E_ACP_BIN;
if (!binary)
throw new Error("BUZZ_E2E_ACP_BIN is required for the live gate");
return this.spawnOwned(name, binary, [], {
BUZZ_RELAY_URL: relayWsUrl,
BUZZ_PRIVATE_KEY: privateKey,
BUZZ_AUTH_TAG: "",
BUZZ_ACP_LAZY_POOL: "true",
BUZZ_ACP_AGENT_COMMAND: process.execPath,
BUZZ_ACP_AGENT_ARGS: resolve("tests/e2e/fixtures/fake-acp-agent.mjs"),
BUZZ_E2E_CLI_BIN: process.env.BUZZ_E2E_CLI_BIN,
...extraEnv,
});
}
async logs(): Promise<string> {
const chunks = await Promise.all(
this.processes.map(async ({ name, logPath }) => {
const body = await readFile(logPath, "utf8").catch(() => "<no log>");
return `===== ${name} =====\n${body}`;
}),
);
return chunks.join("\n");
}
private signal(child: ChildProcess, signal: NodeJS.Signals) {
if (child.exitCode !== null || child.signalCode !== null || !child.pid)
return;
if (process.platform === "win32") {
child.kill(signal);
return;
}
try {
process.kill(-child.pid, signal);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ESRCH") throw error;
}
}
private async stopChild(child: ChildProcess) {
if (child.exitCode !== null || child.signalCode !== null) return;
const exited = new Promise<void>((resolveExit) =>
child.once("exit", () => resolveExit()),
);
this.signal(child, "SIGTERM");
if (await Promise.race([exited.then(() => true), delay(5_000, false)]))
return;
this.signal(child, "SIGKILL");
if (!(await Promise.race([exited.then(() => true), delay(2_000, false)]))) {
throw new Error(`child process ${child.pid ?? "unknown"} did not exit`);
}
}
async stop() {
await Promise.all(
[...this.processes].reverse().map(({ child }) => this.stopChild(child)),
);
await rm(this.root, { recursive: true, force: true });
}
private async startRelay(binary: string, relay: RelaySpec) {
const child = this.spawnOwned(relay.name, binary, [], {
DATABASE_URL: relay.databaseUrl,
REDIS_URL: relay.redisUrl,
RELAY_URL: `ws://127.0.0.1:${relay.ports.main}`,
BUZZ_BIND_ADDR: `127.0.0.1:${relay.ports.main}`,
BUZZ_HEALTH_PORT: String(relay.ports.health),
BUZZ_METRICS_PORT: String(relay.ports.metrics),
BUZZ_REQUIRE_AUTH_TOKEN: "false",
BUZZ_RECONCILE_CHANNELS: "true",
BUZZ_AUTO_MIGRATE: "true",
});
await this.waitForHealth(relay, child);
}
private spawnOwned(
name: string,
command: string,
args: string[],
env: NodeJS.ProcessEnv,
) {
const logPath = join(this.root, `${name}.log`);
const child = spawn(command, args, {
cwd: resolve(".."),
env: { ...process.env, ...env, RUST_LOG: process.env.RUST_LOG ?? "info" },
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
});
const log = createWriteStream(logPath, { flags: "a" });
child.stdout?.pipe(log, { end: false });
child.stderr?.pipe(log, { end: false });
child.on("exit", () => log.end());
this.processes.push({ name, child, logPath });
return child;
}
private async waitForHealth(relay: RelaySpec, child: ChildProcess) {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(`${relay.name} exited before readiness`);
}
try {
const response = await fetch(
`http://127.0.0.1:${relay.ports.health}/_readiness`,
);
if (response.ok) return;
} catch {}
await delay(100);
}
throw new Error(`${relay.name} was not ready within 30s`);
}
}
@@ -0,0 +1,100 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
test.describe("home inbox chrome", () => {
test.use({ viewport: { width: 1280, height: 720 } });
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
});
test("puts the filter on the left and options on the right", async ({
page,
}) => {
const filter = page.getByTestId("inbox-filter-trigger");
const options = page.getByTestId("inbox-options-trigger");
await expect(filter).toBeVisible();
await expect(filter).toContainText("All");
await expect(options).toBeVisible();
const [filterBox, optionsBox] = await Promise.all([
filter.boundingBox(),
options.boundingBox(),
]);
expect(filterBox).not.toBeNull();
expect(optionsBox).not.toBeNull();
expect(filterBox?.x ?? 0).toBeLessThan(optionsBox?.x ?? 0);
});
test("shares a blurred header backdrop across list and detail", async ({
page,
}) => {
await expect(page.getByTestId("home-inbox-detail")).toBeVisible();
const homeInbox = page.getByTestId("home-inbox");
const listScroller = page.getByTestId("home-inbox-list");
const detailScroller = page.getByTestId("home-inbox-detail-scroll");
const sharedBackdrop = page.getByTestId(
"home-inbox-shared-header-backdrop",
);
const [homeBox, backdropBox, listBox, detailBox, backdropFilter] =
await Promise.all([
homeInbox.boundingBox(),
sharedBackdrop.boundingBox(),
listScroller.boundingBox(),
detailScroller.boundingBox(),
sharedBackdrop.evaluate(
(element) => getComputedStyle(element).backdropFilter,
),
]);
expect(homeBox).not.toBeNull();
expect(backdropBox).not.toBeNull();
expect(listBox).not.toBeNull();
expect(detailBox).not.toBeNull();
expect(Math.round(backdropBox?.x ?? 0)).toBe(Math.round(homeBox?.x ?? 0));
expect(Math.round(backdropBox?.width ?? 0)).toBe(
Math.round(homeBox?.width ?? 0),
);
expect(Math.round(listBox?.y ?? 0)).toBe(Math.round(backdropBox?.y ?? 0));
expect(Math.round(detailBox?.y ?? 0)).toBe(Math.round(backdropBox?.y ?? 0));
expect(backdropFilter).not.toBe("none");
await waitForAnimations(page);
});
test("reserves the measured composer height and masks content behind it", async ({
page,
}) => {
const detailScroller = page.getByTestId("home-inbox-detail-scroll");
const composerOverlay = page.getByTestId(
"home-inbox-detail-composer-overlay",
);
await expect(composerOverlay).toBeVisible();
await expect
.poll(async () => {
const [paddingBottom, overlayBox] = await Promise.all([
detailScroller.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).paddingBottom),
),
composerOverlay.boundingBox(),
]);
return Math.abs(paddingBottom - (overlayBox?.height ?? 0));
})
.toBeLessThanOrEqual(1);
const overlayMaskStyles = await composerOverlay.evaluate((element) => ({
gradient: getComputedStyle(element, "::before").backgroundImage,
solidBackground: getComputedStyle(element, "::after").backgroundColor,
}));
expect(overlayMaskStyles.gradient).not.toBe("none");
expect(overlayMaskStyles.solidBackground).not.toBe("rgba(0, 0, 0, 0)");
});
});
@@ -0,0 +1,96 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
const OUTDIR = "test-results/hosted-communities";
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
test.beforeEach(async ({ page }) => {
await installMockBridge(page, {
builderlabAuth: {
email: "owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: { pubkey_hex: DEFAULT_MOCK_PUBKEY },
builderlabCommunities: [
{
id: "active-community",
name: "E2E Test",
normalized_host: "localhost:3000",
},
{
id: "other-community",
name: "Design studio",
normalized_host: "design-studio.communities.buzz.xyz",
},
],
});
await page.goto("/");
await openSettings(page, "hosted-communities");
});
test("capture: community icon picker sits beside its hosted community", async ({
page,
}) => {
const activeRow = page
.getByTestId("hosted-community-row")
.filter({ hasText: "E2E Test" });
const otherRow = page
.getByTestId("hosted-community-row")
.filter({ hasText: "Design studio" });
await expect(activeRow.getByTestId("community-icon-settings")).toBeVisible();
await expect(otherRow.getByTestId("community-icon-settings")).toHaveCount(0);
const iconDataUrl = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="128" height="128" rx="28" fill="#ff56c3"/><text x="64" y="80" text-anchor="middle" font-size="48">😅</text></svg>',
)}`;
await activeRow.getByLabel("Add community icon").click();
const picker = page.getByRole("group", { name: "Community icon picker" });
await expect(picker).toBeVisible();
await expect(page.getByRole("tab", { name: "Image" })).toBeVisible();
await expect(page.getByRole("tab", { name: "Emoji" })).toBeVisible();
await page.getByPlaceholder("Paste a URL").fill(iconDataUrl);
await page.getByRole("button", { name: "Apply" }).click();
const icon = activeRow.getByRole("img", { name: /community icon$/i });
await expect(icon).toBeVisible();
const maskImage = await activeRow
.getByTestId("community-icon-mask")
.evaluate((element) => getComputedStyle(element).webkitMaskImage);
expect(maskImage).toContain("radial-gradient");
await expect(page.getByTestId("community-icon-save")).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() =>
window.__BUZZ_E2E_SIGNED_EVENTS__?.some(
(event) =>
event.kind === 9033 &&
event.tags.some(
(tag) => tag[0] === "icon" && tag[1]?.startsWith("data:image/"),
),
),
),
)
.toBe(true);
const iconBox = await activeRow
.getByTestId("community-icon-settings")
.boundingBox();
const nameBox = await activeRow
.getByText("E2E Test", { exact: true })
.boundingBox();
expect(iconBox).not.toBeNull();
expect(nameBox).not.toBeNull();
expect(iconBox?.x ?? Number.POSITIVE_INFINITY).toBeLessThan(
nameBox?.x ?? Number.NEGATIVE_INFINITY,
);
await waitForAnimations(page);
await activeRow.screenshot({
path: `${OUTDIR}/01-community-icon-row.png`,
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,288 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// Fixed pubkey for the owned managed agent seeded in these tests.
// Must not collide with any existing e2eBridge constant.
const OWNED_AGENT_PUBKEY =
"a0b1c2d3e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e";
// #random is owned by alice; the mock identity is a plain member.
// This is the isolation fixture for the canManageOwnedAgentChannel path —
// selfMember.role is "member" not "owner", so without the new gate the
// Edit button would not appear.
const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d";
// Mock-bridge helper: wait for the bridge to initialise, then invoke a command.
async function invoke(
page: import("@playwright/test").Page,
command: string,
payload?: Record<string, unknown>,
): Promise<unknown> {
await page.waitForFunction(() =>
Boolean(
(
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__,
),
);
return page.evaluate(
async ({ cmd, p }) => {
const win = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
const fn = win.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!fn) throw new Error("Mock bridge unavailable");
return fn(cmd, p);
},
{ cmd: command, p: payload },
);
}
// Open the more-actions menu for a message row and wait for the menu to mount.
async function openMoreActionsMenu(
page: import("@playwright/test").Page,
messageId: string,
) {
const row = page.locator(`[data-message-id="${messageId}"]`);
await row.hover();
await page.getByTestId(`more-actions-${messageId}`).click();
// Wait for dropdown content to mount — any menu item signals it's open.
await expect(page.locator('[role="menuitem"]').first()).toBeVisible({
timeout: 5_000,
});
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
// OwnedBot: a managed agent owned by the mock identity.
// The bridge automatically sets owner_pubkey = MOCK_IDENTITY_PUBKEY
// in mockProfiles when a managed agent is seeded.
pubkey: OWNED_AGENT_PUBKEY,
name: "OwnedBot",
personaId: "builtin:fizz",
status: "running",
// Seed into #agents so the bridge seeds a message from this agent.
channelNames: ["agents"],
},
],
});
});
// ─── Message gate ─────────────────────────────────────────────────────────────
test("owner can edit their owned agent's message", async ({ page }) => {
// The bridge seeds a message from each managed agent in its channels:
// id: `mock-agents-managed-${pubkey.slice(0, 8)}`
const messageId = `mock-agents-managed-${OWNED_AGENT_PUBKEY.slice(0, 8)}`;
const editedContent = `Edited by owner ${Date.now()}`;
await page.goto("/");
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
// Wait for the agent's seeded message to appear.
const agentRow = page.locator(`[data-message-id="${messageId}"]`);
await expect(agentRow).toBeVisible({ timeout: 10_000 });
// Open the more-actions menu and click Edit message.
await openMoreActionsMenu(page, messageId);
await page.getByTestId(`edit-message-${messageId}`).click();
// Edit banner must appear confirming edit mode is active.
await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 });
// Wait for the editor to be populated with the original message content
// (edit mode calls richText.setContent which is async in Tiptap's
// transaction pipeline). Then select-all and type the replacement.
const input = page.getByTestId("message-input");
await expect(input).not.toBeEmpty({ timeout: 5_000 });
await input.click();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.type(editedContent);
await page.keyboard.press("Enter");
// Edit mode must exit (banner gone) and the updated content must render.
await expect(page.getByTestId("edit-target")).toBeHidden();
await expect(page.getByTestId("message-timeline")).toContainText(
editedContent,
);
});
test("owner can delete their owned agent's message", async ({ page }) => {
const messageId = `mock-agents-managed-${OWNED_AGENT_PUBKEY.slice(0, 8)}`;
await page.goto("/");
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
const agentRow = page.locator(`[data-message-id="${messageId}"]`);
await expect(agentRow).toBeVisible({ timeout: 10_000 });
// Open the more-actions menu and click Delete message.
await openMoreActionsMenu(page, messageId);
await page.getByTestId(`delete-message-${messageId}`).click();
// Confirm the deletion in the AlertDialog.
await expect(page.getByRole("alertdialog")).toBeVisible({ timeout: 5_000 });
// The destructive confirm button inside the dialog.
await page
.getByRole("alertdialog")
.getByRole("button", { name: "Delete" })
.click();
// The message row must be removed from the timeline.
await expect(agentRow).toBeHidden({ timeout: 5_000 });
});
test("owner does NOT see Edit or Delete for an unowned agent's message", async ({
page,
}) => {
// "mock-agents-charlie" is seeded in #agents for CHARLIE_PUBKEY.
// Charlie is in mockAgentPubkeys but ownerPubkey is NOT the mock identity.
const charlieMessageId = "mock-agents-charlie";
await page.goto("/");
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
const charlieRow = page.locator(`[data-message-id="${charlieMessageId}"]`);
await expect(charlieRow).toBeVisible({ timeout: 10_000 });
// Open the more-actions menu — it must open without Edit or Delete items.
await charlieRow.hover();
await page.getByTestId(`more-actions-${charlieMessageId}`).click();
await expect(page.locator('[role="menuitem"]').first()).toBeVisible({
timeout: 5_000,
});
await expect(
page.getByTestId(`edit-message-${charlieMessageId}`),
).toHaveCount(0);
await expect(
page.getByTestId(`delete-message-${charlieMessageId}`),
).toHaveCount(0);
});
// ─── Thread-panel gate ────────────────────────────────────────────────────────
test("owner can delete their owned agent's message from the thread panel", async ({
page,
}) => {
// The bridge seeds a message from each managed agent in its channels:
// id: `mock-agents-managed-${pubkey.slice(0, 8)}`
const messageId = `mock-agents-managed-${OWNED_AGENT_PUBKEY.slice(0, 8)}`;
await page.goto("/");
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
// Wait for the agent's seeded message to appear in the main timeline.
const agentRow = page.locator(`[data-message-id="${messageId}"]`).first();
await expect(agentRow).toBeVisible({ timeout: 10_000 });
// Open the thread panel by hovering the message and clicking Reply.
await agentRow.hover();
await agentRow.getByRole("button", { name: "Reply" }).click();
// Wait for the thread panel and confirm the thread head contains the agent message.
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible({ timeout: 5_000 });
const threadHead = threadPanel.getByTestId("message-thread-head");
await expect(
threadHead.locator(`[data-message-id="${messageId}"]`),
).toBeVisible({
timeout: 5_000,
});
// Open the more-actions menu for the thread head from inside the thread panel.
const headRow = threadHead.locator(`[data-message-id="${messageId}"]`);
await headRow.hover();
await threadHead.getByTestId(`more-actions-${messageId}`).click();
// Wait for the dropdown to mount.
await expect(page.locator('[role="menuitem"]').first()).toBeVisible({
timeout: 5_000,
});
// Click Delete.
// Radix dropdown portals render outside the thread-panel DOM boundary, so we
// query from the full page. Only one menu is open, making the testId unique.
await page.getByTestId(`delete-message-${messageId}`).click();
// The AlertDialog must appear. This proves canManageMessageForCurrentUser is
// wired into MessageThreadPanel.tsx — the delete handler is reachable from
// the thread-panel path (i.e. the thread-panel permission gate is not
// accidentally more restrictive than the main-timeline gate). The full
// delete-and-cache-invalidation path is already covered by the main-timeline
// delete test above; asserting the dialog here is sufficient as a thread-panel
// regression guard.
await expect(page.getByRole("alertdialog")).toBeVisible({ timeout: 5_000 });
// Dismiss without confirming — leaves the fixture clean for any subsequent steps.
await page.keyboard.press("Escape");
await expect(page.getByRole("alertdialog")).not.toBeVisible({
timeout: 3_000,
});
});
// ─── Channel management gate ──────────────────────────────────────────────────
test("owner can edit channel name via owned-agent-owner path", async ({
page,
}) => {
const newChannelName = `renamed-${Date.now()}`;
await page.goto("/");
// Add OwnedBot as an owner-role member of #random.
// In #random the mock identity is only a plain member — selfMember.role !== "owner".
// This isolates canManageOwnedAgentChannel as the sole reason Edit appears.
await invoke(page, "add_channel_members", {
channelId: RANDOM_CHANNEL_ID,
pubkeys: [OWNED_AGENT_PUBKEY],
role: "owner",
});
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
// Edit quick-action must be visible: canManageOwnedAgentChannel is true.
const editButton = page.getByTestId("channel-management-edit");
await expect(editButton).toBeVisible({ timeout: 5_000 });
await editButton.click();
// Change the channel name and save.
const nameInput = page.getByTestId("channel-management-name");
await expect(nameInput).toBeVisible({ timeout: 5_000 });
await nameInput.clear();
await nameInput.fill(newChannelName);
await page.getByTestId("channel-management-save-changes").click();
// The edit dialog must close and the updated name must appear in the header.
await expect(page.getByTestId("channel-management-name")).toBeHidden();
await expect(page.getByTestId("chat-title")).toHaveText(newChannelName);
});
test("owner does NOT see channel Edit button when no owned agent is a channel owner", async ({
page,
}) => {
await page.goto("/");
// #random has alice as owner; mock identity is a plain member.
// OwnedBot is NOT added as owner in this test — Edit must not appear.
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await expect(page.getByTestId("channel-management-edit")).toBeHidden();
});
@@ -0,0 +1,203 @@
import { expect, test } from "@playwright/test";
import { installMockBridge, openNewMessagePage } from "../helpers/bridge";
// Guards the NIP-IA discovery-suppression contract (Dawn's table v2):
// - Members sidebar: archived members fold under "Archived (N)", not in the
// active people/bots lists.
// - Mention autocomplete: archived members are filtered out of suggestions
// (but their `members` entry still resolves historical @-mentions).
// - DM picker: archived users are omitted from search results.
// - History invariant: an archived user's existing channel message renders
// normally (Tyler's "never hide messages").
const ALICE_PUBKEY =
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
const BOB_PUBKEY =
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260";
// Active mock identity (matches DEFAULT_MOCK_IDENTITY.pubkey in e2eBridge).
// Used to exercise the self-exemption rule in the predicate.
const SELF_PUBKEY = "deadbeef".repeat(8);
test.describe("NIP-IA hide archived from discovery", () => {
test("members sidebar: archived member folds under Archived section", async ({
page,
}) => {
await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] });
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
// Active People list should NOT include Alice.
const peopleList = page.getByTestId("members-sidebar-people");
await expect(peopleList).not.toContainText("alice");
// Folded Archived section is visible with count = 1.
await expect(page.getByTestId("members-sidebar-archived")).toBeVisible();
await expect(page.getByTestId("members-sidebar-archived-count")).toHaveText(
"(1)",
);
// Expanding it reveals Alice.
await page.getByTestId("members-sidebar-archived").click();
await expect(
page.getByTestId("members-sidebar-archived-list"),
).toContainText("alice");
});
test("members sidebar: no Archived section when no archived members", async ({
page,
}) => {
await installMockBridge(page, { archivedIdentities: [] });
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
await expect(page.getByTestId("members-sidebar-archived")).toHaveCount(0);
});
test("mention autocomplete: archived member is filtered out", async ({
page,
}) => {
await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] });
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Type `@a` to trigger autocomplete. Without filtering Alice would match.
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("@a");
// Alice's suggestion testid does NOT appear.
await expect(
page.getByTestId(`mention-suggestion-${ALICE_PUBKEY}`),
).toHaveCount(0);
// Sanity: Bob (also `@b...` candidate) is available when his query starts;
// proves the autocomplete itself is functional, not just always-empty.
await input.fill("@b");
await expect(
page.getByTestId(`mention-suggestion-${BOB_PUBKEY}`),
).toBeVisible();
});
test("DM picker: archived user is omitted from search results", async ({
page,
}) => {
await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] });
await page.goto("/");
await openNewMessagePage(page);
await expect(page.getByTestId("new-message-page")).toBeVisible();
await page.getByTestId("new-dm-search").fill("alice");
// Alice's result row does NOT appear, even though search would normally
// surface her by display name.
await expect(page.getByTestId(`new-dm-result-${ALICE_PUBKEY}`)).toHaveCount(
0,
);
// Sanity: Bob is still searchable, confirming the dialog works.
await page.getByTestId("new-dm-search").fill("bob");
await expect(page.getByTestId(`new-dm-result-${BOB_PUBKEY}`)).toBeVisible();
});
test("history invariant: archived user's existing message still renders with their name", async ({
page,
}) => {
// Alice has a seeded message in #general from the prior PR's e2e setup
// (see e2eBridge.ts seed). Archiving her must not remove that message.
await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] });
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Alice's seed message renders with her display name in the timeline.
const aliceMessage = page.getByTestId("message-row").nth(1);
await expect(aliceMessage).toContainText("alice");
await expect(aliceMessage).toContainText("Hey team — checking in.");
});
test("self-exemption: archived current user still appears in their own People list (not folded)", async ({
page,
}) => {
// Anti-shadowban property: the current user is never hidden from their
// own client even when archived on the relay. The predicate's
// self-exemption is what enforces this — without it, the user would lose
// their own seat in the members sidebar.
await installMockBridge(page, { archivedIdentities: [SELF_PUBKEY] });
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
// Self appears in active People list — NOT folded into Archived.
const peopleList = page.getByTestId("members-sidebar-people");
await expect(peopleList).toContainText("You");
await expect(
peopleList.getByTestId(`sidebar-member-${SELF_PUBKEY}`),
).toBeVisible();
// No Archived section at all when self is the only archived pubkey in
// the channel (self-exemption makes archived.length === 0).
await expect(page.getByTestId("members-sidebar-archived")).toHaveCount(0);
});
test("self-exemption: archived current user can still self-mention in own autocomplete", async ({
page,
}) => {
// Self-mention is a no-op in practice, but the predicate must not filter
// self from their own autocomplete — that would be the shadowban NIP-IA
// exists to prevent.
await installMockBridge(page, { archivedIdentities: [SELF_PUBKEY] });
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("@npub");
// Self's suggestion appears in autocomplete despite being in the archived
// set — the predicate's self-exemption fires.
await expect(
page.getByTestId(`mention-suggestion-${SELF_PUBKEY}`),
).toBeVisible();
});
// Member-adder (ChannelMemberInviteCard) — the invite search excludes
// existing channel members, so we test in #agents where Bob is NOT a
// member (only the mock identity + Charlie are): the ONLY thing that can
// drop Bob from results is the archive filter. The control run (nothing
// archived) proves he would otherwise appear — guarding a vacuous green.
test("member-adder: archived non-member is omitted from invite search", async ({
page,
}) => {
await installMockBridge(page, { archivedIdentities: [BOB_PUBKEY] });
await page.goto("/");
await page.getByTestId("channel-agents").click();
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
await page.getByTestId("channel-management-search-users").fill("bob");
await expect(
page.getByTestId(`channel-user-search-result-${BOB_PUBKEY}`),
).toHaveCount(0);
});
test("member-adder: control — non-archived non-member IS in invite search", async ({
page,
}) => {
// Same channel/query, nothing archived: Bob now appears. This is the
// companion that makes the test above non-vacuous (proves he was dropped
// by the archive filter, not by member-exclusion or a broken search).
await installMockBridge(page, { archivedIdentities: [] });
await page.goto("/");
await page.getByTestId("channel-agents").click();
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
await page.getByTestId("channel-management-search-users").fill("bob");
await expect(
page.getByTestId(`channel-user-search-result-${BOB_PUBKEY}`),
).toBeVisible();
});
});
+140
View File
@@ -0,0 +1,140 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// NIP-IA archive button + "Archived" flair gate matrix.
//
// Guards the composition `canArchive = isSelf || isRelayAdminOrOwner ||
// isOaOwnerOfViewee` in UserProfilePanel.tsx. Unit tests cover each input in
// isolation; this spec covers the OR composition where silent regressions
// (refactor turns OR into AND, role expansion bypasses a branch, etc.) would
// otherwise slip past code review.
const ALICE_PUBKEY =
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
async function openSelfProfile(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// First seed message in #general is from the active identity.
const firstMessage = page.getByTestId("message-row").first();
await firstMessage.locator("button", { hasText: "npub1mock..." }).click();
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
}
async function openAliceProfile(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Second seed message in #general is from Alice. Her display name "alice"
// is registered in mockDisplayNames, so the author button text is "alice".
const aliceMessage = page.getByTestId("message-row").nth(1);
await aliceMessage.locator("button", { hasText: "alice" }).first().click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible();
await expect(panel).toContainText(ALICE_PUBKEY.slice(0, 8));
}
async function openProfileSettingsMenu(page: import("@playwright/test").Page) {
const trigger = page.getByTestId("user-profile-settings-menu-trigger");
await expect(trigger).toBeVisible();
await trigger.click();
}
test.describe("NIP-IA archive button gate", () => {
test("case 1 — self viewer + self target: Archive visible, no flair", async ({
page,
}) => {
await installMockBridge(page, { relayRole: null, oaOwnerIsMe: false });
await openSelfProfile(page);
await openProfileSettingsMenu(page);
const archiveButton = page.getByTestId("user-profile-archive-identity");
await expect(archiveButton).toBeVisible();
await expect(page.getByTestId("user-profile-archived-flair")).toHaveCount(
0,
);
// Archive is now gated behind a confirmation modal — clicking the button
// opens the dialog rather than firing immediately. Drive the full flow so
// the gate stays meaningful: the modal must surface, then confirm fires.
await expect(page.getByTestId("archive-confirm-dialog")).toHaveCount(0);
await archiveButton.click();
await expect(page.getByTestId("archive-confirm-dialog")).toBeVisible();
const confirm = page.getByTestId("archive-confirm-action");
await expect(confirm).toBeVisible();
await confirm.click();
await expect(page.getByTestId("archive-confirm-dialog")).toHaveCount(0);
});
test("case 2 — relay admin viewing Alice: Archive visible", async ({
page,
}) => {
await installMockBridge(page, {
relayRole: "admin",
relayRequiresMembership: true,
oaOwnerIsMe: false,
archivedIdentities: [],
});
await openAliceProfile(page);
await openProfileSettingsMenu(page);
await expect(
page.getByTestId("user-profile-archive-identity"),
).toBeVisible();
});
test("case 3 — verified OA owner viewing Alice: Archive visible", async ({
page,
}) => {
await installMockBridge(page, {
relayRole: null,
oaOwnerIsMe: true,
archivedIdentities: [],
});
await openAliceProfile(page);
await openProfileSettingsMenu(page);
await expect(
page.getByTestId("user-profile-archive-identity"),
).toBeVisible();
});
test("case 4 — no authority viewing Alice: Archive hidden", async ({
page,
}) => {
await installMockBridge(page, {
relayRole: null,
oaOwnerIsMe: false,
archivedIdentities: [],
});
await openAliceProfile(page);
await expect(
page.getByTestId("user-profile-settings-menu-trigger"),
).toHaveCount(0);
await expect(page.getByTestId("user-profile-archive-identity")).toHaveCount(
0,
);
await expect(
page.getByTestId("user-profile-unarchive-identity"),
).toHaveCount(0);
});
test("case 5 — Alice archived: flair + Unarchive button (under admin gate)", async ({
page,
}) => {
await installMockBridge(page, {
relayRole: "admin",
relayRequiresMembership: true,
oaOwnerIsMe: false,
archivedIdentities: [ALICE_PUBKEY],
});
await openAliceProfile(page);
await expect(page.getByTestId("user-profile-archived-flair")).toBeVisible();
await openProfileSettingsMenu(page);
await expect(
page.getByTestId("user-profile-unarchive-identity"),
).toBeVisible();
await expect(page.getByTestId("user-profile-archive-identity")).toHaveCount(
0,
);
});
});
@@ -0,0 +1,93 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const HELP_SEEN_KEY = "buzz.machine-onboarding.identity-key-help-seen.v1";
test("identity key help explains the first-run choice", async ({ page }) => {
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
const trigger = page.getByTestId("identity-key-help-trigger");
// No initial opacity-0 assertion: on a slow runner the 2s reveal timer can
// fire before the first assertion runs, failing the test for the wrong
// reason. The reveal + persistence assertions below carry the coverage.
await expect(trigger).toHaveCSS("opacity", "1", { timeout: 5000 });
await expect
.poll(() =>
page.evaluate((key) => localStorage.getItem(key), HELP_SEEN_KEY),
)
.toBe("true");
await page.setViewportSize({ width: 720, height: 620 });
await trigger.click();
const dialog = page.getByTestId("identity-key-help-dialog");
await expect(dialog).toBeVisible();
await waitForAnimations(page);
await expect(
dialog.getByRole("heading", { name: "Whats an identity key?" }),
).toBeVisible();
await expect(dialog).toHaveClass(/shadow-none/);
await expect(page.getByTestId("dialog-overlay")).toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
const dialogWrapper = dialog.locator("..");
await expect(dialogWrapper).toHaveCSS("overflow-x", "hidden");
const dialogBounds = await dialog.boundingBox();
expect(dialogBounds).not.toBeNull();
expect(dialogBounds?.x).toBeGreaterThanOrEqual(0);
expect(
(dialogBounds?.x ?? 0) + (dialogBounds?.width ?? 0),
).toBeLessThanOrEqual(720);
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
await expect(trigger).toHaveCSS("opacity", "1");
await page.reload();
await expect(page.getByTestId("identity-key-help-trigger")).toHaveCSS(
"opacity",
"1",
);
});
test("identity key help stays readable when the app resolves dark mode", async ({
page,
}) => {
await page.emulateMedia({ colorScheme: "dark" });
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
// Fresh profiles follow the system scheme, so the emulated dark scheme is
// the first-run repro: the app resolves the dark theme while onboarding.
await expect
.poll(() =>
page.evaluate(() => document.documentElement.classList.contains("dark")),
)
.toBe(true);
const trigger = page.getByTestId("identity-key-help-trigger");
await expect(trigger).toHaveCSS("opacity", "1", { timeout: 5000 });
await trigger.click();
const dialog = page.getByTestId("identity-key-help-dialog");
await expect(dialog).toBeVisible();
await waitForAnimations(page);
// The textured powder card is baked light in both themes, so the dialog pins
// the neutral onboarding theme to its light variant. Without the pin the
// dark theme flips --foreground to near-white and the title disappears
// against the white card.
await expect(
dialog.getByRole("heading", { name: "Whats an identity key?" }),
).toHaveCSS("color", "rgb(23, 23, 23)");
});
+493
View File
@@ -0,0 +1,493 @@
import { hexToBytes } from "@noble/hashes/utils.js";
import { expect, test } from "@playwright/test";
import { nsecEncode } from "nostr-tools/nip19";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
test("normal first launch uses the already-persisted identity", async ({
page,
}) => {
await page.emulateMedia({ colorScheme: "dark" });
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
const gate = page.getByTestId("machine-onboarding-gate");
await expect(gate).toBeVisible();
await expect(gate).toHaveCSS("background-color", "rgb(215, 215, 46)");
// Landing carries a subtle dot-grid pattern over the chartreuse fill.
await expect(gate).toHaveCSS("background-image", /radial-gradient/);
await expect(gate).toHaveCSS("color", "rgb(23, 23, 23)");
await expect(
page.getByRole("button", { name: "Create a new identity key" }),
).toHaveCSS("background-color", "rgb(23, 23, 23)");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await expect(
page.getByRole("heading", {
name: "Your unique identity key has been created",
}),
).toBeVisible();
// Non-landing pages layer the dot grid over the chartreuse→light-blue gradient.
await expect(gate).toHaveCSS(
"background-image",
/radial-gradient\(.*\), linear-gradient\(.*rgb\(215, 215, 46\).*rgb\(215, 231, 246\)\)/s,
);
await expect(gate).toHaveCSS("color", "rgb(23, 23, 23)");
const commands = await page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string }>;
}
).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [],
);
expect(commands.some((entry) => entry.command === "get_identity")).toBe(true);
expect(
commands.some((entry) => entry.command === "persist_current_identity"),
).toBe(false);
});
test("lost boot opens onboarding gate directly on the key-import page", async ({
page,
}, testInfo) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
await page.waitForTimeout(1_000);
await page.screenshot({
path: testInfo.outputPath("desktop-private-key-recovery.png"),
});
});
test("lost boot keeps the pairing-code action stable while generating", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true, pairingStartDelayMs: 2_500 },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("nostr-import-phone-link").click();
const copyButton = page.getByTestId("copy-identity-recovery-code");
await expect(copyButton).toBeVisible();
await expect(copyButton).toBeDisabled();
await expect(copyButton).toHaveText("Generating pairing code...");
const loadingButton = await copyButton.elementHandle();
await expect(copyButton).toBeEnabled();
await expect(copyButton).toHaveText("Copy pairing code");
expect(
await copyButton.evaluate(
(button, loading) => button === loading,
loadingButton,
),
).toBe(true);
});
test("lost boot offers phone recovery with a single-use QR", async ({
page,
}, testInfo) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("nostr-import-phone-link").click();
await expect(page.getByTestId("identity-recovery-pairing")).toBeVisible();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
await expect(
page.getByText("Scan this code with a signed-in Buzz phone."),
).toBeVisible();
await expect(
page.getByText("On your phone, open Settings → Send identity to desktop."),
).toBeVisible();
await page.waitForTimeout(1_000); // Let the onboarding entrance motion settle.
await page.screenshot({
path: testInfo.outputPath("desktop-phone-recovery-qr.png"),
fullPage: true,
});
const copyButton = page.getByTestId("copy-identity-recovery-code");
await expect(copyButton).toHaveText("Copy pairing code");
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await copyButton.click();
await expect(copyButton).toHaveText("Copied");
const copiedPayload = await page.evaluate(() => {
const log = (
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: Record<string, unknown> | null;
}>;
}
).__BUZZ_E2E_COMMAND_LOG__;
return log?.findLast(({ command }) => command === "copy_text_to_clipboard")
?.payload;
});
expect(copiedPayload?.text).toMatch(/^nostrpair:\/\/.+&mode=recover$/);
const commands = await page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string }>;
}
).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [],
);
expect(
commands.some(
(entry) => entry.command === "start_identity_recovery_pairing",
),
).toBe(true);
});
test("phone recovery uses the desktop pairing card semantics", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("nostr-import-phone-link").click();
const card = page.getByTestId("identity-recovery-pairing");
const qrContainer = card.getByTestId("identity-recovery-qr-container");
const qrCode = card.getByTestId("identity-recovery-qr");
const copyButton = card.getByTestId("copy-identity-recovery-code");
await expect(qrCode).toBeVisible();
await expect(qrCode).toHaveAttribute("data-qr-matrix-size", "57");
await expect(qrCode.locator("[data-qr-finder-pattern]")).toHaveCount(3);
await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS(
"animation-name",
"buzz-qr-cell-reveal",
);
const qrBox = await qrContainer.boundingBox();
const copyBox = await copyButton.boundingBox();
expect(qrBox).not.toBeNull();
expect(copyBox).not.toBeNull();
expect(Math.abs((copyBox?.x ?? 0) - (qrBox?.x ?? 0))).toBeLessThan(1);
expect(Math.abs((copyBox?.width ?? 0) - (qrBox?.width ?? 0))).toBeLessThan(1);
await page.evaluate(async () => {
await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", {
event: "pairing-sas-received",
payload: { sas: "123456" },
});
});
await expect(
card.getByText("Does this code match your phone?"),
).toBeVisible();
await expect(
page.getByText("Confirm the code before sharing your identity."),
).toBeVisible();
await expect(
card.getByText(
"This gives this desktop permanent access to your Buzz identity. Only continue if you trust it.",
),
).toBeVisible();
await expect(
card.getByText(/On your phone, open Settings/),
).not.toBeVisible();
await expect(card.getByTestId("identity-recovery-sas")).toHaveText("123 456");
await expect(card.getByTestId("confirm-identity-recovery-sas")).toHaveText(
"Codes match",
);
await expect(card.getByTestId("deny-identity-recovery-sas")).toHaveText(
"Cancel",
);
const cancelBox = await card
.getByTestId("deny-identity-recovery-sas")
.boundingBox();
const confirmBox = await card
.getByTestId("confirm-identity-recovery-sas")
.boundingBox();
expect(cancelBox).not.toBeNull();
expect(confirmBox).not.toBeNull();
expect((cancelBox?.y ?? 0) - (confirmBox?.y ?? 0)).toBeGreaterThan(
confirmBox?.height ?? 0,
);
});
test("canceling recovery uses the standard pairing cancellation state", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("nostr-import-phone-link").click();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
await page.evaluate(async () => {
await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", {
event: "pairing-sas-received",
payload: { sas: "123456" },
});
});
await page.getByTestId("deny-identity-recovery-sas").click();
await expect(
page.getByText("The codes didn't match. Pairing was canceled."),
).toBeVisible();
await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
({ command }) => command === "cancel_pairing",
).length,
),
)
.toBeGreaterThan(0);
});
test("phone recovery continues to harness setup without creating or restarting", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("nostr-import-phone-link").click();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
await page.evaluate(async () => {
await window.__TAURI_INTERNALS__?.invoke?.(
"complete_identity_recovery_pairing",
);
});
await expect(
page.getByRole("heading", { name: "Set up your agent harnesses" }),
).toBeVisible();
await expect(page.getByTestId("relaunch-required")).toHaveCount(0);
await expect(
page.getByRole("heading", {
name: "Your unique identity key has been created",
}),
).toHaveCount(0);
});
test("recovery turns relay failures into actionable copy", async ({ page }) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("nostr-import-phone-link").click();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
await page.evaluate(async () => {
await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", {
event: "pairing-error",
payload: { message: "failed to send sas-confirm" },
});
});
await expect(
page.getByText(
"This pairing code expired or lost its connection. Create a new code and try again.",
),
).toBeVisible();
await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
});
test("desktop refreshes recovery codes before the relay expires them", async ({
page,
}) => {
await page.clock.install();
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("nostr-import-phone-link").click();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
const recoveryStarts = () =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
({ command }) => command === "start_identity_recovery_pairing",
).length,
);
await expect.poll(recoveryStarts).toBe(1);
await page.clock.fastForward(90_000);
await expect.poll(recoveryStarts).toBe(2);
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
});
test("importing a key from lost mode shows the relaunch-required screen", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
await page.getByTestId("nostr-import-nsec-input").fill(importedNsec);
await expect(page.getByTestId("nostr-import-npub-preview")).toBeVisible();
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("relaunch-required")).toBeVisible();
});
test("start-new-identity from lost mode persists the ephemeral key after confirmation", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
page.on("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Start new identity" }).click();
await expect(page.getByTestId("relaunch-required")).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string }>;
}
).__BUZZ_E2E_COMMAND_PAYLOADS__?.some(
(e) => e.command === "persist_current_identity",
) ?? false,
),
)
.toBe(true);
});
test("cancelling start-new-identity in lost mode stays on the import screen", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
page.on("dialog", (dialog) => dialog.dismiss());
await page.getByRole("button", { name: "Start new identity" }).click();
// Still on the import screen — no navigation, no persist
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
await expect(page.getByTestId("relaunch-required")).toHaveCount(0);
});
test("locked boot shows the keyring-locked screen without the onboarding gate or key-import UI", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLocked: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("keyring-locked")).toBeVisible();
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toHaveCount(0);
});
test("locked boot can re-import a key and requires relaunch", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLocked: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("keyring-locked")).toBeVisible();
page.on("dialog", (dialog) => dialog.accept());
await page
.getByRole("button", { name: "Re-import your key instead" })
.click();
const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
await page.getByTestId("nostr-import-nsec-input").fill(importedNsec);
await expect(page.getByTestId("nostr-import-npub-preview")).toBeVisible();
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("relaunch-required")).toBeVisible();
await expect(page.getByTestId("keyring-locked")).toHaveCount(0);
});
test("locked screen relaunch button records the process-restart invoke", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLocked: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("keyring-locked")).toBeVisible();
await page.getByTestId("relaunch-app").click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string }>;
}
).__BUZZ_E2E_COMMAND_PAYLOADS__?.some(
(e) => e.command === "plugin:process|restart",
) ?? false,
),
)
.toBe(true);
});
@@ -0,0 +1,792 @@
import { expect, type Page, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css";
const IMAGE_SHAS = ["a".repeat(64), "b".repeat(64), "c".repeat(64)];
const SPOILER_VISIBLE_SHA = "d".repeat(64);
const SPOILER_HIDDEN_SHA = "e".repeat(64);
const SPOILER_VISIBLE_URL = `http://localhost:3000/media/${SPOILER_VISIBLE_SHA}.png`;
const SPOILER_HIDDEN_URL = `http://localhost:3000/media/${SPOILER_HIDDEN_SHA}.png`;
const NO_DIM_WIDE_URL = "https://example.com/e2e/gallery-wide.png";
const NO_DIM_PORTRAIT_URL = "https://example.com/e2e/gallery-portrait.png";
const NO_DIM_SECOND_URL = "https://example.com/e2e/gallery-second.png";
const PROGRESSIVE_URL = "https://example.com/e2e/progressive-full.png";
const PROGRESSIVE_THUMB_URL = "https://example.com/e2e/progressive-thumb.jpg";
async function waitForMockLiveSubscription(page: Page, channelName: string) {
await expect
.poll(async () => {
return page.evaluate((name) => {
return (
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: name,
}) ?? false
);
}, channelName);
})
.toBe(true);
}
function imageImetaTag({
dim,
filename,
sha,
thumb,
url,
}: {
dim: string;
filename: string;
sha: string;
thumb?: string;
url: string;
}) {
return [
"imeta",
`url ${url}`,
"m image/png",
`x ${sha}`,
"size 1234",
`dim ${dim}`,
`filename ${filename}`,
...(thumb ? [`thumb ${thumb}`] : []),
];
}
async function installNoDimImageRoutes(page: Page) {
await page.route("https://example.com/e2e/gallery-*.png", (route) => {
const requestedUrl = route.request().url();
const isPortrait = requestedUrl.includes("portrait");
const isSecond = requestedUrl.includes("second");
const width = isPortrait ? 120 : 320;
const height = isPortrait ? 320 : 120;
const fill = isSecond ? "#a78bfa" : isPortrait ? "#f4b860" : "#4aa3df";
route.fulfill({
body: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="100%" height="100%" fill="${fill}"/></svg>`,
contentType: "image/svg+xml",
});
});
}
async function getLightboxFrameBox(page: Page) {
const box = await page.locator("[data-image-lightbox-frame]").boundingBox();
if (!box) {
throw new Error("Expected lightbox frame to have a layout box");
}
return box;
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page, {
uploadDescriptors: [
{
url: `http://localhost:3000/media/${IMAGE_SHAS[0]}.png`,
sha256: IMAGE_SHAS[0],
size: 1234,
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
dim: "160x100",
filename: "first.png",
},
{
url: `http://localhost:3000/media/${IMAGE_SHAS[1]}.png`,
sha256: IMAGE_SHAS[1],
size: 2345,
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
dim: "100x160",
filename: "second.png",
},
{
url: `http://localhost:3000/media/${IMAGE_SHAS[2]}.png`,
sha256: IMAGE_SHAS[2],
size: 3456,
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
dim: "140x140",
filename: "third.png",
},
],
});
});
test("image bundle lightbox navigates as a gallery", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-input").fill("gallery bundle");
await page.getByRole("button", { name: "Attach file" }).click();
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
const row = page
.getByTestId("message-row")
.filter({ hasText: "gallery bundle" })
.last();
await expect(row).toBeVisible();
const triggers = row.getByTestId("message-image-lightbox-trigger");
await expect(triggers).toHaveCount(3);
const mosaic = row.locator("[data-image-mosaic]");
await expect(mosaic).toHaveAttribute("data-image-mosaic-count", "3");
await expectSmoothCorners(mosaic);
const mosaicCornerRadius = await mosaic.evaluate(
(element) => window.getComputedStyle(element).borderTopLeftRadius,
);
const mosaicBox = await mosaic.boundingBox();
const firstBox = await triggers.first().boundingBox();
const secondBox = await triggers.nth(1).boundingBox();
const thirdBox = await triggers.nth(2).boundingBox();
if (!mosaicBox || !firstBox || !secondBox || !thirdBox) {
throw new Error("Expected image mosaic tiles to have layout boxes");
}
expect(mosaicBox.width).toBeCloseTo(512, 0);
expect(firstBox.height).toBeGreaterThan(secondBox.height * 1.8);
expect(firstBox.height).toBeCloseTo(
thirdBox.y + thirdBox.height - secondBox.y,
0,
);
expect(secondBox.x).toBeCloseTo(thirdBox.x, 0);
expect(secondBox.y).toBeLessThan(thirdBox.y);
await expectCornerRadiusPx(mosaic, 16);
await expectCornerRadiusPx(triggers.first(), 0);
await expect(triggers.first().locator("img")).toHaveCSS(
"object-fit",
"cover",
);
await expectCornerRadiusPx(triggers.first().locator("img"), 0);
await triggers.first().click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.locator(`img[src*="${IMAGE_SHAS[0]}"]`)).toBeVisible();
const lightboxSurface = page
.locator("[data-image-lightbox-frame] > div > div")
.first();
await expectCornerRadiusPx(lightboxSurface, 16);
await expectSmoothCorners(lightboxSurface);
await expect(
page.getByRole("button", { name: "Previous image" }),
).toHaveCount(0);
await page.getByRole("button", { name: "Next image" }).click();
await expect(dialog.locator(`img[src*="${IMAGE_SHAS[1]}"]`)).toBeVisible();
await expect(
page.getByRole("button", { name: "Previous image" }),
).toBeVisible();
await page.keyboard.press("ArrowRight");
const currentLightboxImage = dialog.locator(`img[src*="${IMAGE_SHAS[2]}"]`);
const lightboxFrame = page.locator("[data-image-lightbox-frame]");
await expect(currentLightboxImage).toBeVisible();
await expect(currentLightboxImage).toHaveCSS("object-fit", "contain");
await expect(page.getByRole("button", { name: "Next image" })).toHaveCount(0);
const currentThumbnailBox = await triggers
.nth(2)
.locator("img")
.boundingBox();
if (!currentThumbnailBox) {
throw new Error("Expected current gallery thumbnail to have a layout box");
}
await page.waitForTimeout(500);
await page.mouse.click(20, 20);
await expect(currentLightboxImage).toHaveCSS("object-fit", "cover");
const closingFrameStyle = await lightboxFrame.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Expected HTML lightbox frame");
}
return {
borderBottomLeftRadius: element.style.borderBottomLeftRadius,
borderBottomRightRadius: element.style.borderBottomRightRadius,
borderTopLeftRadius: element.style.borderTopLeftRadius,
borderTopRightRadius: element.style.borderTopRightRadius,
height: Number.parseFloat(element.style.height),
left: Number.parseFloat(element.style.left),
top: Number.parseFloat(element.style.top),
transitionProperty: element.style.transitionProperty,
width: Number.parseFloat(element.style.width),
};
});
expect(closingFrameStyle.borderTopLeftRadius).toBe("0px");
expect(closingFrameStyle.borderTopRightRadius).toBe("0px");
expect(closingFrameStyle.borderBottomLeftRadius).toBe("0px");
expect(closingFrameStyle.borderBottomRightRadius).toBe(mosaicCornerRadius);
expect(closingFrameStyle.transitionProperty).toContain("border-radius");
const lightboxSurfaceStyle = await page
.locator("[data-image-lightbox-frame] > div > div")
.first()
.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Expected HTML lightbox surface");
}
return {
borderBottomRightRadius: element.style.borderBottomRightRadius,
borderTopLeftRadius: element.style.borderTopLeftRadius,
transitionProperty: element.style.transitionProperty,
};
});
expect(lightboxSurfaceStyle.borderTopLeftRadius).toBe("0px");
expect(lightboxSurfaceStyle.borderBottomRightRadius).toBe(mosaicCornerRadius);
expect(lightboxSurfaceStyle.transitionProperty).toBe("border-radius");
expect(Math.abs(closingFrameStyle.left - currentThumbnailBox.x)).toBeLessThan(
2,
);
expect(Math.abs(closingFrameStyle.top - currentThumbnailBox.y)).toBeLessThan(
2,
);
expect(
Math.abs(closingFrameStyle.width - currentThumbnailBox.width),
).toBeLessThan(2);
expect(
Math.abs(closingFrameStyle.height - currentThumbnailBox.height),
).toBeLessThan(2);
});
test("hidden spoiler images are excluded from gallery navigation until revealed", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await page.evaluate(
({ content, extraTags }) => {
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
extraTags?: string[][];
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content,
extraTags,
});
},
{
content: [
"spoiler gallery",
`![visible](${SPOILER_VISIBLE_URL})`,
`||![hidden](${SPOILER_HIDDEN_URL})||`,
].join("\n"),
extraTags: [
imageImetaTag({
dim: "160x100",
filename: "visible.png",
sha: SPOILER_VISIBLE_SHA,
url: SPOILER_VISIBLE_URL,
}),
imageImetaTag({
dim: "100x160",
filename: "hidden.png",
sha: SPOILER_HIDDEN_SHA,
url: SPOILER_HIDDEN_URL,
}),
],
},
);
const row = page
.getByTestId("message-row")
.filter({ hasText: "spoiler gallery" })
.last();
await expect(row).toBeVisible();
await row.locator(`img[src*="${SPOILER_VISIBLE_SHA}"]`).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(page.getByRole("button", { name: "Next image" })).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
const spoiler = row.locator(".buzz-spoiler[data-spoiler]").first();
await expect(spoiler).toHaveAttribute("data-revealed", "false");
await spoiler.click();
await expect(spoiler).toHaveAttribute("data-revealed", "true");
await row.locator(`img[src*="${SPOILER_VISIBLE_SHA}"]`).click();
await expect(dialog).toBeVisible();
await expect(page.getByRole("button", { name: "Next image" })).toBeVisible();
});
test("message images load a thumbnail before requesting the original", async ({
page,
}) => {
let fullRequested = false;
let releaseThumbnail: (() => void) | undefined;
let releaseFull: (() => void) | undefined;
const thumbnailGate = new Promise<void>((resolve) => {
releaseThumbnail = resolve;
});
const fullGate = new Promise<void>((resolve) => {
releaseFull = resolve;
});
const svg = (fill: string) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200"><rect width="100%" height="100%" fill="${fill}"/></svg>`;
await page.route(PROGRESSIVE_THUMB_URL, async (route) => {
await thumbnailGate;
await route.fulfill({ body: svg("#f4b860"), contentType: "image/svg+xml" });
});
await page.route(PROGRESSIVE_URL, async (route) => {
fullRequested = true;
await fullGate;
await route.fulfill({ body: svg("#4aa3df"), contentType: "image/svg+xml" });
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await waitForMockLiveSubscription(page, "general");
await page.evaluate(
({ content, extraTags }) => {
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
extraTags: string[][];
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content,
extraTags,
});
},
{
content: `progressive image\n![progressive](${PROGRESSIVE_URL})`,
extraTags: [
imageImetaTag({
dim: "320x200",
filename: "progressive.png",
sha: "f".repeat(64),
thumb: PROGRESSIVE_THUMB_URL,
url: PROGRESSIVE_URL,
}),
],
},
);
const row = page
.getByTestId("message-row")
.filter({ hasText: "progressive image" })
.last();
const trigger = row.getByTestId("message-image-lightbox-trigger");
await expect(
trigger.locator(`img[src="${PROGRESSIVE_THUMB_URL}"]`),
).toHaveCount(1);
await expect(trigger.locator(`img[src="${PROGRESSIVE_URL}"]`)).toHaveCount(0);
expect(fullRequested).toBe(false);
const before = await trigger.boundingBox();
const rowBefore = await row.boundingBox();
releaseThumbnail?.();
const full = trigger.locator(`img[src="${PROGRESSIVE_URL}"]`);
await expect(full).toHaveCount(1);
await expect(full).toHaveClass(/opacity-0/);
expect(fullRequested).toBe(true);
releaseFull?.();
await expect(full).toBeVisible();
await expect(full).not.toHaveClass(/opacity-0/);
expect(await trigger.boundingBox()).toEqual(before);
expect(await row.boundingBox()).toEqual(rowBefore);
});
test("gallery items without imeta dimensions keep their thumbnail aspect ratio", async ({
page,
}) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await page.evaluate(
({ content }) => {
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content,
});
},
{
content: [
"no dim gallery",
`![wide](${NO_DIM_WIDE_URL})`,
`![portrait](${NO_DIM_PORTRAIT_URL})`,
].join("\n"),
},
);
const row = page
.getByTestId("message-row")
.filter({ hasText: "no dim gallery" })
.last();
await expect(row).toBeVisible();
await expect(row.locator(`img[src="${NO_DIM_WIDE_URL}"]`)).toBeVisible();
await expect(row.locator(`img[src="${NO_DIM_PORTRAIT_URL}"]`)).toBeVisible();
await row.locator(`img[src="${NO_DIM_WIDE_URL}"]`).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.locator(`img[src="${NO_DIM_WIDE_URL}"]`)).toBeVisible();
await page.waitForTimeout(350);
const wideFrameBox = await getLightboxFrameBox(page);
expect(wideFrameBox.width / wideFrameBox.height).toBeGreaterThan(2);
await page.getByRole("button", { name: "Next image" }).click();
await expect(
dialog.locator(`img[src="${NO_DIM_PORTRAIT_URL}"]`),
).toBeVisible();
await page.waitForTimeout(350);
const portraitFrameBox = await getLightboxFrameBox(page);
expect(portraitFrameBox.width / portraitFrameBox.height).toBeLessThan(0.6);
});
test("forum markdown images use the markdown root as their gallery scope", async ({
page,
}) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await expect
.poll(() => {
return page.evaluate(() => {
return (
typeof (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function"
);
});
})
.toBe(true);
const postId = await page.evaluate(
({ content }) => {
const event = (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
kind: number;
}) => { id: string };
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "watercooler",
content,
kind: 45001,
});
return event?.id ?? null;
},
{
content: [
"forum gallery scope",
`![wide](${NO_DIM_WIDE_URL})`,
`![portrait](${NO_DIM_PORTRAIT_URL})`,
].join("\n"),
},
);
expect(postId).not.toBeNull();
await page.getByTestId("channel-watercooler").click();
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
await page
.getByRole("button")
.filter({ hasText: "forum gallery scope" })
.first()
.getByText("forum gallery scope")
.click();
const threadPost = page.locator(`[data-forum-event-id="${postId}"]`);
await expect(threadPost).toBeVisible();
const triggers = threadPost.getByTestId("message-image-lightbox-trigger");
await expect(triggers).toHaveCount(2);
await expect
.poll(() =>
triggers.first().evaluate((trigger) => {
return trigger.closest("[data-testid='message-row']") !== null;
}),
)
.toBe(false);
await triggers.first().click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.locator(`img[src="${NO_DIM_WIDE_URL}"]`)).toBeVisible();
await expect(
page.getByRole("button", { name: "Previous image" }),
).toHaveCount(0);
await expect(page.getByRole("button", { name: "Next image" })).toBeVisible();
await page.getByRole("button", { name: "Next image" }).click();
await expect(
dialog.locator(`img[src="${NO_DIM_PORTRAIT_URL}"]`),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Previous image" }),
).toBeVisible();
});
test("multi-image mosaics keep a fixed width and grow by rows", async ({
page,
}) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const urls = Array.from(
{ length: 5 },
(_, index) =>
`https://example.com/e2e/gallery-${index % 2 === 0 ? "wide" : "portrait"}.png?item=${index}`,
);
await page.evaluate((imageUrls) => {
const emit = (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
for (const count of [2, 4, 5]) {
emit?.({
channelName: "general",
content: [
`${count} image mosaic`,
...imageUrls.slice(0, count).map((url) => `![image](${url})`),
].join("\n"),
});
}
}, urls);
const mosaics: Array<{ count: number; height: number; width: number }> = [];
for (const count of [2, 4, 5]) {
const row = page
.getByTestId("message-row")
.filter({ hasText: `${count} image mosaic` })
.last();
const mosaic = row.locator("[data-image-mosaic]");
await expect(mosaic).toHaveAttribute(
"data-image-mosaic-count",
String(count),
);
const box = await mosaic.boundingBox();
if (!box) throw new Error(`Expected ${count}-image mosaic layout box`);
mosaics.push({ count, height: box.height, width: box.width });
}
expect(mosaics[0].width).toBeCloseTo(mosaics[1].width, 0);
expect(mosaics[1].width).toBeCloseTo(mosaics[2].width, 0);
expect(mosaics[1].height).toBeGreaterThan(mosaics[0].height);
expect(mosaics[2].height).toBeGreaterThan(mosaics[1].height);
expect(mosaics[2].height - mosaics[1].height).toBeCloseTo(
mosaics[0].height + 6,
0,
);
});
test("image mosaic screenshot", async ({ page }) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await page.evaluate(
({ portraitUrl, secondUrl, wideUrl }) => {
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: [
"Weekend photo dump",
`![Coastal overlook](${wideUrl})`,
`![Boardwalk detail](${portraitUrl})`,
`![Golden hour](${secondUrl})`,
].join("\n"),
});
},
{
portraitUrl: NO_DIM_PORTRAIT_URL,
secondUrl: NO_DIM_SECOND_URL,
wideUrl: NO_DIM_WIDE_URL,
},
);
const row = page
.getByTestId("message-row")
.filter({ hasText: "Weekend photo dump" })
.last();
await expect(row.locator("[data-image-mosaic] img")).toHaveCount(3);
await waitForAnimations(page);
await row.screenshot({
path: "test-results/image-mosaic/three-image-mosaic.png",
});
});
test("mosaic image context menu is portaled outside the clipped gallery", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-input").fill("mosaic context menu");
await page.getByRole("button", { name: "Attach file" }).click();
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
const row = page
.getByTestId("message-row")
.filter({ hasText: "mosaic context menu" })
.last();
const mosaic = row.locator("[data-image-mosaic]");
const trigger = row.getByTestId("message-image-lightbox-trigger").last();
await expect(mosaic).toBeVisible();
await trigger.click({ button: "right" });
const menu = page.locator("[data-image-context-menu]");
await expect(menu).toBeVisible();
await expect(page.getByRole("button", { name: "Copy image" })).toBeVisible();
await expect(
page.getByRole("button", { name: "Download image" }),
).toBeVisible();
await expect(mosaic.locator("[data-image-context-menu]")).toHaveCount(0);
expect(
await menu.evaluate((element) => element.parentElement === document.body),
).toBe(true);
const mosaicBox = await mosaic.boundingBox();
const menuBox = await menu.boundingBox();
if (!mosaicBox || !menuBox) {
throw new Error("Expected mosaic and image context menu layout boxes");
}
expect(menuBox.x + menuBox.width).toBeGreaterThan(
mosaicBox.x + mosaicBox.width,
);
});
test("lightbox image context menu stays inside the dialog focus scope", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-input").fill("lightbox context menu");
await page.getByRole("button", { name: "Attach file" }).click();
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
const row = page
.getByTestId("message-row")
.filter({ hasText: "lightbox context menu" })
.last();
await row.getByTestId("message-image-lightbox-trigger").first().click();
const dialog = page.getByRole("dialog");
const lightboxImage = dialog.locator(`img[src*="${IMAGE_SHAS[0]}"]`);
await expect(lightboxImage).toBeVisible();
await lightboxImage.click({ button: "right" });
const menu = dialog.locator("[data-image-context-menu]");
const copyButton = menu.getByRole("button", { name: "Copy image" });
const downloadButton = menu.getByRole("button", { name: "Download image" });
await expect(menu).toBeVisible();
await expect(page.locator("body > [data-image-context-menu]")).toHaveCount(0);
await dialog.focus();
await page.keyboard.press("Shift+Tab");
await expect(downloadButton).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(copyButton).toBeFocused();
await page.keyboard.press("Tab");
await expect(downloadButton).toBeFocused();
await page.keyboard.press("Tab");
await expect(
dialog.getByRole("button", { name: "Next image" }),
).toBeFocused();
});
test("right-click image shows Copy image and invokes copy command", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-input").fill("copy me");
await page.getByRole("button", { name: "Attach file" }).click();
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
const row = page
.getByTestId("message-row")
.filter({ hasText: "copy me" })
.last();
const trigger = row.getByTestId("message-image-lightbox-trigger").first();
await expect(trigger).toBeVisible();
await trigger.click({ button: "right" });
const copyButton = page.getByRole("button", { name: "Copy image" });
await expect(copyButton).toBeVisible();
await expect(
page.getByRole("button", { name: "Download image" }),
).toBeVisible();
// The image menu carries the shared generic marker plus its own image-
// specific attribute, and never the link/video attributes — so e2e locators
// can target one surface without aliasing another.
await expect(page.locator("[data-media-context-menu]")).toBeVisible();
await expect(page.locator("[data-image-context-menu]")).toBeVisible();
await expect(page.locator("[data-link-context-menu]")).toHaveCount(0);
await expect(page.locator("[data-video-context-menu]")).toHaveCount(0);
await copyButton.click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("copy_image_to_clipboard");
});
+652
View File
@@ -0,0 +1,652 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const CURRENT_PUBKEY = "deadbeef".repeat(8);
const OWN_MESSAGE_ID = "d1".repeat(32);
const FOREIGN_MESSAGE_ID = "e2".repeat(32);
const EMPTY_DELETE_ROOT_ID = "a1".repeat(32);
const EMPTY_DELETE_REPLY_ID = "b2".repeat(32);
const EMPTY_DELETE_ROOT_CONTENT = "Inbox empty-edit root message.";
const EMPTY_DELETE_REPLY_CONTENT =
"Selected Inbox reply remains after root deletion.";
const COLD_ROOT_ID = "c3".repeat(32);
const COLD_SELECTED_REPLY_ID = "f4".repeat(32);
const COLD_DELETED_EDIT_ID = "0a".repeat(32);
const COLD_SIBLING_REPLY_ID = "1b".repeat(32);
const COLD_SIBLING_EDIT_ID = "2c".repeat(32);
const COLD_ROOT_CONTENT = "Cold Inbox thread root.";
const COLD_SELECTED_ORIGINAL_CONTENT = "Cold Inbox reply original text.";
const COLD_SELECTED_RETRACTED_CONTENT = "Cold Inbox reply retracted edit text.";
const COLD_SIBLING_ORIGINAL_CONTENT = "Cold Inbox sibling reply original text.";
const COLD_SIBLING_EDITED_CONTENT = "Cold Inbox sibling reply edited text.";
const ATTACHMENT_URL = `https://mock.relay/media/${"a".repeat(64)}.pdf`;
const ATTACHMENT_FILENAME = "inbox-edit-proof.pdf";
const SHOTS = "test-results/inbox-edit";
type MockFeedItem = {
category: "mention";
channel_id: string;
channel_name: string;
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
};
type MockWindow = Window & {
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{
command: string;
payload: unknown;
}>;
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
parentEventId?: string | null;
pubkey?: string;
mentionPubkeys?: string[];
id?: string;
kind?: number;
extraTags?: string[][];
}) => {
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
};
__BUZZ_E2E_INVALIDATE_CHANNELS__?: () => Promise<void>;
__BUZZ_E2E_RELEASE_SEND_MESSAGE_LIVE_ECHO__?: () => number;
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: MockFeedItem) => unknown;
};
async function openMoreActions(
page: import("@playwright/test").Page,
messageId: string,
) {
const row = page.locator(`[data-message-id="${messageId}"]`);
await row.hover();
await page.getByTestId(`more-actions-${messageId}`).click();
await expect(page.locator('[role="menuitem"]').first()).toBeVisible();
}
async function seedEmptyDeleteThread(page: import("@playwright/test").Page) {
await page.waitForFunction(() => {
const win = window as MockWindow;
return (
typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"
);
});
await page.evaluate(
({
channelId,
currentPubkey,
replyContent,
replyId,
rootContent,
rootId,
}) => {
const win = window as MockWindow;
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emit || !push)
throw new Error("Mock bridge helpers are unavailable.");
const root = emit({
channelName: "general",
content: rootContent,
id: rootId,
pubkey: currentPubkey,
});
const reply = emit({
channelName: "general",
content: replyContent,
id: replyId,
mentionPubkeys: [currentPubkey],
parentEventId: root.id,
pubkey: currentPubkey,
});
push({
category: "mention",
channel_id: channelId,
channel_name: "general",
content: reply.content,
created_at: reply.created_at,
id: reply.id,
kind: reply.kind,
pubkey: reply.pubkey,
tags: reply.tags,
});
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: CURRENT_PUBKEY,
replyContent: EMPTY_DELETE_REPLY_CONTENT,
replyId: EMPTY_DELETE_REPLY_ID,
rootContent: EMPTY_DELETE_ROOT_CONTENT,
rootId: EMPTY_DELETE_ROOT_ID,
},
);
await page.getByTestId(`home-inbox-item-${EMPTY_DELETE_REPLY_ID}`).click();
const detail = page.getByTestId("home-inbox-detail");
const rootRow = detail.locator(`[data-message-id="${EMPTY_DELETE_ROOT_ID}"]`);
await expect(rootRow).toContainText(EMPTY_DELETE_ROOT_CONTENT);
await expect(
detail.locator(`[data-message-id="${EMPTY_DELETE_REPLY_ID}"]`),
).toContainText(EMPTY_DELETE_REPLY_CONTENT);
return { detail, rootRow };
}
async function submitEmptyEdit(
page: import("@playwright/test").Page,
detail: import("@playwright/test").Locator,
messageId: string,
) {
await openMoreActions(page, messageId);
const editAction = page.getByTestId(`edit-message-${messageId}`);
await expect(editAction).toBeVisible();
await editAction.click();
await expect(detail.getByTestId("edit-target")).toBeVisible();
const input = detail.getByTestId("message-input");
await expect(input).not.toBeEmpty();
await input.fill("");
await expect(input).toBeEmpty();
await page.keyboard.press("Enter");
}
function editCommandCount(page: import("@playwright/test").Page) {
return page.evaluate(
() =>
((window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(entry) => entry.command === "edit_message",
).length,
);
}
test("editing an immediate attachment reply preserves its media tags", async ({
page,
}) => {
await installMockBridge(page, {
deferSendMessageLiveEcho: true,
uploadDescriptors: [
{
url: ATTACHMENT_URL,
sha256: "a".repeat(64),
size: 12345,
type: "application/pdf",
uploaded: Math.floor(Date.now() / 1000),
filename: ATTACHMENT_FILENAME,
},
],
});
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await page.waitForFunction(
() =>
typeof (window as MockWindow).__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ ===
"function",
);
await page.evaluate(
({ channelId, messageId, pubkey }) => {
const pushFeedItem = (window as MockWindow)
.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!pushFeedItem) {
throw new Error("Mock feed helper is not installed.");
}
pushFeedItem({
category: "mention",
channel_id: channelId,
channel_name: "general",
content: "Inbox thread root.",
created_at: Math.floor(Date.now() / 1_000),
id: messageId,
kind: 9,
pubkey,
tags: [
["h", channelId],
["p", pubkey],
],
});
},
{
channelId: GENERAL_CHANNEL_ID,
messageId: OWN_MESSAGE_ID,
pubkey: CURRENT_PUBKEY,
},
);
await page.getByTestId(`home-inbox-item-${OWN_MESSAGE_ID}`).click();
const detail = page.getByTestId("home-inbox-detail");
await expect(detail).toContainText("Inbox thread root.");
await detail.getByRole("button", { name: "Attach file" }).click();
await expect(detail.getByTestId("message-composer")).toContainText(
ATTACHMENT_FILENAME,
);
const input = detail.getByTestId("message-input");
await input.fill("Attachment reply before editing.");
await detail.getByTestId("send-message").click();
await expect(detail.getByText("Sending")).toHaveCount(0);
const sendPayload = await page.evaluate(() => {
const payloads = (window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [];
return payloads.findLast(
(entry) => entry.command === "send_channel_message",
)?.payload;
});
expect(sendPayload).toEqual(
expect.objectContaining({
mediaTags: [
[
"imeta",
`url ${ATTACHMENT_URL}`,
"m application/pdf",
`x ${"a".repeat(64)}`,
"size 12345",
`filename ${ATTACHMENT_FILENAME}`,
],
],
parentEventId: OWN_MESSAGE_ID,
}),
);
const reply = detail
.locator('[data-testid="home-inbox-context-message"]')
.filter({ hasText: "Attachment reply before editing." });
await expect(reply).toBeVisible();
await expect(
reply.getByRole("link", { name: ATTACHMENT_FILENAME }),
).toHaveAttribute("href", ATTACHMENT_URL);
const replyId = await reply.getAttribute("data-message-id");
expect(replyId).not.toBeNull();
const replyRow = detail.locator(`[data-message-id="${replyId}"]`);
await openMoreActions(page, replyId as string);
await page.getByTestId(`edit-message-${replyId}`).click();
await expect(detail.getByTestId("edit-target")).toBeVisible();
await expect(detail.getByTestId("message-composer")).toContainText(
ATTACHMENT_FILENAME,
);
await input.fill("Attachment reply after editing.");
await page.keyboard.press("Enter");
await expect(detail.getByTestId("edit-target")).toBeHidden();
const editPayload = await page.evaluate(() => {
const payloads = (window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [];
return payloads.findLast((entry) => entry.command === "edit_message")
?.payload;
});
expect(editPayload).toEqual(
expect.objectContaining({
input: expect.objectContaining({
eventId: replyId,
mediaTags: [
[
"imeta",
`url ${ATTACHMENT_URL}`,
"m application/pdf",
`x ${"a".repeat(64)}`,
"size 12345",
`filename ${ATTACHMENT_FILENAME}`,
],
],
}),
}),
);
const releasedEchoes = await page.evaluate(() => {
const release = (window as MockWindow)
.__BUZZ_E2E_RELEASE_SEND_MESSAGE_LIVE_ECHO__;
if (!release) {
throw new Error("Mock send-echo release helper is not installed.");
}
return release();
});
expect(releasedEchoes).toBe(1);
await expect(replyRow).toContainText("Attachment reply after editing.");
await expect(
replyRow.getByRole("link", { name: ATTACHMENT_FILENAME }),
).toHaveAttribute("href", ATTACHMENT_URL);
});
test("Inbox offers a working Edit action only for manageable messages", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await page.waitForFunction(
() =>
typeof (window as MockWindow).__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ ===
"function",
);
await page.evaluate(
({
channelId,
currentPubkey,
foreignPubkey,
foreignMessageId,
ownMessageId,
}) => {
const pushFeedItem = (window as MockWindow)
.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!pushFeedItem) {
throw new Error("Mock feed helper is not installed.");
}
const createdAt = Math.floor(Date.now() / 1_000);
const messages = [
{
content: "My Inbox message before editing.",
createdAt,
id: ownMessageId,
pubkey: currentPubkey,
},
{
content: "Another person's Inbox message.",
createdAt: createdAt - 1,
id: foreignMessageId,
pubkey: foreignPubkey,
},
];
for (const message of messages) {
pushFeedItem({
category: "mention",
channel_id: channelId,
channel_name: "general",
content: message.content,
created_at: message.createdAt,
id: message.id,
kind: 9,
pubkey: message.pubkey,
tags: [
["h", channelId],
["p", currentPubkey],
],
});
}
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: CURRENT_PUBKEY,
foreignMessageId: FOREIGN_MESSAGE_ID,
foreignPubkey: TEST_IDENTITIES.alice.pubkey,
ownMessageId: OWN_MESSAGE_ID,
},
);
await page.getByTestId(`home-inbox-item-${OWN_MESSAGE_ID}`).click();
const detail = page.getByTestId("home-inbox-detail");
await expect(detail).toContainText("My Inbox message before editing.");
await openMoreActions(page, OWN_MESSAGE_ID);
await expect(
page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/01-edit-action.png` });
await page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`).click();
await expect(detail.getByTestId("edit-target")).toBeVisible();
const input = detail.getByTestId("message-input");
await expect(input).not.toBeEmpty();
await input.click();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.type("My Inbox message after editing.");
await page.keyboard.press("Enter");
await expect(detail.getByTestId("edit-target")).toBeHidden();
await expect(
detail.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`),
).toContainText("My Inbox message after editing.");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/02-edited-message.png` });
await page.getByTestId(`home-inbox-item-${FOREIGN_MESSAGE_ID}`).click();
await expect(detail).toContainText("Another person's Inbox message.");
await openMoreActions(page, FOREIGN_MESSAGE_ID);
await expect(
page.getByTestId(`edit-message-${FOREIGN_MESSAGE_ID}`),
).toHaveCount(0);
await page.keyboard.press("Escape");
await page.evaluate(async (channelId) => {
const testWindow = window as MockWindow;
const invoke = testWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
const invalidateChannels = testWindow.__BUZZ_E2E_INVALIDATE_CHANNELS__;
if (!invoke || !invalidateChannels) {
throw new Error("Mock channel helpers are not installed.");
}
await invoke("archive_channel", { channelId });
await invalidateChannels();
}, GENERAL_CHANNEL_ID);
await page.getByTestId(`home-inbox-item-${OWN_MESSAGE_ID}`).click();
await expect(detail).toContainText("My Inbox message after editing.");
await openMoreActions(page, OWN_MESSAGE_ID);
await expect(page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`)).toHaveCount(
0,
);
});
test("empty edit confirms deletion of the edited non-selected Inbox row", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
const { detail, rootRow } = await seedEmptyDeleteThread(page);
const selectedReplyRow = detail.locator(
`[data-message-id="${EMPTY_DELETE_REPLY_ID}"]`,
);
const editsBefore = await editCommandCount(page);
await submitEmptyEdit(page, detail, EMPTY_DELETE_ROOT_ID);
const dialog = page.getByRole("alertdialog");
await expect(dialog).toContainText("Delete message?");
await expect(detail.getByTestId("edit-target")).toBeVisible();
expect(await editCommandCount(page)).toBe(editsBefore);
await dialog.getByRole("button", { name: "Delete" }).click();
await expect(dialog).toBeHidden();
await expect(detail.getByTestId("edit-target")).toBeHidden();
await expect(rootRow).toBeHidden();
await expect(selectedReplyRow).toContainText(EMPTY_DELETE_REPLY_CONTENT);
expect(await editCommandCount(page)).toBe(editsBefore);
const deletePayload = await page.evaluate(() => {
const payloads = (window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [];
return payloads.findLast((entry) => entry.command === "delete_message")
?.payload;
});
expect(deletePayload).toEqual(
expect.objectContaining({ eventId: EMPTY_DELETE_ROOT_ID }),
);
});
test("cancelling an empty Inbox edit deletion preserves content and edit mode", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
const { detail, rootRow } = await seedEmptyDeleteThread(page);
const editsBefore = await editCommandCount(page);
await submitEmptyEdit(page, detail, EMPTY_DELETE_ROOT_ID);
const dialog = page.getByRole("alertdialog");
await expect(dialog).toContainText("Delete message?");
await expect(detail.getByTestId("edit-target")).toBeVisible();
expect(await editCommandCount(page)).toBe(editsBefore);
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect(dialog).toBeHidden();
await expect(detail.getByTestId("edit-target")).toBeVisible();
await expect(rootRow).toContainText(EMPTY_DELETE_ROOT_CONTENT);
await expect(
detail.locator(`[data-message-id="${EMPTY_DELETE_REPLY_ID}"]`),
).toContainText(EMPTY_DELETE_REPLY_CONTENT);
expect(await editCommandCount(page)).toBe(editsBefore);
expect(
await page.evaluate(
() =>
((window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(entry) => entry.command === "delete_message",
).length,
),
).toBe(0);
});
// Regression test for the cold Inbox hydration closure. A deletion can target
// an edit event instead of the original message, and `formatTimelineMessages`
// drops an edit only when the edit's own id is in the deletion set. A one-hop
// `#e` fetch over the context message ids returns the edit but not the deletion
// of that edit, so a cold Inbox open re-applies retracted content. The sibling
// reply is the positive control: its live edit is NOT deleted, so it must still
// render as edited after the same cold fetch.
test("cold Inbox open drops an edit that was itself deleted", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await page.waitForFunction(() => {
const win = window as MockWindow;
return (
typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"
);
});
await page.evaluate(
({
channelId,
currentPubkey,
deletedEditId,
rootContent,
rootId,
selectedOriginalContent,
selectedReplyId,
selectedRetractedContent,
siblingEditId,
siblingEditedContent,
siblingOriginalContent,
siblingReplyId,
}) => {
const win = window as MockWindow;
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emit || !push) {
throw new Error("Mock bridge helpers are unavailable.");
}
const root = emit({
channelName: "general",
content: rootContent,
id: rootId,
pubkey: currentPubkey,
});
const selectedReply = emit({
channelName: "general",
content: selectedOriginalContent,
id: selectedReplyId,
mentionPubkeys: [currentPubkey],
parentEventId: root.id,
pubkey: currentPubkey,
});
const siblingReply = emit({
channelName: "general",
content: siblingOriginalContent,
id: siblingReplyId,
parentEventId: root.id,
pubkey: currentPubkey,
});
// The retracted edit: published, then deleted by a kind:5 that targets
// the EDIT event, never the reply itself.
const retractedEdit = emit({
channelName: "general",
content: selectedRetractedContent,
extraTags: [["e", selectedReply.id]],
id: deletedEditId,
kind: 40003,
pubkey: currentPubkey,
});
emit({
channelName: "general",
content: "",
extraTags: [["e", retractedEdit.id]],
kind: 5,
pubkey: currentPubkey,
});
// Positive control: a live edit with no deletion of its own.
emit({
channelName: "general",
content: siblingEditedContent,
extraTags: [["e", siblingReply.id]],
id: siblingEditId,
kind: 40003,
pubkey: currentPubkey,
});
push({
category: "mention",
channel_id: channelId,
channel_name: "general",
content: selectedReply.content,
created_at: selectedReply.created_at,
id: selectedReply.id,
kind: selectedReply.kind,
pubkey: selectedReply.pubkey,
tags: selectedReply.tags,
});
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: CURRENT_PUBKEY,
deletedEditId: COLD_DELETED_EDIT_ID,
rootContent: COLD_ROOT_CONTENT,
rootId: COLD_ROOT_ID,
selectedOriginalContent: COLD_SELECTED_ORIGINAL_CONTENT,
selectedReplyId: COLD_SELECTED_REPLY_ID,
selectedRetractedContent: COLD_SELECTED_RETRACTED_CONTENT,
siblingEditId: COLD_SIBLING_EDIT_ID,
siblingEditedContent: COLD_SIBLING_EDITED_CONTENT,
siblingOriginalContent: COLD_SIBLING_ORIGINAL_CONTENT,
siblingReplyId: COLD_SIBLING_REPLY_ID,
},
);
// First selection of this item: every edit and deletion must come from the
// hydration fetch, not from an optimistic local write.
await page.getByTestId(`home-inbox-item-${COLD_SELECTED_REPLY_ID}`).click();
const detail = page.getByTestId("home-inbox-detail");
const siblingRow = detail.locator(
`[data-message-id="${COLD_SIBLING_REPLY_ID}"]`,
);
await expect(siblingRow).toContainText(COLD_SIBLING_EDITED_CONTENT);
const selectedRow = detail.locator(
`[data-message-id="${COLD_SELECTED_REPLY_ID}"]`,
);
await expect(selectedRow).toContainText(COLD_SELECTED_ORIGINAL_CONTENT);
await expect(selectedRow).not.toContainText(COLD_SELECTED_RETRACTED_CONTENT);
await expect(detail).not.toContainText(COLD_SELECTED_RETRACTED_CONTENT);
});
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import type { RelayEvent } from "../../src/shared/api/types";
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const SHOTS = "test-results/inbox-reactions";
type MockWindow = Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
parentEventId?: string | null;
pubkey?: string;
mentionPubkeys?: string[];
/** 64-hex id — required for the message to be a valid reaction target. */
id?: string;
}) => RelayEvent;
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: {
category: "mention" | "needs_action" | "activity" | "agent_activity";
channel_id: string | null;
channel_name: string;
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
}) => unknown;
};
// Regression test: a reaction added from the Inbox detail pane must survive
// the post-toggle refetch. Inbox items are typically thread replies, which the
// server-assembled channel window does NOT carry reactions for — the Inbox
// must hydrate them by `#e` reference or the optimistic pill vanishes.
test("inbox reaction on a thread-reply mention persists after refetch", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await page.waitForFunction(() => {
const win = window as MockWindow;
return (
typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"
);
});
// Seed a thread: alice's top-level message, then alice's reply mentioning
// tyler (the current user). The reply is the inbox item — the shape Wes hit.
// A second, unrelated mention is seeded so the test can genuinely switch
// selection away and back.
const { replyEvent, otherEvent } = await page.evaluate(
({ channelId, currentPubkey, senderPubkey }) => {
const win = window as MockWindow;
const emitMessage = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const pushFeedItem = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emitMessage || !pushFeedItem) {
throw new Error("Mock bridge helpers are not installed.");
}
const root = emitMessage({
channelName: "general",
content: "Thread root about the launch.",
pubkey: senderPubkey,
id: "a1".repeat(32),
});
const reply = emitMessage({
channelName: "general",
content: "Reply mentioning you — please react to this.",
parentEventId: root.id,
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "b2".repeat(32),
});
pushFeedItem({
id: reply.id,
kind: reply.kind,
pubkey: reply.pubkey,
content: reply.content,
created_at: reply.created_at,
channel_id: channelId,
channel_name: "general",
tags: reply.tags,
category: "mention",
});
const other = emitMessage({
channelName: "general",
content: "Unrelated mention for selection switching.",
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "c3".repeat(32),
});
pushFeedItem({
id: other.id,
kind: other.kind,
pubkey: other.pubkey,
content: other.content,
created_at: other.created_at,
channel_id: channelId,
channel_name: "general",
tags: other.tags,
category: "mention",
});
return { replyEvent: reply, otherEvent: other };
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
// Open the inbox item and react via the hover action bar's quick reaction.
const item = page.getByTestId(`home-inbox-item-${replyEvent.id}`);
await item.click();
const detail = page.getByTestId("home-inbox-detail");
await expect(detail).toContainText("please react to this");
const selectedMessage = page.getByTestId("home-inbox-selected-message");
// Deliver a live reaction with the real add_reaction wire shape: `e` target,
// no `h` channel tag. The Inbox must render it without waiting for another
// message or a context refetch.
await page.evaluate(async (eventId) => {
const invoke = (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) throw new Error("Mock Tauri invoke bridge is unavailable.");
await invoke("add_reaction", { eventId, emoji: "❤️" });
}, replyEvent.id);
await expect(selectedMessage.getByLabel("Toggle ❤️ reaction")).toBeVisible();
await selectedMessage.hover();
const actionBar = page.getByTestId(`message-action-bar-${replyEvent.id}`);
const [actionBarBox, selectedMessageBox] = await Promise.all([
actionBar.boundingBox(),
selectedMessage.boundingBox(),
]);
expect(actionBarBox).not.toBeNull();
expect(selectedMessageBox).not.toBeNull();
if (!actionBarBox || !selectedMessageBox) {
throw new Error("Inbox message action bar bounds were unavailable.");
}
expect(actionBarBox.y).toBeGreaterThanOrEqual(selectedMessageBox.y);
await selectedMessage
.getByRole("button", { name: "React with :+1:" })
.click();
// The pill must appear AND persist: the post-toggle refetch replaces the
// optimistic state with fetched reaction events. Give the refetch time to
// land before asserting, then assert the pill is still there.
const reactions = selectedMessage.getByTestId("message-reactions");
await expect(reactions).toContainText("👍");
await page.waitForTimeout(1_500);
await expect(reactions).toContainText("👍");
await page.screenshot({ path: `${SHOTS}/01-pill-after-refetch.png` });
// Re-select the item (drops all optimistic state) — the reaction must
// come back purely from the fetched relay data. Select a genuinely
// different item first so the selection actually changes.
const otherItem = page.getByTestId(`home-inbox-item-${otherEvent.id}`);
await otherItem.click();
await expect(detail).toContainText("Unrelated mention");
await item.click();
await expect(detail).toContainText("please react to this");
await expect(
page
.getByTestId("home-inbox-selected-message")
.getByTestId("message-reactions"),
).toContainText("👍");
await page.screenshot({ path: `${SHOTS}/02-pill-after-reselect.png` });
});
@@ -0,0 +1,359 @@
/**
* Screenshots documenting the focused Inbox behavior for PR #2045.
*
* These replace the hand-generated shots posted earlier in that PR, which went
* stale twice: once when the surface was briefly renamed to "Activity", and
* again when the Projects filter landed. Keeping them in a spec means the next
* round regenerates instead of drifting.
*
* Run: pnpm build:e2e && pnpm exec playwright test --project=smoke \
* tests/e2e/inbox-refactor-screenshots.spec.ts
* Output: test-results/inbox-refactor/
*/
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/inbox-refactor";
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301";
const DM_CHANNEL_ID = "f48efb06-0c93-5025-aac9-2e646bb6bfa8";
// Mock bridge default pubkey — must match DEFAULT_MOCK_PUBKEY in bridge.ts.
const MOCK_PUBKEY = "deadbeef".repeat(8);
const DRAFT_STORE_KEY = `buzz-drafts.v1:${MOCK_PUBKEY}`;
// Fixed timestamps so draft ordering renders deterministically.
const DRAFT_CREATED_AT_1 = "2026-07-01T10:00:00.000Z";
const DRAFT_CREATED_AT_2 = "2026-07-02T14:30:00.000Z";
type MockFeedWindow = Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
createdAt?: number;
id?: string;
parentEventId?: string;
pubkey?: string;
}) => {
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
};
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: {
category: "mention" | "needs_action" | "activity" | "agent_activity";
channel_id: string | null;
channel_name: string;
channel_type?: string | null;
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
}) => void;
};
/**
* The mock community is seeded without a pubkey, which initDraftStore needs on
* startup. Init scripts run in registration order, so this patches the seed
* installMockBridge already wrote.
*/
async function patchCommunityPubkey(page: import("@playwright/test").Page) {
await page.addInitScript(
({ pubkey }) => {
const raw = window.localStorage.getItem("buzz-communities");
const communities = raw
? (JSON.parse(raw) as Array<Record<string, unknown>>)
: [];
if (communities[0]) {
communities[0].pubkey = pubkey;
window.localStorage.setItem(
"buzz-communities",
JSON.stringify(communities),
);
}
},
{ pubkey: MOCK_PUBKEY },
);
}
/** Two active drafts so the Drafts filter renders its count badge. */
async function seedTwoDrafts(page: import("@playwright/test").Page) {
await page.addInitScript(
({ storeKey, channelId, agentsChannelId, createdAt1, createdAt2 }) => {
const draft = (content: string, channel: string, timestamp: string) => ({
content,
selectionStart: content.length,
selectionEnd: content.length,
channelId: channel,
createdAt: timestamp,
updatedAt: timestamp,
pendingImeta: [],
spoileredAttachmentUrls: [],
status: "active" as const,
});
window.localStorage.setItem(
storeKey,
JSON.stringify({
[`channel:${channelId}`]: draft(
"Drafting the release notes — will post once the checklist is signed off.",
channelId,
createdAt1,
),
[`channel:${agentsChannelId}`]: draft(
"Following up on the harness rollout.",
agentsChannelId,
createdAt2,
),
}),
);
},
{
storeKey: DRAFT_STORE_KEY,
channelId: GENERAL_CHANNEL_ID,
agentsChannelId: AGENTS_CHANNEL_ID,
createdAt1: DRAFT_CREATED_AT_1,
createdAt2: DRAFT_CREATED_AT_2,
},
);
}
/** Wait for the live mock-feed helpers the seeding below depends on. */
async function waitForMockFeedHelpers(page: import("@playwright/test").Page) {
await expect(page.getByTestId("home-inbox-list")).toBeVisible({
timeout: 10_000,
});
await page.waitForFunction(() => {
const win = window as MockFeedWindow;
return (
typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"
);
});
}
test.describe("inbox refactor screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => console.error("PAGE ERROR:", err.message));
page.on("console", (msg) => {
if (msg.type() === "error") {
console.error("CONSOLE ERROR:", msg.text().slice(0, 300));
}
});
});
test("01 — filter menu with Reminders/Drafts divider and counts", async ({
page,
}) => {
await patchCommunityPubkey(page);
await seedTwoDrafts(page);
await installMockBridge(page, { mode: "mock" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("home-inbox")).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("inbox-filter-trigger").click();
const menu = page.getByRole("menu");
await expect(menu).toBeVisible();
await expect(
menu.getByRole("menuitemradio", { name: "Projects" }),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/01-current-filters.png` });
});
test("02 — Inbox label, inbox icon, and overflow controls", async ({
page,
}) => {
await installMockBridge(page, { mode: "mock" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("home-inbox")).toBeVisible({
timeout: 10_000,
});
// The sidebar must be in frame — the label is the point of this shot.
const inboxButton = page
.getByTestId("sidebar-primary-menu")
.getByRole("button", { name: "Inbox", exact: true });
await expect(inboxButton).toBeVisible();
// Inbox is a destination, not a notification tray, so it carries the inbox
// glyph rather than a bell. Asserted because nothing else pins the icon.
await expect(inboxButton.locator("svg.lucide-inbox")).toHaveCount(1);
await page.getByTestId("inbox-options-trigger").click();
await expect(page.getByText("Show unread only")).toBeVisible();
await expect(page.getByText("Mark all as read")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/02-current-controls.png` });
});
test("03 — consecutive DMs group into one conversation row", async ({
page,
}) => {
await installMockBridge(page, { mode: "mock" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForMockFeedHelpers(page);
const dmIds = ["shot-dm-first", "shot-dm-second", "shot-dm-third"];
await page.evaluate(
({ channelId, createdAt, ids, senderPubkey }) => {
const win = window as MockFeedWindow;
const emitMessage = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const pushFeedItem = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emitMessage || !pushFeedItem) {
throw new Error("Mock bridge helpers are not installed.");
}
[
"Quick update: the staging deploy is ready.",
"I ran the smoke tests and everything passed.",
"Can you take a final look before we release?",
].forEach((content, index) => {
const event = emitMessage({
channelName: "alice-tyler",
content,
createdAt: createdAt + index,
id: ids[index],
pubkey: senderPubkey,
});
pushFeedItem({
category: "activity",
channel_id: channelId,
channel_name: "alice-tyler",
channel_type: null,
content: event.content,
created_at: event.created_at,
id: event.id,
kind: event.kind,
pubkey: event.pubkey,
tags: event.tags,
});
});
},
{
channelId: DM_CHANNEL_ID,
createdAt: Math.floor(Date.now() / 1000),
ids: dmIds,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
const firstDmRow = page.getByTestId(`home-inbox-item-${dmIds[0]}`);
await expect(firstDmRow).toBeVisible();
// The grouping claim: three DMs, one row.
await expect(page.getByTestId(`home-inbox-item-${dmIds[1]}`)).toHaveCount(
0,
);
await expect(page.getByTestId(`home-inbox-item-${dmIds[2]}`)).toHaveCount(
0,
);
await firstDmRow.click();
const detail = page.getByTestId("home-inbox-detail");
await expect(detail.getByRole("heading")).toHaveText("DM with alice");
await expect(detail.getByTestId("message-unread-divider")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/03-grouped-dms.png` });
});
test("04 — thread opens at the oldest unread reply", async ({ page }) => {
await installMockBridge(page, { mode: "mock" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForMockFeedHelpers(page);
const replyIds = [
"shot-reply-first",
"shot-reply-second",
"shot-reply-third",
];
await page.evaluate(
({ agentPubkeys, channelId, currentPubkey, ids }) => {
const win = window as MockFeedWindow;
const emitMessage = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const pushFeedItem = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emitMessage || !pushFeedItem) {
throw new Error("Mock bridge helpers are not installed.");
}
const createdAt = Math.floor(Date.now() / 1000) + 60;
const root = emitMessage({
channelName: "general",
content: "Release checklist for the desktop build",
createdAt: createdAt - 10,
id: "shot-thread-root",
pubkey: currentPubkey,
});
[
"Started on the changelog — first pass is up.",
"Verified the signed build on macOS.",
"One open question on the version suffix.",
].forEach((content, index) => {
const event = emitMessage({
channelName: "general",
content,
createdAt: createdAt + index,
id: ids[index],
parentEventId: root.id,
pubkey: agentPubkeys[index % agentPubkeys.length],
});
pushFeedItem({
category: "activity",
channel_id: channelId,
channel_name: "general",
channel_type: "stream",
content: event.content,
created_at: event.created_at,
id: event.id,
kind: event.kind,
pubkey: event.pubkey,
tags: event.tags,
});
});
},
{
agentPubkeys: [
TEST_IDENTITIES.alice.pubkey,
TEST_IDENTITIES.charlie.pubkey,
],
channelId: GENERAL_CHANNEL_ID,
currentPubkey: MOCK_PUBKEY,
ids: replyIds,
},
);
const firstUnreadRow = page.getByTestId(`home-inbox-item-${replyIds[0]}`);
await expect(firstUnreadRow).toBeVisible();
await firstUnreadRow.click();
const detail = page.getByTestId("home-inbox-detail");
// The anchoring claim: full thread present, selection on the oldest unread.
await expect(detail).toContainText(
"Release checklist for the desktop build",
);
await expect(detail.getByTestId("message-unread-divider")).toBeVisible();
await expect(page.getByTestId("home-inbox-selected-message")).toContainText(
"Started on the changelog — first pass is up.",
);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-thread-context.png` });
});
});
@@ -0,0 +1,193 @@
/**
* E2E spec for the inline "Add custom harness…" entry in the agent dialogs.
*
* Registering a custom harness used to be reachable only from Settings
* Agents, so anyone whose first touchpoint was "New agent" never learned the
* path existed. The harness dropdowns now carry the entry directly.
*
* Covers, on all three surfaces (create, edit definition, edit instance):
* - choosing the entry opens the registration form
* - saving registers the harness and selects it in the dropdown
*
* Create and instance edit additionally assert the sentinel never becomes the
* selection; create alone covers dismissing the form leaving the previous
* selection untouched. The three surfaces share the same routing, so those
* checks are not repeated on every one.
*/
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
const ADD_ENTRY = "Add custom harness…";
const HARNESS_LABEL = "My Weird Agent";
const HARNESS_COMMAND = "my-weird-acp";
type Page = import("@playwright/test").Page;
type Locator = import("@playwright/test").Locator;
/** Open a PersonaDropdownField (button trigger + menuitemradio options). */
async function openDropdown(trigger: Locator) {
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
}
/** Register a harness through the inline form and wait for it to close. */
async function registerHarness(page: Page) {
const form = page.getByTestId("custom-harness-form");
await expect(form).toBeVisible({ timeout: 8_000 });
await page.fill("#ch-label", HARNESS_LABEL);
await page.fill("#ch-command", HARNESS_COMMAND);
// The id auto-derives from the label; it is what the dropdown selects on.
await expect(page.locator("#ch-id")).toHaveValue("my-weird-agent");
await form.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByTestId("add-custom-harness-dialog")).not.toBeVisible({
timeout: 8_000,
});
}
/** Open the create-agent dialog (AgentDefinitionDialog, create mode). */
async function openCreateDialog(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
const dialog = page.getByTestId("persona-dialog");
await expect(dialog).toBeVisible({ timeout: 10_000 });
await dialog.getByRole("tab", { name: "Customize for this agent" }).click();
return dialog;
}
/** Open the edit dialog for a saved definition (same dialog, edit mode). */
async function openDefinitionEditDialog(page: Page, name: string) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId("agents-library-personas")).toBeVisible({
timeout: 10_000,
});
await page.getByRole("button", { name: `Open actions for ${name}` }).click();
await page.getByRole("menuitem", { name: "Edit" }).click();
const dialog = page.getByTestId("persona-dialog");
await expect(dialog).toBeVisible({ timeout: 10_000 });
await dialog.getByRole("tab", { name: "Customize for this agent" }).click();
return dialog;
}
test.describe("inline add custom harness", () => {
test("create dialog registers a harness inline and selects it", async ({
page,
}) => {
await installMockBridge(page);
const dialog = await openCreateDialog(page);
const harness = dialog.locator("#persona-runtime");
await openDropdown(harness);
await page.getByRole("menuitemradio", { name: ADD_ENTRY }).click();
// The sentinel opens the form; it must never become the selection.
await expect(page.getByTestId("add-custom-harness-dialog")).toBeVisible({
timeout: 8_000,
});
await expect(harness).not.toContainText(ADD_ENTRY);
await registerHarness(page);
// The saved harness is now the selected harness.
await expect(harness).toContainText(HARNESS_LABEL, { timeout: 8_000 });
});
test("dismissing the form leaves the create dialog's harness unchanged", async ({
page,
}) => {
await installMockBridge(page);
const dialog = await openCreateDialog(page);
const harness = dialog.locator("#persona-runtime");
await expect(harness).toBeVisible({ timeout: 10_000 });
const before = await harness.textContent();
await openDropdown(harness);
await page.getByRole("menuitemradio", { name: ADD_ENTRY }).click();
await expect(page.getByTestId("add-custom-harness-dialog")).toBeVisible({
timeout: 8_000,
});
await page.keyboard.press("Escape");
await expect(
page.getByTestId("add-custom-harness-dialog"),
).not.toBeVisible();
// No harness was registered, so the prior selection must survive.
await expect(harness).toHaveText(before ?? "");
});
test("definition edit dialog registers a harness inline and selects it", async ({
page,
}) => {
await installMockBridge(page, {
personas: [
{
displayName: "Editable Agent",
systemPrompt: "An agent whose harness gets replaced.",
},
],
});
const dialog = await openDefinitionEditDialog(page, "Editable Agent");
const harness = dialog.locator("#persona-runtime");
await openDropdown(harness);
await page.getByRole("menuitemradio", { name: ADD_ENTRY }).click();
await expect(page.getByTestId("add-custom-harness-dialog")).toBeVisible({
timeout: 8_000,
});
await registerHarness(page);
await expect(harness).toContainText(HARNESS_LABEL, { timeout: 8_000 });
});
test("instance edit dialog registers a harness inline and keeps Custom command", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey:
"npub1e2e00000000000000000000000000000000000000000000000000000000",
name: "Instance Agent",
status: "stopped",
channelNames: ["agents"],
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-agents-view").click();
await page
.getByRole("button", { name: "Instance Agent agent profile" })
.click();
await expect(page.getByTestId("user-profile-panel")).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("user-profile-edit-agent").click();
await expect(page.getByTestId("edit-agent-dialog")).toBeVisible({
timeout: 10_000,
});
const provider = page.locator("#edit-agent-runtime");
await openDropdown(provider);
// "Custom command" is a different feature (ad-hoc command override) and
// must survive alongside the new entry.
await expect(
page.getByRole("menuitemradio", { name: "Custom command" }),
).toBeVisible();
await page.getByRole("menuitemradio", { name: ADD_ENTRY }).click();
await expect(page.getByTestId("add-custom-harness-dialog")).toBeVisible({
timeout: 8_000,
});
await expect(provider).not.toContainText(ADD_ENTRY);
await registerHarness(page);
await expect(provider).toContainText(HARNESS_LABEL, { timeout: 8_000 });
});
});
+571
View File
@@ -0,0 +1,571 @@
import { expect, test, type Browser } from "@playwright/test";
import {
installRelayBridge,
openChannelBrowser,
openCreateChannelDialog,
TEST_IDENTITIES,
} from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
import { assertRelaySeeded } from "../helpers/seed";
const isCi = Boolean(process.env.CI);
const relaySeedHookTimeoutMs = isCi ? 90_000 : 30_000;
async function createStream(
page: import("@playwright/test").Page,
channelName: string,
description?: string,
) {
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
if (description !== undefined) {
await page.getByTestId("create-channel-description").fill(description);
}
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
}
async function openChannelManagement(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
}
async function openChannelEditDialog(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-edit").click();
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toBeVisible();
}
async function closeChannelManagement(page: import("@playwright/test").Page) {
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
}
async function assertDesktopNotificationsEnabled(
page: import("@playwright/test").Page,
) {
await openSettings(page, "notifications");
await expect(page.getByTestId("settings-notifications")).toBeVisible();
await expect(page.getByTestId("notifications-desktop-state")).toContainText(
"On",
);
await page.getByTestId("settings-back-to-app").click();
}
async function sendChannelMessage(
page: import("@playwright/test").Page,
{
channelName,
content,
kind,
mentionPubkeys,
}: {
channelName: string;
content: string;
kind?: number | null;
mentionPubkeys?: string[];
},
) {
await page.evaluate(
async ({
channelName: targetChannelName,
content,
kind,
mentionPubkeys,
}) => {
const tauriWindow = window as Window & {
__TAURI_INTERNALS__?: {
invoke: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
};
const invoke = tauriWindow.__TAURI_INTERNALS__?.invoke;
if (!invoke) {
throw new Error("Tauri invoke bridge is unavailable.");
}
const channels = (await invoke("get_channels")) as Array<{
id: string;
name: string;
}>;
const channel = channels.find(({ name }) => name === targetChannelName);
if (!channel) {
throw new Error(`Channel not found: ${targetChannelName}`);
}
await invoke("send_channel_message", {
channelId: channel.id,
content,
parentEventId: null,
mediaTags: null,
mentionPubkeys: mentionPubkeys ?? null,
kind: kind ?? null,
});
},
{ channelName, content, kind, mentionPubkeys },
);
}
async function joinChannel(
page: import("@playwright/test").Page,
channelName: string,
) {
await page.evaluate(async (targetChannelName) => {
const tauriWindow = window as Window & {
__TAURI_INTERNALS__?: {
invoke: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
};
const invoke = tauriWindow.__TAURI_INTERNALS__?.invoke;
if (!invoke) {
throw new Error("Tauri invoke bridge is unavailable.");
}
const channels = (await invoke("get_channels")) as Array<{
id: string;
name: string;
}>;
const channel = channels.find(({ name }) => name === targetChannelName);
if (!channel) {
throw new Error(`Channel not found: ${targetChannelName}`);
}
await invoke("join_channel", {
channelId: channel.id,
});
}, channelName);
}
async function getLoggedNotifications(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const win = window as Window & {
__BUZZ_E2E_NOTIFICATIONS__?: Array<{
body: string | null;
title: string;
}>;
};
return win.__BUZZ_E2E_NOTIFICATIONS__ ?? [];
});
}
async function getLoggedNotificationCount(
page: import("@playwright/test").Page,
) {
return (await getLoggedNotifications(page)).length;
}
async function expectLoggedNotifications(
page: import("@playwright/test").Page,
expected: Array<{ body: string | null; title: string }>,
) {
await expect.poll(() => getLoggedNotifications(page)).toEqual(expected);
}
test.beforeAll(async () => {
test.setTimeout(relaySeedHookTimeoutMs);
await assertRelaySeeded();
});
test("create channel and verify in sidebar", async ({ page }) => {
const channelName = `integration-e2e-${Date.now()}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
});
test("two users see the same channel", async ({
browser,
}: {
browser: Browser;
}) => {
const channelName = `shared-channel-${Date.now()}`;
const contextOne = await browser.newContext();
const contextTwo = await browser.newContext();
const pageOne = await contextOne.newPage();
const pageTwo = await contextTwo.newPage();
try {
await installRelayBridge(pageOne, "tyler");
await installRelayBridge(pageTwo, "alice");
await pageOne.goto("/");
await openCreateChannelDialog(pageOne);
await pageOne.getByTestId("create-channel-name").fill(channelName);
await pageOne.getByTestId("create-channel-submit").click();
await expect(pageOne.getByTestId("stream-list")).toContainText(channelName);
await pageTwo.goto("/");
await openChannelBrowser(pageTwo);
await expect(pageTwo.getByTestId("channel-browser-dialog")).toBeVisible();
await pageTwo
.getByTestId(`browse-channel-${channelName}`)
.getByRole("button", { name: "Join" })
.click();
await expect(pageTwo.getByTestId("stream-list")).toContainText(channelName);
} finally {
await contextOne.close();
await contextTwo.close();
}
});
test("message delivery across users", async ({
browser,
}: {
browser: Browser;
}) => {
const message = `Cross-user message ${Date.now()}`;
const contextOne = await browser.newContext();
const contextTwo = await browser.newContext();
const pageOne = await contextOne.newPage();
const pageTwo = await contextTwo.newPage();
try {
await installRelayBridge(pageOne, "tyler");
await installRelayBridge(pageTwo, "alice");
await pageOne.goto("/");
await pageTwo.goto("/");
await pageOne.getByTestId("channel-general").click();
await pageTwo.getByTestId("channel-general").click();
await expect(pageOne.getByTestId("chat-title")).toHaveText("general");
await expect(pageTwo.getByTestId("chat-title")).toHaveText("general");
await pageOne.getByTestId("message-input").fill(message);
await pageOne.getByTestId("send-message").click();
await expect(pageTwo.getByTestId("message-timeline")).toContainText(
message,
);
} finally {
await contextOne.close();
await contextTwo.close();
}
});
test("live mentions refetch the home feed without waiting for polling", async ({
browser,
}: {
browser: Browser;
}) => {
const stamp = Date.now();
const targetContext = await browser.newContext();
const senderContext = await browser.newContext();
const targetPage = await targetContext.newPage();
const senderPage = await senderContext.newPage();
try {
await installRelayBridge(targetPage, "tyler");
await installRelayBridge(senderPage, "alice");
await targetPage.goto("/");
await senderPage.goto("/");
await assertDesktopNotificationsEnabled(targetPage);
await targetPage.getByTestId("channel-general").click();
await expect(targetPage.getByTestId("chat-title")).toHaveText("general");
const message = `Heads up @tyler live mention ${stamp}`;
await sendChannelMessage(senderPage, {
channelName: "general",
content: message,
mentionPubkeys: [TEST_IDENTITIES.tyler.pubkey],
});
await expect(targetPage.getByTestId("message-timeline")).toContainText(
message,
);
await expectLoggedNotifications(targetPage, [
{
body: message,
title: "alice mentioned you in #general",
},
]);
// The Inbox feed should have been refetched live (the original purpose
// of this test). The home badge stays at 0 while the user is actively
// reading #general — reading in-channel advances the NIP-RS marker past
// the new mention — so the assertion that the refetch happened is the
// Inbox-list content, not the badge.
await targetPage
.getByTestId("app-sidebar")
.getByRole("button", { name: "Inbox" })
.click();
await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible();
await expect(targetPage.getByTestId("home-inbox-list")).toContainText(
message,
);
await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0);
await expect.poll(() => getLoggedNotificationCount(targetPage)).toBe(1);
} finally {
await targetContext.close();
await senderContext.close();
}
});
test("live forum mentions refetch the home feed without waiting for polling", async ({
browser,
}: {
browser: Browser;
}) => {
const stamp = Date.now();
const targetContext = await browser.newContext();
const senderContext = await browser.newContext();
const targetPage = await targetContext.newPage();
const senderPage = await senderContext.newPage();
try {
await installRelayBridge(targetPage, "tyler");
await installRelayBridge(senderPage, "alice");
await targetPage.goto("/");
await senderPage.goto("/");
await assertDesktopNotificationsEnabled(targetPage);
await targetPage.getByTestId("channel-general").click();
await expect(targetPage.getByTestId("chat-title")).toHaveText("general");
await joinChannel(senderPage, "watercooler");
const message = `Forum ping @tyler ${stamp}`;
await sendChannelMessage(senderPage, {
channelName: "watercooler",
content: message,
kind: 45001,
mentionPubkeys: [TEST_IDENTITIES.tyler.pubkey],
});
await expect(targetPage.getByTestId("sidebar-home-count")).toHaveText("1");
await expectLoggedNotifications(targetPage, [
{
body: message,
title: "alice mentioned you in #watercooler",
},
]);
await targetPage
.getByTestId("app-sidebar")
.getByRole("button", { name: "Inbox" })
.click();
await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible();
await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible();
await expect(targetPage.getByTestId("home-inbox-list")).toContainText(
message,
);
await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0);
await expect.poll(() => getLoggedNotificationCount(targetPage)).toBe(1);
} finally {
await targetContext.close();
await senderContext.close();
}
});
test("DM channel appears in sidebar", async ({ page }) => {
await installRelayBridge(page, "tyler");
await page.goto("/");
await expect(page.getByTestId("dm-list")).toContainText("alice-tyler");
});
test("send message to DM", async ({ page }) => {
const message = `DM message ${Date.now()}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await page.getByTestId("channel-alice-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
await page.getByTestId("message-input").fill(message);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
});
test("forum channel appears in sidebar", async ({ page }) => {
await installRelayBridge(page, "tyler");
await page.goto("/");
await expect(page.getByTestId("forum-list")).toContainText("watercooler");
});
test("create channel with description", async ({ page }) => {
const channelName = `desc-channel-${Date.now()}`;
const description = `Description for ${channelName}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await createStream(page, channelName, description);
await expect(page.getByTestId("chat-title")).toHaveAttribute(
"title",
description,
);
});
test("multiple channels independent", async ({ page }) => {
const channelA = `channel-a-${Date.now()}`;
const channelB = `channel-b-${Date.now()}`;
const messageA = `Message in A ${Date.now()}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelA);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(channelA);
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelB);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(channelB);
// Navigate to channel A and send a message
await page.getByTestId(`channel-${channelA}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelA);
await page.getByTestId("message-input").fill(messageA);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(messageA);
// Switch to channel B — message from A should not appear
await page.getByTestId(`channel-${channelB}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelB);
await expect(page.getByTestId("message-timeline")).not.toContainText(
messageA,
);
});
test("manage sheet updates channel details through the relay", async ({
page,
}) => {
const stamp = Date.now();
const initialName = `manage-integration-${stamp}`;
const renamedChannel = `manage-renamed-${stamp}`;
const initialDescription = `Initial description ${stamp}`;
const updatedDescription = `Updated description ${stamp}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await createStream(page, initialName, initialDescription);
await openChannelManagement(page);
await openChannelEditDialog(page);
const editDialog = page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
});
await editDialog.getByTestId("channel-management-name").fill(renamedChannel);
await editDialog
.getByTestId("channel-management-description")
.fill(updatedDescription);
await expect(editDialog.getByTestId("channel-management-topic")).toHaveCount(
0,
);
await expect(
editDialog.getByTestId("channel-management-purpose"),
).toHaveCount(0);
await editDialog.getByTestId("channel-management-save-changes").click();
await expect(editDialog).toHaveCount(0);
await expect(page.getByTestId("chat-title")).toHaveText(renamedChannel);
await expect(page.getByTestId("stream-list")).toContainText(renamedChannel);
await closeChannelManagement(page);
await page.reload();
await page.getByTestId(`channel-${renamedChannel}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(renamedChannel);
await expect(page.getByTestId("chat-title")).toHaveAttribute(
"title",
updatedDescription,
);
await openChannelManagement(page);
await openChannelEditDialog(page);
const reopenedEditDialog = page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
});
await expect(
reopenedEditDialog.getByTestId("channel-management-name"),
).toHaveValue(renamedChannel);
await expect(
reopenedEditDialog.getByTestId("channel-management-description"),
).toHaveValue(updatedDescription);
await expect(
reopenedEditDialog.getByTestId("channel-management-topic"),
).toHaveCount(0);
await expect(
reopenedEditDialog.getByTestId("channel-management-purpose"),
).toHaveCount(0);
});
test("manage sheet archive and unarchive survives a reload through the relay", async ({
page,
}) => {
const channelName = `archive-integration-${Date.now()}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
await createStream(page, channelName, "Archive integration channel");
await openChannelManagement(page);
await page.getByTestId("channel-management-archive").click();
await expect(page.getByTestId("channel-management-unarchive")).toBeVisible();
await closeChannelManagement(page);
await expect(page.getByTestId("stream-list")).not.toContainText(channelName);
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"false",
);
await expect(page.getByTestId("send-message")).toBeDisabled();
await page.reload();
await expect(page.getByTestId("stream-list")).not.toContainText(channelName);
await openChannelBrowser(page);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
await expect(page.getByTestId(`browse-channel-${channelName}`)).toContainText(
"archived",
);
await page.getByTestId(`browse-channel-${channelName}`).click();
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"false",
);
await openChannelManagement(page);
await page.getByTestId("channel-management-unarchive").click();
await expect(page.getByTestId("channel-management-archive")).toBeVisible();
await closeChannelManagement(page);
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"true",
);
});
+163
View File
@@ -0,0 +1,163 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
let invitePayloads: Record<string, unknown>[];
test.beforeEach(async ({ page }) => {
invitePayloads = [];
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
origin: "http://127.0.0.1:4173",
});
await installMockBridge(page, {
relayRequiresMembership: true,
});
await page.route("**/api/invites", async (route) => {
invitePayloads.push(route.request().postDataJSON());
await route.fulfill({
contentType: "application/json",
json: {
code: "qr-download-test",
expires_at: Math.floor(Date.now() / 1000) + 86_400,
url: "buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=qr-download-test",
},
status: 200,
});
});
});
test("copies a freshly minted invite link from the link field", async ({
page,
}) => {
await page.goto("/");
await openSettings(page, "community-members");
await expect(page.getByTestId("settings-community-members")).toBeVisible();
await page.getByTestId("community-invite-dialog-trigger").click();
await expect(page.getByTestId("member-pubkey-input")).toBeVisible();
await expect(page.getByTestId("member-role")).toHaveCount(0);
await expect(page.getByTestId("confirm-add-member")).toHaveCount(0);
await expect(page.getByTestId("invite-link-url")).toHaveValue(
"buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=qr-download-test",
);
await expect(page.getByTestId("invite-link-qr-code")).toHaveCount(0);
await expect(page.getByTestId("invite-link-max-uses-trigger")).toHaveText(
"No limit",
);
await page.getByTestId("copy-invite-link").click();
await expect(page.getByTestId("copy-invite-link")).toContainText("Copied");
expect(invitePayloads).toEqual([{ ttl_secs: 3 * 24 * 60 * 60 }]);
const payload = await page.evaluate(() => {
const log = (
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: Record<string, unknown> | null;
}>;
}
).__BUZZ_E2E_COMMAND_LOG__;
return log?.findLast(({ command }) => command === "copy_text_to_clipboard")
?.payload;
});
expect(payload).toEqual({
text: "buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=qr-download-test",
});
});
test("sets a selected invite-use limit", async ({ page }) => {
await page.goto("/");
await openSettings(page, "community-members");
await page.getByTestId("community-invite-dialog-trigger").click();
const maxUsesTrigger = page.getByTestId("invite-link-max-uses-trigger");
await maxUsesTrigger.click();
await expect(
page.getByRole("menuitemradio", { name: "No limit" }),
).toBeVisible();
await expect(
page.getByRole("menuitemradio", { name: "25 uses" }),
).toBeVisible();
await page.getByTestId("invite-link-max-uses-10").click();
await expect(maxUsesTrigger).toHaveText("10 uses");
await expect
.poll(() => invitePayloads.at(-1))
.toEqual({
max_uses: 10,
ttl_secs: 3 * 24 * 60 * 60,
});
await page.getByTestId("copy-invite-link").click();
await expect(page.getByTestId("copy-invite-link")).toContainText("Copied");
});
test("retries a failed invite-link generation", async ({ page }) => {
let attempts = 0;
await page.route("**/api/invites", async (route) => {
attempts += 1;
if (attempts === 1) {
await route.fulfill({ status: 500 });
return;
}
await route.fulfill({
contentType: "application/json",
json: {
code: "retry-test",
expires_at: Math.floor(Date.now() / 1000) + 86_400,
url: "buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=retry-test",
},
status: 200,
});
});
await page.goto("/");
await openSettings(page, "community-members");
await page.getByTestId("community-invite-dialog-trigger").click();
const linkField = page.getByTestId("invite-link-url");
const copyButton = page.getByTestId("copy-invite-link");
await expect(linkField).toHaveAttribute(
"placeholder",
"Couldnt create invite link",
);
await expect(copyButton).toHaveText("Retry");
await expect(copyButton).toBeEnabled();
await copyButton.click();
await expect(linkField).toHaveValue(
"buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=retry-test",
);
await expect(copyButton).toHaveText("Copy link");
expect(attempts).toBe(2);
});
test("reopens with the default expiry before generating a new link", async ({
page,
}) => {
await page.goto("/");
await openSettings(page, "community-members");
await page.getByTestId("community-invite-dialog-trigger").click();
await expect
.poll(() => invitePayloads)
.toEqual([{ ttl_secs: 3 * 24 * 60 * 60 }]);
await page.getByTestId("invite-link-ttl-trigger").click();
await page.getByTestId("invite-link-ttl-604800").click();
await expect
.poll(() => invitePayloads)
.toEqual([{ ttl_secs: 3 * 24 * 60 * 60 }, { ttl_secs: 7 * 24 * 60 * 60 }]);
const dialog = page.getByTestId("community-invite-dialog");
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toHaveCount(0);
await page.getByTestId("community-invite-dialog-trigger").click();
await expect
.poll(() => invitePayloads)
.toEqual([
{ ttl_secs: 3 * 24 * 60 * 60 },
{ ttl_secs: 7 * 24 * 60 * 60 },
{ ttl_secs: 3 * 24 * 60 * 60 },
]);
});
@@ -0,0 +1,233 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
const OUTDIR = "test-results/invites-settings";
const DIRECT_ADD_HEX =
"ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328";
const DIRECT_ADD_NPUB =
"npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60";
const SECOND_DIRECT_ADD_HEX =
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const SECOND_DIRECT_ADD_NPUB =
"npub1424242424242424242424242424242424242424242424242424qamrcaj";
test.beforeEach(async ({ page }, testInfo) => {
await installMockBridge(page, {
relayRequiresMembership: true,
relayRole: testInfo.title.includes("admin can add members")
? "admin"
: "owner",
});
await page.route("**/api/invites", async (route) => {
await route.fulfill({
contentType: "application/json",
json: {
code: "community-email-test",
expires_at: Math.floor(Date.now() / 1000) + 3 * 86_400,
url: "https://alpha.example.com/invite/community-email-test",
},
status: 200,
});
});
await page.goto("/");
await openSettings(page, "community-members");
});
test("opens a profile from a community member avatar", async ({ page }) => {
await page.getByRole("button", { name: "Open profile for alice" }).click();
await expect(page).toHaveURL(
new RegExp(`/pulse\\?profile=${TEST_IDENTITIES.alice.pubkey}$`),
);
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
});
test("capture: consolidated invites settings", async ({ page }) => {
const panel = page.getByTestId("settings-panel-community-members");
await expect(
page.getByTestId("settings-nav-community-members"),
).toContainText("Invites");
await expect(
page.getByRole("heading", { name: "Invites", exact: true }),
).toBeVisible();
await expect(page.getByTestId("community-icon-settings")).toHaveCount(0);
await expect(
page.getByTestId("community-invite-dialog-trigger"),
).toBeVisible();
await expect(page.getByTestId("community-invite-email-field")).toHaveCount(0);
await expect(page.getByTestId("copy-invite-link")).toHaveCount(0);
await expect(page.getByText("alice", { exact: true })).toBeVisible();
await expect(page.getByText("bob", { exact: true })).toBeVisible();
await expect(
page.getByText("Manage roles or remove access.", { exact: true }),
).toHaveCount(0);
await expect(
page.getByText("People who use the link join as members."),
).toHaveCount(0);
await expect(page.getByTestId("community-icon-save")).toHaveCount(0);
const aliceName = page.getByText("alice", { exact: true });
const aliceRow = page
.locator('[data-testid^="relay-member-row-"]')
.filter({ has: aliceName });
const aliceNpub = aliceRow.locator('[data-testid^="relay-member-npub-"]');
await expect(aliceName).toHaveCSS("opacity", "1");
await expect(aliceNpub).toHaveCSS("opacity", "0");
await aliceRow.hover();
await expect(aliceName).toHaveCSS("opacity", "0");
await expect(aliceNpub).toHaveCSS("opacity", "1");
await page.mouse.move(0, 0);
await waitForAnimations(page);
await panel.screenshot({ path: `${OUTDIR}/01-invites-settings.png` });
});
test("capture: share-style community invite dialog", async ({ page }) => {
await page.getByTestId("community-invite-dialog-trigger").click();
const dialog = page.getByTestId("community-invite-dialog");
await expect(dialog).toBeVisible();
await expect(
dialog.getByRole("heading", { name: "Invite to community" }),
).toBeVisible();
await expect(page.getByTestId("community-invite-email-field")).toHaveCount(0);
await expect(page.getByPlaceholder("Type an email address")).toHaveCount(0);
await expect(
dialog.getByText(
"Add someone directly or share a link they can use to join.",
),
).toBeVisible();
await expect(
dialog.getByRole("heading", { name: "Add someone", exact: true }),
).toHaveCount(0);
await expect(dialog.getByTestId("invite-options-divider")).toBeVisible();
await expect(
dialog.getByText("Or, copy a link", { exact: true }),
).toBeVisible();
await expect(dialog.getByText("Link settings", { exact: true })).toHaveCount(
0,
);
await expect(page.getByTestId("member-pubkey-input")).toBeVisible();
await expect(page.getByTestId("member-role")).toHaveCount(0);
await expect(page.getByTestId("confirm-add-member")).toHaveCount(0);
await expect(page.getByTestId("invite-link-url")).toHaveValue(
"https://alpha.example.com/invite/community-email-test",
);
await expect(page.getByTestId("copy-invite-link")).toHaveText("Copy link");
await expect(page.getByTestId("invite-link-ttl-trigger")).toHaveText(
"3 days",
);
await page.getByTestId("member-pubkey-input").fill(DIRECT_ADD_NPUB);
await expect(page.getByTestId("member-search-popover")).toBeVisible();
await page.getByTestId(`member-search-result-${DIRECT_ADD_HEX}`).click();
const memberRole = page.getByTestId("member-role");
const selectedChip = page.getByTestId(
`member-search-selection-remove-${DIRECT_ADD_HEX}`,
);
await expect(memberRole).toHaveText("Member");
const inviteButton = page.getByTestId("confirm-add-member");
await expect(inviteButton).toHaveText("Invite");
await waitForAnimations(page);
await expect(inviteButton).toHaveCSS("height", "44px");
await expect(inviteButton).toHaveJSProperty(
"offsetHeight",
await page
.getByTestId("member-recipient-field")
.evaluate((field) => Math.round(field.getBoundingClientRect().height)),
);
const selectedChipRemoveIcon = selectedChip.locator("span.absolute");
await expect(selectedChipRemoveIcon).toHaveCSS("opacity", "0");
await selectedChip.hover();
await expect(selectedChipRemoveIcon).toHaveCSS("opacity", "1");
const memberSearch = page.getByTestId("member-pubkey-input");
await expect(memberSearch).toBeFocused();
await memberSearch.fill(SECOND_DIRECT_ADD_NPUB);
await expect(page.getByTestId("member-search-popover")).toBeVisible();
await page
.getByTestId(`member-search-result-${SECOND_DIRECT_ADD_HEX}`)
.click();
await expect(
page.getByTestId(`member-search-selection-remove-${SECOND_DIRECT_ADD_HEX}`),
).toBeVisible();
await expect(memberSearch).toBeFocused();
await memberRole.click();
await expect(
page.getByRole("menuitemradio", { name: "Admin" }),
).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("confirm-add-member")).toBeEnabled();
await waitForAnimations(page);
await page.mouse.move(0, 0);
await dialog.screenshot({ path: `${OUTDIR}/02-invite-dialog.png` });
});
test("admin can add members but cannot assign the admin role", async ({
page,
}) => {
await page.getByTestId("community-invite-dialog-trigger").click();
await page.getByTestId("member-pubkey-input").fill(DIRECT_ADD_NPUB);
await page.getByTestId(`member-search-result-${DIRECT_ADD_HEX}`).click();
const memberRole = page.getByTestId("member-role");
await expect(memberRole).toHaveText("Member");
await memberRole.click();
await expect(page.getByRole("menuitemradio", { name: "Admin" })).toHaveCount(
0,
);
await page.keyboard.press("Escape");
});
test("owner can add multiple admins directly by npub from live Invites UI", async ({
page,
}) => {
await page.getByTestId("community-invite-dialog-trigger").click();
await page.getByTestId("member-pubkey-input").fill(DIRECT_ADD_NPUB);
await page.getByTestId(`member-search-result-${DIRECT_ADD_HEX}`).click();
await page.getByTestId("member-pubkey-input").fill(SECOND_DIRECT_ADD_NPUB);
await page
.getByTestId(`member-search-result-${SECOND_DIRECT_ADD_HEX}`)
.click();
await page.getByTestId("member-role").click();
await page.getByRole("menuitemradio", { name: "Admin" }).click();
await page.getByTestId("confirm-add-member").click();
await expect
.poll(async () =>
page.evaluate(
({ targetPubkeys, role }) =>
targetPubkeys.every((targetPubkey) =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).some((entry) => {
if (entry.command !== "plugin:websocket|send") return false;
const wireMessage = (
entry.payload as {
message?: { data?: unknown };
}
)?.message?.data;
if (typeof wireMessage !== "string") return false;
const message = JSON.parse(wireMessage) as unknown[];
if (message[0] !== "EVENT") return false;
const event = message[1] as
| { kind?: number; tags?: string[][] }
| undefined;
return (
event?.kind === 9030 &&
event.tags?.some(
(tag) => tag[0] === "p" && tag[1] === targetPubkey,
) &&
event.tags.some((tag) => tag[0] === "role" && tag[1] === role)
);
}),
),
{
targetPubkeys: [DIRECT_ADD_HEX, SECOND_DIRECT_ADD_HEX],
role: "admin",
},
),
)
.toBe(true);
});
@@ -0,0 +1,68 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const SAMPLE_NSEC =
"nsec1u70xptkumvfc4k4hu0rc4fnzcexvw63zvq2ng9vmqujsaayhparqu8eju9";
// --buzz-onboarding-backup-ink (#717106), the olive key ink shared with the
// backup step.
const BACKUP_INK = "rgb(113, 113, 6)";
test("key import masks the key with a reveal toggle", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
const input = page.getByTestId("nostr-import-nsec-input");
await expect(input).toBeVisible();
await waitForAnimations(page);
// Masked by default; no toggle until there is input; key text uses the
// shared backup ink.
const toggle = page.getByTestId("nostr-import-reveal-toggle");
await expect(input).toHaveAttribute("type", "password");
await expect(toggle).toHaveCSS("opacity", "0");
await expect(input).toHaveCSS("color", BACKUP_INK);
// The toggle is absolutely positioned: its appearance must not resize the
// input or shift the centered text.
const widthBefore = await input.evaluate(
(el) => el.getBoundingClientRect().width,
);
await input.fill(SAMPLE_NSEC);
await expect(toggle).toHaveCSS("opacity", "1");
const widthAfter = await input.evaluate(
(el) => el.getBoundingClientRect().width,
);
expect(widthAfter).toBe(widthBefore);
// Reveal, then clear: a sticky reveal must never carry over to newly
// pasted content, so the next key starts masked again.
await toggle.click();
await expect(input).toHaveAttribute("type", "text");
await input.fill("");
await expect(toggle).toHaveCSS("opacity", "0");
await input.fill(SAMPLE_NSEC);
await expect(input).toHaveAttribute("type", "password");
// Re-masking via the toggle still works.
await toggle.click();
await expect(input).toHaveAttribute("type", "text");
await toggle.click();
await expect(input).toHaveAttribute("type", "password");
// Narrow viewport: the absolutely positioned toggle must not cause
// horizontal overflow.
await page.setViewportSize({ width: 720, height: 620 });
await waitForAnimations(page);
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth,
);
expect(overflow).toBe(false);
});
@@ -0,0 +1,151 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// =============================================================================
// Regression — a live broadcast reply must enter the authoritative channel
// window store (PR #1500 review, blocker 2)
// =============================================================================
//
// NIP-CW §Top-level Classification: a depth-1 reply carrying `["broadcast","1"]`
// is a channel-window row — it belongs on the timeline as well as in its thread.
// The relay serves it as a row (`buzz-db/src/thread.rs`: "top-level rows =
// depth 0, missing metadata, or broadcast depth-1 replies").
//
// The client's live-append path drops it from the WINDOW STORE. `appendMessage`
// (hooks.ts) routes every parented timeline event into the thread cache and
// returns BEFORE the window-store merge, gating only on `parentId !== null`
// with no broadcast check. So a live broadcast reply never reaches the
// `channel-window` store's `liveOverlay` — it survives on the timeline only via
// the incidental `useLiveChannelUpdates` write to `channel-messages`, which any
// window-store rebuild (page-zero refresh, later top-level append) erases.
//
// We assert the durable invariant — the broadcast reply IS in the window
// store's `liveOverlay` — rather than a DOM row, because the timeline render is
// masked by the second (unfiltered) subscriber. The window store is the
// authoritative source `flattenChannelWindowEvents` rebuilds from.
//
// RED at f2a551f2 (appendMessage returns before the overlay merge → liveOverlay
// omits the broadcast reply). GREEN on wren/review-live-window-fixes @ 9a533a9e
// (broadcast replies fall through into `mergeLiveChannelWindowEvent`).
//
// An ordinary (non-broadcast) depth-1 reply is emitted as a control: it is NOT
// a window row and MUST stay out of the overlay, so a naive "merge every
// parented event" fix can't false-green this spec.
const CHANNEL = "general";
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
) {
await expect
.poll(() =>
page.evaluate(
(ch) =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: ch,
}) ?? false,
channelName,
),
)
.toBe(true);
}
async function emit(
page: import("@playwright/test").Page,
input: {
content: string;
parentEventId?: string | null;
createdAt?: number;
extraTags?: string[][];
},
) {
const event = await page.evaluate(
(payload) =>
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: payload.channel,
content: payload.content,
parentEventId: payload.parentEventId,
createdAt: payload.createdAt,
extraTags: payload.extraTags,
}),
{
channel: CHANNEL,
content: input.content,
parentEventId: input.parentEventId ?? null,
createdAt: input.createdAt,
extraTags: input.extraTags,
},
);
if (!event) throw new Error("mock message emitter is not installed");
return event;
}
async function liveOverlayContents(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const qc = window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as {
getQueriesData: (f: unknown) => Array<[readonly unknown[], unknown]>;
};
const win = qc
.getQueriesData({ queryKey: [] })
.find(([key]) => JSON.stringify(key).includes("channel-window"));
const store = win?.[1] as
| { liveOverlay?: Array<{ content?: string }> }
| undefined;
return (store?.liveOverlay ?? []).map((event) => event.content ?? "");
});
}
test("a live broadcast depth-1 reply enters the authoritative channel window store", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
// Seed a top-level root into the cold window before opening the channel, so
// there is a thread for the broadcast reply to descend from.
const now = Math.floor(Date.now() / 1000);
const root = await emit(page, { content: "timeline root", createdAt: now });
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL);
await expect(
page.getByTestId("message-timeline").getByText("timeline root"),
).toBeVisible();
// The live subscription must be established before we emit, or the event is
// delivered before appendMessage is listening — that would be a cold-load
// test, not a live-append test.
await waitForMockLiveSubscription(page, CHANNEL);
// LIVE broadcast depth-1 reply: parent is the root, carries ["broadcast","1"].
await emit(page, {
content: "broadcast to the channel",
parentEventId: root.id,
createdAt: now + 1,
extraTags: [["broadcast", "1"]],
});
// CONTROL — an ordinary (non-broadcast) depth-1 reply: NOT a window row, MUST
// stay out of the overlay.
await emit(page, {
content: "ordinary thread reply",
parentEventId: root.id,
createdAt: now + 2,
});
// The broadcast reply must land in the authoritative window-store overlay via
// live append alone — the invariant that survives any window-store rebuild.
await expect
.poll(() => liveOverlayContents(page))
.toContain("broadcast to the channel");
// The ordinary reply is a thread reply, never a window row.
expect(await liveOverlayContents(page)).not.toContain(
"ordinary thread reply",
);
});
@@ -0,0 +1,163 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/local-archive";
// Well-known channel IDs from the mock bridge seed (e2eBridge.ts mockChannels).
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
// Navigate to the Local Archive settings panel.
async function openLocalArchiveSettings(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
await page.getByTestId("settings-nav-local-archive").click();
const card = page.getByTestId("settings-local-archive");
await expect(card).toBeVisible({ timeout: 10_000 });
return card;
}
async function settleAnimations(el: import("@playwright/test").Locator) {
await el.evaluate((node) =>
Promise.all(node.getAnimations({ subtree: true }).map((a) => a.finished)),
);
}
test.describe("local archive screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
page.on("console", (msg) => {
if (msg.type() === "error") {
console.error("CONSOLE ERROR:", msg.text().slice(0, 500));
}
});
});
test("01 — subscriptions list with channel entry and observer toggle on", async ({
page,
}) => {
await installMockBridge(page, {
saveSubscriptions: [
{
scope_type: "channel_h",
scope_value: GENERAL_CHANNEL_ID,
kinds: "[9,40002,40003]",
},
{
scope_type: "owner_p",
scope_value: "deadbeef".repeat(8),
kinds: "[24200]",
},
],
});
const card = await openLocalArchiveSettings(page);
// Channel subscription row appears in the channel list.
await expect(
card.getByTestId(`local-archive-sub-channel_h:${GENERAL_CHANNEL_ID}`),
).toBeVisible({ timeout: 5_000 });
// owner_p rows are filtered out of the channel list and surfaced via the
// dedicated observer section instead — toggle should be visible and checked.
const observerToggle = card.getByTestId("local-archive-observer-toggle");
await expect(observerToggle).toBeVisible({ timeout: 5_000 });
await expect(observerToggle).toBeChecked();
await settleAnimations(card);
await card.screenshot({ path: `${SHOTS}/01-subscriptions-list.png` });
});
test("02 — Add form opens directly to channel picker", async ({ page }) => {
await installMockBridge(page, { saveSubscriptions: [] });
const card = await openLocalArchiveSettings(page);
// The source-picker step no longer exists — clicking Add opens the channel
// subscription form directly (channel select is the first visible field).
await card.getByTestId("local-archive-open-add").click();
await expect(card.getByTestId("local-archive-channel-select")).toBeVisible({
timeout: 5_000,
});
await settleAnimations(card);
await card.screenshot({ path: `${SHOTS}/02-add-form-channel-picker.png` });
});
test("03 — Step 2 kind checklist with indeterminate group header", async ({
page,
}) => {
await installMockBridge(page, { saveSubscriptions: [] });
const card = await openLocalArchiveSettings(page);
// Navigate to the kind checklist — clicking Add opens the form directly.
await card.getByTestId("local-archive-open-add").click();
// Step 2 should be visible now. Select a channel so the form becomes valid.
await card
.getByTestId("local-archive-channel-select")
.selectOption({ value: GENERAL_CHANNEL_ID });
// Check a subset of the first group's items to trigger the indeterminate
// state on the group header checkbox.
const firstGroupItems = card
.locator("[data-testid^='local-archive-kind-']")
.first();
await firstGroupItems.click();
await settleAnimations(card);
await card.screenshot({
path: `${SHOTS}/03-step2-kind-checklist-indeterminate.png`,
});
});
test("04 — custom kinds entry with invalid-token error", async ({ page }) => {
await installMockBridge(page, { saveSubscriptions: [] });
const card = await openLocalArchiveSettings(page);
await card.getByTestId("local-archive-open-add").click();
// Type invalid tokens into the custom kinds field.
await card
.getByTestId("local-archive-custom-kinds")
.fill("30023 bad-token 1337 notanumber");
// Error message should appear.
await expect(
card.getByTestId("local-archive-custom-kinds-error"),
).toBeVisible({ timeout: 5_000 });
await settleAnimations(card);
await card.screenshot({
path: `${SHOTS}/04-custom-kinds-invalid-error.png`,
});
});
test("05 — observer archive section with toggle", async ({ page }) => {
await installMockBridge(page, { saveSubscriptions: [] });
const card = await openLocalArchiveSettings(page);
// Observer archive is now a dedicated first-class section, not an add-flow
// source. Assert the section, descriptive copy, and toggle are all visible.
const observerSection = card.getByTestId("local-archive-observer-section");
await expect(observerSection).toBeVisible({ timeout: 5_000 });
// The section description mentions observer frames and their ephemeral nature.
await expect(
observerSection.getByText(/not stored by the relay/i),
).toBeVisible();
await expect(
card.getByTestId("local-archive-observer-toggle"),
).toBeVisible();
await settleAnimations(card);
await card.screenshot({ path: `${SHOTS}/05-observer-section.png` });
});
});
@@ -0,0 +1,73 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
/**
* Regression gate for the markdown parse cache (shared/ui/markdown/nodeCache).
*
* The timeline's per-channel-switch remount used to re-run every row's
* react-markdown parse; the cache makes warm re-entries pure element reuse.
* This spec asserts the deterministic invariant behind that win: a warm
* channel switch performs ZERO fresh parses. Unlike the wall-clock
* benchmark (warm-switch-markdown.perf.ts, instrument-only), this is
* machine-independent and safe to gate CI on if someone disconnects
* MarkdownInner from the cache or breaks the key, this fails.
*/
async function parseCount(page: import("@playwright/test").Page) {
return page.evaluate(() => window.__BUZZ_E2E_MD_PARSE_COUNT__?.() ?? -1);
}
async function settleChannel(
page: import("@playwright/test").Page,
title: string,
) {
await expect(page.getByTestId("chat-title")).toHaveText(title);
await expect(page.locator('[data-render-pending="true"]')).toHaveCount(0);
await expect(
page.getByTestId("message-timeline").locator("[data-message-id]").first(),
).toBeVisible();
// Let any trailing deferred commits flush before reading the counter.
await page.waitForTimeout(300);
}
test("warm channel switches trigger zero fresh markdown parses", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_MD_PARSE_COUNT__ === "function",
);
// Cold visits populate the query caches and the markdown node cache.
await page.getByTestId("channel-deep-history").click();
await settleChannel(page, "deep-history");
await page.getByTestId("channel-general").click();
await settleChannel(page, "general");
const afterCold = await parseCount(page);
// Sanity: the cold visits really parsed rows (counter is wired up).
expect(afterCold).toBeGreaterThan(10);
// Warm switches: every row must be a cache hit.
let previous = afterCold;
for (let round = 0; round < 2; round += 1) {
await page.getByTestId("channel-deep-history").click();
await settleChannel(page, "deep-history");
const inDeepHistory = await parseCount(page);
expect(
inDeepHistory - previous,
`warm switch into deep-history (round ${round}) re-parsed markdown`,
).toBe(0);
await page.getByTestId("channel-general").click();
await settleChannel(page, "general");
const inGeneral = await parseCount(page);
expect(
inGeneral - inDeepHistory,
`warm switch into general (round ${round}) re-parsed markdown`,
).toBe(0);
previous = inGeneral;
}
});
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
type E2eWindow = Window & {
__BUZZ_E2E_COMMANDS__?: string[];
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{
command: string;
payload: { request?: { mode?: string; modelId?: string } } | null;
}>;
__BUZZ_E2E_SET_MESH__?: (mesh: {
nodeState?: "off" | "running";
nodeMode?: "serve" | "client" | null;
}) => void;
};
test("Share compute chooses a model before sharing", async ({ page }) => {
const modelRef = "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M";
await installMockBridge(page);
await page.goto("/");
await openSettings(page, "compute");
const card = page.getByTestId("settings-mesh-share-compute");
const toggle = page.getByTestId("mesh-share-compute-toggle");
const model = page.getByTestId("mesh-share-compute-model");
await expect(card).not.toContainText("Not sharing right now");
await expect(
page.getByTestId("mesh-share-compute-options-motion"),
).toHaveCount(0);
await expect(
page.getByTestId("mesh-share-compute-sharing-status"),
).toHaveCount(0);
await expect(model).toBeVisible();
await expect(toggle).toBeEnabled();
await model.click();
await page.getByRole("option", { name: "Custom model…" }).click();
await page.getByLabel("Custom model reference").fill(modelRef);
await toggle.click();
await expect(
page.getByTestId("mesh-share-compute-options-motion"),
).toBeVisible();
await expect(
page.getByTestId("mesh-share-compute-sharing-status"),
).toBeVisible();
await expect(model).toBeVisible();
await expect(card).toContainText(
"Buzz downloads remote models when sharing starts",
);
await expect(toggle).toBeChecked();
await expect(
page.getByTestId("mesh-share-compute-sharing-status"),
).toContainText("SmolLM2 135M with relay members");
await expect
.poll(() =>
page.evaluate(() => (window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? []),
)
.toContain("mesh_start_node");
await expect
.poll(() =>
page.evaluate(
() => (window as E2eWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [],
),
)
.toContainEqual({
command: "mesh_start_node",
payload: {
request: { mode: "serve", modelId: modelRef },
},
});
await toggle.click();
await expect(toggle).not.toBeChecked();
await expect(card).not.toContainText("Not sharing right now");
await expect(
page.getByTestId("mesh-share-compute-options-motion"),
).toHaveCount(0);
await expect(
page.getByTestId("mesh-share-compute-sharing-status"),
).toHaveCount(0);
await expect(model).toBeVisible();
await expect
.poll(() =>
page.evaluate(() => (window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? []),
)
.toContain("mesh_stop_node");
});
test("a consuming client can switch to sharing its saved local model", async ({
page,
}) => {
// Regression: consuming someone else's shared compute starts a client-mode
// node in the single runtime slot, which reports state:"running". The Share
// toggle keyed off state alone and lit up. A later guard overcorrected by
// disabling the switch and copying the remote model over the local sharing
// choice. Keep the switch off, preserve the local model, then replace the
// client with one serve start (never a stop command).
const localModel = "hf://demo/local-small-model:Q4_K_M";
await page.addInitScript((model) => {
window.localStorage.setItem("buzz.mesh-compute.share.model.v1", model);
}, localModel);
await installMockBridge(page);
await page.goto("/");
// The mesh seed hook is installed when the mock bridge boots; calling it
// before then silently no-ops (optional chaining) and the seed is lost.
await page.waitForFunction(
() => typeof (window as E2eWindow).__BUZZ_E2E_SET_MESH__ === "function",
);
await page.evaluate(() => {
(window as E2eWindow).__BUZZ_E2E_SET_MESH__?.({
nodeState: "running",
nodeMode: "client",
});
});
await openSettings(page, "compute");
const card = page.getByTestId("settings-mesh-share-compute");
const toggle = page.getByTestId("mesh-share-compute-toggle");
await expect(card).toContainText(
"This machine is currently using another member's shared compute",
);
await expect(card).toContainText("Buzz may briefly restart");
await expect(toggle).not.toBeChecked();
await expect(
page.getByTestId("mesh-share-compute-options-motion"),
).toHaveCount(0);
await expect(toggle).toBeEnabled();
const customModel = page.getByLabel("Custom model reference");
await expect(customModel).toHaveValue(localModel);
await customModel.fill("");
await expect(customModel).toBeVisible();
await customModel.fill("hf://demo/replacement-model:Q4_K_M");
await toggle.click();
await expect(toggle).toBeChecked();
const commands = await page.evaluate(() => ({
names: (window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? [],
payloads: (window as E2eWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [],
}));
expect(commands.names).not.toContain("mesh_stop_node");
expect(commands.payloads).toContainEqual({
command: "mesh_start_node",
payload: {
request: { mode: "serve", modelId: "hf://demo/replacement-model:Q4_K_M" },
},
});
});
@@ -0,0 +1,114 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/message-feedback";
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
) {
await expect
.poll(async () => {
return page.evaluate(
({ ch }) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ??
false,
{ ch: channelName },
);
})
.toBe(true);
}
test("pending continuation keeps Sending next to its timestamp", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const sentMessage = `Message before pending state ${Date.now()}`;
const pendingMessage = `Pending message status ${Date.now()}`;
const createdAt = Math.floor(Date.now() / 1_000);
await page.evaluate(
({ firstMessage, secondMessage, timestamp }) => {
const emit = (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
createdAt: number;
pending?: boolean;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
emit?.({
channelName: "general",
content: firstMessage,
createdAt: timestamp - 1,
});
emit?.({
channelName: "general",
content: secondMessage,
createdAt: timestamp,
pending: true,
});
},
{
firstMessage: sentMessage,
secondMessage: pendingMessage,
timestamp: createdAt,
},
);
const pendingRow = page
.getByTestId("message-row")
.filter({ hasText: pendingMessage });
const status = pendingRow.getByTestId("message-send-status");
await expect(status).toHaveText("Sending…");
await expect(pendingRow.getByTestId("message-author")).toHaveCount(1);
const timestamp = status.locator("xpath=../p[1]");
const [timestampBox, statusBox] = await Promise.all([
timestamp.boundingBox(),
status.boundingBox(),
]);
expect(timestampBox).not.toBeNull();
expect(statusBox).not.toBeNull();
if (!timestampBox || !statusBox) {
throw new Error("Pending message metadata is missing its inline layout.");
}
expect(statusBox.x).toBeGreaterThan(timestampBox.x);
expect(Math.abs(statusBox.y - timestampBox.y)).toBeLessThanOrEqual(1);
await waitForAnimations(page);
await pendingRow.screenshot({ path: `${SHOTS}/pending-message-inline.png` });
});
test("profile hover uses the channel hover surface", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
const profile = page.getByTestId("sidebar-profile-card");
const channel = page.getByTestId("channel-random");
await channel.hover();
const channelHoverColor = await channel.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await profile.hover();
await expect(profile).toHaveCSS("background-color", channelHoverColor);
await waitForAnimations(page);
await page
.getByTestId("app-sidebar")
.screenshot({ path: `${SHOTS}/profile-hover.png` });
});
File diff suppressed because it is too large Load Diff
+451
View File
@@ -0,0 +1,451 @@
import { expect, test, type Page } from "@playwright/test";
import { mkdirSync } from "node:fs";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
const SCREENSHOT_DIR = "test-results/mobile-pairing-qr";
test.beforeEach(async ({ page }) => {
await installMockBridge(page, { pairingStartDelayMs: 300 });
});
async function emitPairingEvent(page: Page, event: string, payload?: unknown) {
await page.evaluate(
async ({ eventName, eventPayload }) => {
const internals = (
window as Window & {
__TAURI_INTERNALS__?: {
invoke?: (
command: string,
args: Record<string, unknown>,
) => Promise<unknown>;
};
}
).__TAURI_INTERNALS__;
if (!internals?.invoke) {
throw new Error("Tauri E2E event bridge is unavailable");
}
await internals.invoke("plugin:event|emit", {
event: eventName,
payload: eventPayload,
});
},
{ eventName: event, eventPayload: payload },
);
}
test("mobile pairing starts on demand and reveals the QR code", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await page.getByTestId("settings-nav-mobile").click();
mkdirSync(SCREENSHOT_DIR, { recursive: true });
const section = page.getByTestId("settings-mobile");
const card = page.getByTestId("mobile-pairing-card");
const layout = card.getByTestId("mobile-pairing-layout");
const qrContainer = page.getByTestId("mobile-pairing-qr-container");
const steps = card.getByTestId("mobile-pairing-steps");
const scanStepIndicator = card.getByTestId(
"mobile-pairing-scan-step-indicator",
);
const confirmStepIndicator = card.getByTestId(
"mobile-pairing-confirm-step-indicator",
);
const finalStep = card.getByTestId("mobile-pairing-final-step");
const startButton = card.getByTestId("start-pairing-button");
await expect(card).toBeVisible();
await expect(startButton).toHaveText("Start pairing");
await expect(steps.getByText("Scan QR code", { exact: true })).toBeVisible();
await expect(
steps.getByText("Confirm mobile code", { exact: true }),
).toBeVisible();
await expect(
finalStep.getByText("Pair your mobile app", { exact: true }),
).toBeVisible();
const finalStepIndicator = finalStep.getByTestId(
"mobile-pairing-final-step-indicator",
);
await expect(finalStepIndicator).toHaveText("3");
await expect(scanStepIndicator).toHaveAttribute("data-completed", "false");
await expect(confirmStepIndicator).toHaveAttribute("data-completed", "false");
await expect(finalStepIndicator).toHaveCSS("width", "48px");
await expect(finalStepIndicator).toHaveCSS("height", "48px");
expect(
await finalStepIndicator.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).borderRadius),
),
).toBeGreaterThanOrEqual(24);
const indicatorBackground = await finalStepIndicator.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
const primaryActionBackground = await startButton.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
expect(indicatorBackground).not.toBe(primaryActionBackground);
await expect(layout).toHaveCSS("padding-top", "60px");
await expect(layout).toHaveCSS("padding-right", "60px");
await expect(layout).toHaveCSS("padding-left", "60px");
await expect(layout).toHaveCSS("column-gap", "56px");
await expect(page.getByTestId("mobile-pairing-qr")).toHaveCount(0);
await expect(page.getByTestId("copy-pairing-code")).toHaveCount(0);
expect(
await page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
(entry) => entry.command === "start_pairing",
).length,
),
).toBe(0);
const sectionBox = await section.boundingBox();
const cardBox = await card.boundingBox();
const initialStepsBox = await steps.boundingBox();
expect(sectionBox).not.toBeNull();
expect(cardBox).not.toBeNull();
expect(initialStepsBox).not.toBeNull();
const initialQrBox = await qrContainer.boundingBox();
expect(initialQrBox).not.toBeNull();
const qrTopSpace = (initialQrBox?.y ?? 0) - (cardBox?.y ?? 0);
const qrLeftSpace = (initialQrBox?.x ?? 0) - (cardBox?.x ?? 0);
const qrBottomSpace =
(cardBox?.y ?? 0) +
(cardBox?.height ?? 0) -
((initialQrBox?.y ?? 0) + (initialQrBox?.height ?? 0));
expect(Math.abs(qrTopSpace - qrLeftSpace)).toBeLessThan(0.5);
expect(Math.abs(qrTopSpace - qrBottomSpace)).toBeLessThan(0.5);
const sectionCenter = (sectionBox?.x ?? 0) + (sectionBox?.width ?? 0) / 2;
const cardCenter = (cardBox?.x ?? 0) + (cardBox?.width ?? 0) / 2;
expect(Math.abs(sectionCenter - cardCenter)).toBeLessThan(0.5);
await waitForAnimations(page);
await card.screenshot({ path: `${SCREENSHOT_DIR}/pairing-start.png` });
await startButton.click();
const loadingSpinner = card.getByTestId("pairing-loading-spinner");
await expect(loadingSpinner).toBeVisible();
await expect(loadingSpinner).toHaveCSS("animation-name", "spin");
const qrCode = page.getByTestId("mobile-pairing-qr");
const copyButton = card.getByTestId("copy-pairing-code");
await expect(qrCode).toBeVisible();
await expect(copyButton).toHaveText("Copy pairing code");
await expect(startButton).toHaveCount(0);
await expect(card.getByText("Pair mobile device")).toHaveCount(0);
await expect(page.getByTestId("mobile-pairing-dialog")).toHaveCount(0);
await expect(
page.getByText("Securely transfer your identity via NIP-AB protocol"),
).toHaveCount(0);
await expect(qrCode).toHaveAttribute("data-qr-matrix-size", "57");
await expect(qrCode.locator("[data-qr-finder-pattern]")).toHaveCount(3);
expect(await qrCode.locator("circle").count()).toBeGreaterThan(100);
expect(await qrCode.locator(".buzz-qr-cell-reveal").count()).toBeGreaterThan(
100,
);
await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS(
"animation-name",
"buzz-qr-cell-reveal",
);
await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS(
"animation-duration",
"0.058s",
);
await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS(
"animation-timing-function",
"linear",
);
await expect(
qrCode.locator('[data-qr-cell-row="56"].buzz-qr-cell-reveal').first(),
).toHaveCSS("animation-delay", "0.189s");
await expect(copyButton).toHaveCSS("animation-name", "enter");
await expect(copyButton).toHaveCSS("animation-duration", "0.25s");
await expect(qrCode.locator("image")).toHaveAttribute(
"href",
"/app-icon@2x.png",
);
await waitForAnimations(page);
const qrBox = await qrContainer.boundingBox();
const copyBox = await copyButton.boundingBox();
const stepsBox = await steps.boundingBox();
const qrCardBox = await card.boundingBox();
expect(qrBox).not.toBeNull();
expect(copyBox).not.toBeNull();
expect(stepsBox).not.toBeNull();
expect(qrCardBox).not.toBeNull();
expect(qrBox?.x ?? 0).toBeLessThan(stepsBox?.x ?? 0);
expect(Math.abs((stepsBox?.y ?? 0) - (initialStepsBox?.y ?? 0))).toBeLessThan(
0.5,
);
expect(
Math.abs((qrCardBox?.height ?? 0) - (cardBox?.height ?? 0)),
).toBeLessThan(0.5);
expect(copyBox?.y ?? 0).toBeGreaterThan(
(qrBox?.y ?? 0) + (qrBox?.height ?? 0),
);
expect(Math.abs((copyBox?.x ?? 0) - (qrBox?.x ?? 0))).toBeLessThan(0.5);
expect(Math.abs((copyBox?.width ?? 0) - (qrBox?.width ?? 0))).toBeLessThan(
0.5,
);
await emitPairingEvent(page, "pairing-error", {
message: "Session timed out",
});
await expect(qrCode).toHaveCount(0);
await expect(copyButton).toHaveCount(0);
await expect(card.getByText("Pairing code expired.")).toBeVisible();
const regenerateButton = card.getByTestId("regenerate-pairing-button");
await expect(regenerateButton).toHaveText("Generate new pairing code");
await waitForAnimations(page);
await card.screenshot({ path: `${SCREENSHOT_DIR}/pairing-expired.png` });
await regenerateButton.click();
await expect(loadingSpinner).toBeVisible();
await expect(qrCode).toBeVisible();
await expect(copyButton).toBeVisible();
expect(
await page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
(entry) => entry.command === "start_pairing",
).length,
),
).toBe(2);
await waitForAnimations(page);
await card.screenshot({ path: `${SCREENSHOT_DIR}/pairing-card.png` });
await qrCode.screenshot({ path: `${SCREENSHOT_DIR}/pairing-qr.png` });
});
test("pairing completion updates the final step and resets after leaving", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await page.getByTestId("settings-nav-mobile").click();
const card = page.getByTestId("mobile-pairing-card");
const finalStep = card.getByTestId("mobile-pairing-final-step");
const scanStepIndicator = card.getByTestId(
"mobile-pairing-scan-step-indicator",
);
const confirmStepIndicator = card.getByTestId(
"mobile-pairing-confirm-step-indicator",
);
await card.getByTestId("start-pairing-button").click();
await expect(page.getByTestId("mobile-pairing-qr")).toBeVisible();
await emitPairingEvent(page, "pairing-sas-received", { sas: "123456" });
const confirmation = card.getByTestId("mobile-pairing-code-confirmation");
await expect(scanStepIndicator).toHaveAttribute("data-completed", "true");
await expect(scanStepIndicator.locator('[data-state="complete"]')).toHaveCSS(
"opacity",
"1",
);
await expect(scanStepIndicator.locator("svg")).toHaveCount(1);
await expect(confirmStepIndicator).toHaveAttribute("data-completed", "false");
await expect(page.getByTestId("mobile-pairing-dialog")).toHaveCount(0);
await expect(page.getByTestId("mobile-pairing-qr")).toHaveCount(0);
const confirmationCode = confirmation.getByTestId("pairing-sas-code");
await expect(confirmationCode).toHaveAccessibleName(
"Confirmation code 123 456",
);
await expect(
confirmationCode.locator('[data-testid^="pairing-sas-code-digit-"]'),
).toHaveCount(6);
await expect(
confirmationCode.getByTestId("pairing-sas-code-digit-1"),
).toHaveCSS("border-radius", "12px");
await expect(
confirmationCode.getByTestId("pairing-sas-code-digit-1"),
).toHaveCSS("box-shadow", "none");
const firstCodeDigit = confirmationCode.getByTestId(
"pairing-sas-code-digit-1",
);
const firstCodeDigitBox = await firstCodeDigit.boundingBox();
expect(firstCodeDigitBox).not.toBeNull();
expect(firstCodeDigitBox?.width ?? 0).toBeGreaterThan(32);
expect(firstCodeDigitBox?.height ?? 0).toBeGreaterThan(48);
const secondCodeDigitBox = await confirmationCode
.getByTestId("pairing-sas-code-digit-2")
.boundingBox();
const thirdCodeDigitBox = await confirmationCode
.getByTestId("pairing-sas-code-digit-3")
.boundingBox();
const fourthCodeDigitBox = await confirmationCode
.getByTestId("pairing-sas-code-digit-4")
.boundingBox();
expect(secondCodeDigitBox).not.toBeNull();
expect(thirdCodeDigitBox).not.toBeNull();
expect(fourthCodeDigitBox).not.toBeNull();
const regularDigitGap =
(thirdCodeDigitBox?.x ?? 0) -
((secondCodeDigitBox?.x ?? 0) + (secondCodeDigitBox?.width ?? 0));
const groupedDigitGap =
(fourthCodeDigitBox?.x ?? 0) -
((thirdCodeDigitBox?.x ?? 0) + (thirdCodeDigitBox?.width ?? 0));
expect(groupedDigitGap).toBeGreaterThan(regularDigitGap);
await expect(card.getByTestId("mobile-pairing-qr-container")).toHaveCSS(
"border-color",
"rgba(0, 0, 0, 0)",
);
const confirmButton = confirmation.getByTestId("confirm-sas");
const cancelButton = confirmation.getByTestId("deny-sas");
const confirmationBox = await confirmation.boundingBox();
const confirmationTitleBox = await confirmation
.getByTestId("pairing-sas-title")
.boundingBox();
const confirmationCodeBox = await confirmationCode.boundingBox();
const confirmationActionsBox = await confirmation
.getByTestId("pairing-sas-actions")
.boundingBox();
expect(confirmationBox).not.toBeNull();
expect(confirmationTitleBox).not.toBeNull();
expect(confirmationCodeBox).not.toBeNull();
expect(confirmationActionsBox).not.toBeNull();
expect(
Math.abs((confirmationTitleBox?.y ?? 0) - (confirmationBox?.y ?? 0)),
).toBeLessThan(0.5);
const codeCenter =
(confirmationCodeBox?.y ?? 0) + (confirmationCodeBox?.height ?? 0) / 2;
const availableCodeCenter =
((confirmationTitleBox?.y ?? 0) +
(confirmationTitleBox?.height ?? 0) +
(confirmationActionsBox?.y ?? 0)) /
2;
expect(Math.abs(availableCodeCenter - codeCenter)).toBeLessThan(0.5);
expect(
Math.abs(
(confirmationActionsBox?.y ?? 0) +
(confirmationActionsBox?.height ?? 0) -
((confirmationBox?.y ?? 0) + (confirmationBox?.height ?? 0)),
),
).toBeLessThan(0.5);
await expect(confirmButton).toHaveCSS("height", "36px");
await expect(cancelButton).toHaveCSS("height", "36px");
await expect(confirmButton.locator("svg")).toHaveCount(1);
await expect(cancelButton.locator("svg")).toHaveCount(0);
const confirmBox = await confirmButton.boundingBox();
const cancelBox = await cancelButton.boundingBox();
expect(confirmBox).not.toBeNull();
expect(cancelBox).not.toBeNull();
expect(confirmBox?.y ?? 0).toBeLessThan(cancelBox?.y ?? 0);
await expect(
confirmation.getByText(/Only confirm if you started this pairing/),
).toHaveCount(0);
await expect(confirmation.getByTestId("pairing-sas-title")).toHaveCSS(
"font-size",
"16px",
);
mkdirSync(SCREENSHOT_DIR, { recursive: true });
await waitForAnimations(page);
await card.screenshot({
path: `${SCREENSHOT_DIR}/pairing-code-confirmation.png`,
});
await confirmation.getByTestId("confirm-sas").click();
await expect(card.getByText("Pairing mobile device...")).toBeVisible();
await expect(confirmStepIndicator).toHaveAttribute("data-completed", "true");
await expect(
confirmStepIndicator.locator('[data-state="complete"]'),
).toHaveCSS("opacity", "1");
await expect(confirmStepIndicator.locator("svg")).toHaveCount(1);
await emitPairingEvent(page, "pairing-complete");
await expect(finalStep.getByText("Paired", { exact: true })).toBeVisible();
await expect(
finalStep.getByText("Your mobile app is now connected to this relay."),
).toBeVisible();
await expect(
finalStep.getByTestId("mobile-pairing-final-step-indicator").locator("svg"),
).toHaveCount(1);
await expect(
finalStep.getByTestId("mobile-pairing-final-step-indicator"),
).toHaveAttribute("data-completed", "true");
const pairedSurface = card.getByTestId("mobile-pairing-qr-container");
await expect(
pairedSurface.getByText("Paired", { exact: true }),
).toBeVisible();
await expect(
pairedSurface.getByText("Your mobile app is now connected to this relay."),
).toHaveCount(0);
await waitForAnimations(page);
await card.screenshot({ path: `${SCREENSHOT_DIR}/pairing-complete.png` });
await page.getByTestId("settings-nav-updates").click();
await page.getByTestId("settings-nav-mobile").click();
const restartedCard = page.getByTestId("mobile-pairing-card");
await expect(restartedCard.getByTestId("start-pairing-button")).toBeVisible();
await expect(
restartedCard
.getByTestId("mobile-pairing-final-step")
.getByText("Pair your mobile app", { exact: true }),
).toBeVisible();
await expect(page.getByTestId("mobile-pairing-qr")).toHaveCount(0);
});
test("late pairing events are ignored after canceling", async ({ page }) => {
await page.goto("/");
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await page.getByTestId("settings-nav-mobile").click();
const card = page.getByTestId("mobile-pairing-card");
await card.getByTestId("start-pairing-button").click();
await expect(page.getByTestId("mobile-pairing-qr")).toBeVisible();
await emitPairingEvent(page, "pairing-sas-received", { sas: "123456" });
const confirmation = card.getByTestId("mobile-pairing-code-confirmation");
await expect(confirmation).toBeVisible();
await confirmation.getByTestId("deny-sas").click();
await expect(confirmation).toHaveCount(0);
await expect(
card.getByText("The codes didn't match. Pairing was canceled."),
).toBeVisible();
await emitPairingEvent(page, "pairing-complete");
await emitPairingEvent(page, "pairing-sas-received", { sas: "654321" });
await expect(confirmation).toHaveCount(0);
await expect(
card
.getByTestId("mobile-pairing-final-step")
.getByText("Paired", { exact: true }),
).toHaveCount(0);
await expect(
card.getByText("The codes didn't match. Pairing was canceled."),
).toBeVisible();
});
test("step completion respects reduced motion", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/");
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await page.getByTestId("settings-nav-mobile").click();
const card = page.getByTestId("mobile-pairing-card");
const scanStepIndicator = card.getByTestId(
"mobile-pairing-scan-step-indicator",
);
await card.getByTestId("start-pairing-button").click();
await expect(page.getByTestId("mobile-pairing-qr")).toBeVisible();
await emitPairingEvent(page, "pairing-sas-received", { sas: "123456" });
await expect(scanStepIndicator).toHaveAttribute("data-completed", "true");
await expect(scanStepIndicator).toHaveCSS("transition-property", "none");
const completedContent = scanStepIndicator.locator('[data-state="complete"]');
await expect(completedContent).toHaveCSS("opacity", "1");
await expect(completedContent).toHaveCSS("transform", "none");
});
+427
View File
@@ -0,0 +1,427 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
const ENGINEERING_CHANNEL_ID = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9";
const WATERCOLOR_CHANNEL_ID = "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11";
const FORUM_POST_ID = "mock-forum-release-thread";
const FORUM_REPLY_ID = "mock-forum-release-reply";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
async function navigateToWorkflows(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("open-workflows-view").click();
await expect(page).toHaveURL(/#\/workflows$/);
await expect(page.getByTestId("workflows-view")).toBeVisible();
}
async function createWorkflow(
page: import("@playwright/test").Page,
name: string,
) {
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await dialog.getByLabel("Workflow name").fill(name);
await dialog.getByRole("button", { name: "Add step" }).click();
await dialog.getByRole("button", { name: "Create" }).click();
await expect(dialog).not.toBeVisible();
}
test("global back and forward move across channel routes", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("global-back").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("global-forward").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
});
test("back/forward keyboard chords work while the composer has focus", async ({
page,
}) => {
const backChord = process.platform === "darwin" ? "Meta+[" : "Alt+ArrowLeft";
const forwardChord =
process.platform === "darwin" ? "Meta+]" : "Alt+ArrowRight";
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
// The composer autofocuses on channel switch; make the regression
// condition explicit by clicking into it. The chords must still fire
// from inside the contenteditable (#3775).
await page.getByTestId("message-input").click();
await expect(page.getByTestId("message-input")).toBeFocused();
await page.keyboard.press(backChord);
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.keyboard.press(forwardChord);
await expect(page.getByTestId("chat-title")).toHaveText("random");
// preventDefault kept the chord out of the editor — no stray characters.
await expect(page.getByTestId("message-input")).toHaveText("");
});
// FIXME: the forum post "Back to posts" header renders under the fixed top
// chrome drag region, which intercepts the click. Pre-existing breakage —
// this spec file was never registered in playwright.config.ts until now.
// The header-chrome rework (PR #941) covers this overlap class.
test.fixme("direct forum thread links close back to the forum route", async ({
page,
}) => {
await page.goto(
`/#/channels/${WATERCOLOR_CHANNEL_ID}/posts/${FORUM_POST_ID}`,
);
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
await expect(
page.getByRole("button", { name: "Back to posts" }),
).toBeVisible();
await page.getByRole("button", { name: "Back to posts" }).click();
await expect(page).toHaveURL(
/#\/channels\/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11$/,
);
await expect(
page.getByText("Release checklist: async feedback thread."),
).toBeVisible();
});
test("direct workflow detail links close back to workflows", async ({
page,
}) => {
const workflowName = `workflow_nav_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
const workflowCard = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
const workflowTestId = await workflowCard.getAttribute("data-testid");
const workflowId = workflowTestId?.replace("workflow-card-", "");
expect(workflowId).toBeTruthy();
await page.goto(`/#/workflows/${workflowId}`);
await expect(page.getByTestId("workflow-detail-panel")).toBeVisible();
await page.getByRole("button", { name: "Close detail panel" }).click();
await expect(page).toHaveURL(/#\/workflows$/);
await expect(page.getByTestId("workflows-view")).toBeVisible();
});
test("forum reply deep links survive reload", async ({ page }) => {
await page.goto(
`/#/channels/${WATERCOLOR_CHANNEL_ID}/posts/${FORUM_POST_ID}?replyId=${FORUM_REPLY_ID}`,
);
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
await expect(
page.getByText("Looks good to me. We should ship it."),
).toBeVisible();
await page.reload();
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
await expect(
page.getByText("Looks good to me. We should ship it."),
).toBeVisible();
});
test("back and forward restore open thread panels", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const rootMessage = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.first();
await rootMessage.hover();
await rootMessage.getByRole("button", { name: "Reply" }).click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await expect(page).toHaveURL(/thread=/);
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(threadPanel).not.toBeVisible();
await page.getByTestId("global-back").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(threadPanel).toBeVisible();
await page.getByTestId("global-forward").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(threadPanel).not.toBeVisible();
});
test("back undoes closing a thread panel", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const rootMessage = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.first();
await rootMessage.hover();
await rootMessage.getByRole("button", { name: "Reply" }).click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await threadPanel.getByRole("button", { name: "Close panel" }).click();
await expect(threadPanel).not.toBeVisible();
await page.getByTestId("global-back").click();
await expect(threadPanel).toBeVisible();
});
test("open thread panels survive reload", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const rootMessage = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.first();
await rootMessage.hover();
await rootMessage.getByRole("button", { name: "Reply" }).click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await expect(page).toHaveURL(/thread=/);
await page.reload();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(threadPanel).toBeVisible();
});
test("home inbox selection survives reload and back restores it", async ({
page,
}) => {
await page.goto("/");
const inboxList = page.getByTestId("home-inbox-list");
await expect(inboxList).toBeVisible();
const items = inboxList.locator('[data-testid^="home-inbox-item-"]');
await expect(items.first()).toBeVisible();
// The wide-viewport default selection stays local-only — the URL records
// explicit selections, so background loads never touch the history stack.
await expect(page.getByTestId("home-inbox-detail")).toBeVisible();
expect(page.url()).not.toContain("item=");
const defaultUrl = page.url();
const selectedItem = items.first();
const selectedTestId = await selectedItem.getAttribute("data-testid");
const selectedItemId = selectedTestId?.replace("home-inbox-item-", "");
expect(selectedItemId).toBeTruthy();
await selectedItem.click();
await expect
.poll(() => page.url())
.toContain(`item=${encodeURIComponent(selectedItemId ?? "")}`);
await page.reload();
await expect(inboxList).toBeVisible();
await expect(page.getByTestId("home-inbox-detail")).toBeVisible();
expect(page.url()).toContain(
`item=${encodeURIComponent(selectedItemId ?? "")}`,
);
await page.getByTestId("global-back").click();
await expect.poll(() => page.url()).toBe(defaultUrl);
});
test("settings is a route: section survives reload, closing returns to the previous panel state", async ({
page,
}) => {
await page.goto("/");
// Open a channel with a thread panel so there's panel state to come back to.
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const rootMessage = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.first();
await rootMessage.hover();
await rootMessage.getByRole("button", { name: "Reply" }).click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
const channelUrl = page.url();
await openSettings(page);
await expect(page).toHaveURL(/#\/settings/);
// Section switches rewrite the settings entry (replace, not push).
await page.getByTestId("settings-nav-notifications").click();
await expect(page).toHaveURL(/section=notifications/);
await page.reload();
await expect(page.getByTestId("settings-view")).toBeVisible();
await expect(page).toHaveURL(/section=notifications/);
await page.getByTestId("settings-back-to-app").click();
await expect.poll(() => page.url()).toBe(channelUrl);
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(threadPanel).toBeVisible();
});
test("settings shortcut returns without opening search dialog", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const channelUrl = page.url();
// Open search via the ⌘K shortcut so the focus-request counter is non-zero,
// then close it. The Settings subtree remounts the sidebar + search on close,
// which must not replay the stale counter and resurrect search.
await page.evaluate(() => {
const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform);
window.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
code: "KeyK",
ctrlKey: !isMac,
key: "k",
metaKey: isMac,
}),
);
});
await expect(page.getByTestId("search-dialog-input")).toBeFocused();
await page.keyboard.press("Escape");
await expect(page.getByTestId("search-results")).not.toBeVisible();
await page.keyboard.press(
process.platform === "darwin" ? "Meta+Comma" : "Control+Comma",
);
await expect(page).toHaveURL(/#\/settings/);
await page.getByTestId("settings-back-to-app").click();
await expect.poll(() => page.url()).toBe(channelUrl);
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("search-results")).not.toBeVisible();
});
test("message links to visible root messages open the thread panel", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-timeline")).toContainText(
"Welcome to #general",
);
const link =
"buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=mock-general-welcome";
await page.getByTestId("message-input").fill(`Root link repro ${link}`);
await page.getByTestId("send-message").click();
const linkMessage = page
.getByTestId("message-row")
.filter({ hasText: "Root link repro" })
.last();
await expect(linkMessage).toBeVisible();
await linkMessage
.getByRole("button", { name: "Open message in general" })
.click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await expect(page).toHaveURL(/thread=mock-general-welcome/);
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
"Welcome to #general",
);
});
test("message links reopen a closed thread when the same messageId is already in the URL", async ({
page,
}) => {
await page.goto(
"/#/channels/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50?messageId=mock-general-welcome",
);
await expect(page.getByTestId("chat-title")).toHaveText("general");
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
"Welcome to #general",
);
await threadPanel.getByRole("button", { name: "Close panel" }).click();
await expect(threadPanel).not.toBeVisible();
const link =
"buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=mock-general-welcome";
await page
.getByTestId("message-input")
.fill(`Reopen same root link repro ${link}`);
await page.getByTestId("send-message").click();
const linkMessage = page
.getByTestId("message-row")
.filter({ hasText: "Reopen same root link repro" })
.last();
await expect(linkMessage).toBeVisible();
await linkMessage
.getByRole("button", { name: "Open message in general" })
.click();
await expect(threadPanel).toBeVisible();
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
"Welcome to #general",
);
});
test("message deep links survive reload", async ({ page }) => {
await page.goto(
`/#/channels/${ENGINEERING_CHANNEL_ID}?messageId=mock-engineering-shipped`,
);
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
await expect(page.getByTestId("message-timeline")).toContainText(
"Engineering shipped the desktop build.",
);
await page.reload();
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
await expect(page.getByTestId("message-timeline")).toContainText(
"Engineering shipped the desktop build.",
);
});
@@ -0,0 +1,562 @@
/**
* Screenshot spec for the needsRestart badge and config diff (PR #1853 + diff
* overlay work).
*
* Exercises:
* - Agent grid restart actions without a duplicate status badge.
* - Profile badge tooltip with itemised beforeafter diff.
* - Runtime-tab banner with full uncapped diff list.
* - Side-panel badge visible on the default (Info) tab not only Runtime.
* - DOM validity: tooltip trigger has no <button> ancestor.
* - Generic rendering: unknown field ids, number/array values, masked values.
*/
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
const SHOTS = "test-results/restart-diff-screenshots";
// ── Sample diff entries ───────────────────────────────────────────────────────
const DIFF_ENTRIES = [
{
field: "model",
change: {
kind: "value" as const,
before: "gpt-4",
after: "claude-3-5-sonnet",
},
},
{
field: "system_prompt",
change: { kind: "text" as const, before_chars: 512, after_chars: 1024 },
},
{
field: "env.OPENAI_API_KEY",
change: { kind: "masked" as const, before: "••••abc1", after: "••••xyz9" },
},
{ field: "env.BUZZ_LOG", change: { kind: "added" as const } },
{
field: "relay_url",
change: { kind: "masked" as const, before: "••••", after: "••••" },
},
// Array value — args are atomic; rendered via JSON.stringify (position 6: last visible in tooltip)
{
field: "agent_args",
change: {
kind: "value" as const,
before: ["acp"],
after: ["acp", "--verbose"],
},
},
// 7th entry — truncated in tooltip (cap is 6); visible in uncapped banner
{
field: "parallelism",
change: { kind: "value" as const, before: 1, after: 4 },
},
// 8th entry — truncated in tooltip; visible in uncapped banner
{
field: "args",
change: { kind: "masked" as const, before: "••••", after: "••••" },
},
];
const STANDALONE_AGENT = {
pubkey: TEST_IDENTITIES.alice.pubkey,
name: "Local Agent",
status: "running" as const,
needsRestart: true,
restartDiff: DIFF_ENTRIES,
};
const PERSONA_AGENT = {
pubkey: TEST_IDENTITIES.bob.pubkey,
name: "Persona Agent",
personaId: "builtin:fizz",
status: "running" as const,
needsRestart: true,
restartDiff: DIFF_ENTRIES,
};
/** Running agent with no config drift — restart action must be absent. */
const NO_DRIFT_AGENT = {
pubkey: TEST_IDENTITIES.tyler.pubkey,
name: "Stable Agent",
status: "running" as const,
needsRestart: false,
};
/**
* Inactive agent with a friendly error AND a restart diff. Opening its card
* forces the panel to open on the Runtime tab (opensRuntimeTab logic).
* Used to assert: Runtime tab is active, hero badge visible, uncapped banner.
*/
const INACTIVE_FRIENDLY_ERROR_AGENT = {
pubkey: TEST_IDENTITIES.outsider.pubkey,
name: "Error Restart Agent",
status: "stopped" as const,
needsRestart: true,
restartDiff: DIFF_ENTRIES,
lastError: "Agent reported error (code -32002): llm model not found",
lastErrorCode: -32002,
};
const RESTART_AGENT = {
pubkey: "cd".repeat(32),
name: "Restart Agent",
status: "running" as const,
needsRestart: true,
restartDiff: DIFF_ENTRIES,
};
const START_AGENT = {
pubkey: "ef".repeat(32),
name: "Start Agent",
status: "stopped" as const,
needsRestart: false,
};
async function gotoAgentsView(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("open-agents-view")).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId("agents-library-personas")).toBeVisible({
timeout: 10_000,
});
}
const WCAG_AA_NORMAL_TEXT_CONTRAST = 4.5;
async function renderedTextContrast(
locator: import("@playwright/test").Locator,
): Promise<number> {
return locator.evaluate((element) => {
type Rgba = { r: number; g: number; b: number; a: number };
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("Could not create contrast test canvas");
const parseColor = (color: string): Rgba => {
context.clearRect(0, 0, 1, 1);
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
const [r, g, b, alpha] = context.getImageData(0, 0, 1, 1).data;
return { r, g, b, a: alpha / 255 };
};
const composite = (foreground: Rgba, background: Rgba): Rgba => {
const alpha = foreground.a + background.a * (1 - foreground.a);
if (alpha === 0) return { r: 0, g: 0, b: 0, a: 0 };
return {
r:
(foreground.r * foreground.a +
background.r * background.a * (1 - foreground.a)) /
alpha,
g:
(foreground.g * foreground.a +
background.g * background.a * (1 - foreground.a)) /
alpha,
b:
(foreground.b * foreground.a +
background.b * background.a * (1 - foreground.a)) /
alpha,
a: alpha,
};
};
const backgroundLayers: Rgba[] = [];
let current: Element | null = element;
while (current) {
backgroundLayers.push(
parseColor(window.getComputedStyle(current).backgroundColor),
);
current = current.parentElement;
}
let renderedBackground: Rgba = { r: 255, g: 255, b: 255, a: 1 };
for (const layer of backgroundLayers.reverse()) {
renderedBackground = composite(layer, renderedBackground);
}
const renderedForeground = composite(
parseColor(window.getComputedStyle(element).color),
renderedBackground,
);
const luminance = (color: Rgba) => {
const channels = [color.r, color.g, color.b].map((value) => {
const channel = value / 255;
return channel <= 0.04045
? channel / 12.92
: ((channel + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
};
const foregroundLuminance = luminance(renderedForeground);
const backgroundLuminance = luminance(renderedBackground);
return (
(Math.max(foregroundLuminance, backgroundLuminance) + 0.05) /
(Math.min(foregroundLuminance, backgroundLuminance) + 0.05)
);
});
}
test.describe("restart-diff screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
});
// ── Badge presence ──────────────────────────────────────────────────────────
test("01-grid-standalone-restart-badge", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [STANDALONE_AGENT, NO_DRIFT_AGENT],
});
await gotoAgentsView(page);
const agentCard = page.getByTestId(
`managed-agent-${STANDALONE_AGENT.pubkey}`,
);
await expect(agentCard).toBeVisible({ timeout: 10_000 });
await expect(agentCard.getByText("Restart", { exact: true })).toBeVisible();
await expect(agentCard.getByTestId("restart-diff-badge")).toHaveCount(0);
const stableCard = page.getByTestId(
`managed-agent-${NO_DRIFT_AGENT.pubkey}`,
);
await expect(stableCard).toBeVisible({ timeout: 10_000 });
await expect(stableCard.getByText("Restart", { exact: true })).toHaveCount(
0,
);
await waitForAnimations(page);
await agentCard.screenshot({
path: `${SHOTS}/01-grid-standalone-restart-badge.png`,
});
});
test("02-grid-persona-restart-badge", async ({ page }) => {
await installMockBridge(page, {
activePersonaIds: ["builtin:fizz"],
managedAgents: [PERSONA_AGENT],
});
await gotoAgentsView(page);
const personaCard = page.getByTestId(
`persona-agent-row-${PERSONA_AGENT.personaId}`,
);
await expect(personaCard).toBeVisible({ timeout: 10_000 });
await expect(
personaCard.getByText("Restart", { exact: true }),
).toBeVisible();
await expect(personaCard.getByTestId("restart-diff-badge")).toHaveCount(0);
await waitForAnimations(page);
await personaCard.screenshot({
path: `${SHOTS}/02-grid-persona-restart-badge.png`,
});
});
test("03-running-restart-action", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [RESTART_AGENT],
});
await gotoAgentsView(page);
const agentCard = page.getByTestId(`managed-agent-${RESTART_AGENT.pubkey}`);
await expect(agentCard).toBeVisible({ timeout: 10_000 });
const restartAction = page.getByTestId(
`agent-runtime-start-${RESTART_AGENT.pubkey}`,
);
await expect(restartAction).toHaveText("Restart");
await expect(restartAction).toHaveAttribute("aria-label", "Restart Agent");
await expect(restartAction).toHaveClass(/bg-transparent/);
await expect(restartAction).toHaveClass(/text-amber-800/);
await expect(restartAction).toHaveClass(/dark:text-amber-400/);
await expect(restartAction.locator("svg")).toHaveCount(0);
await expect(restartAction).toHaveCSS("width", "72px");
await expect(restartAction).toHaveCSS("height", "36px");
await expect(agentCard.getByTestId("restart-diff-badge")).toHaveCount(0);
await expect(page.locator("html")).toHaveClass(/light/);
expect(await renderedTextContrast(restartAction)).toBeGreaterThanOrEqual(
WCAG_AA_NORMAL_TEXT_CONTRAST,
);
await waitForAnimations(page);
await agentCard.screenshot({
path: `${SHOTS}/03-running-restart-action.png`,
});
});
test("restart action meets WCAG AA contrast in dark mode", async ({
page,
}) => {
await page.addInitScript(() => {
window.localStorage.setItem("buzz-theme", "catppuccin-mocha");
});
await installMockBridge(page, {
managedAgents: [RESTART_AGENT],
});
await gotoAgentsView(page);
const restartAction = page.getByTestId(
`agent-runtime-start-${RESTART_AGENT.pubkey}`,
);
await expect(restartAction).toBeVisible();
await expect(page.locator("html")).toHaveClass(/dark/);
expect(await renderedTextContrast(restartAction)).toBeGreaterThanOrEqual(
WCAG_AA_NORMAL_TEXT_CONTRAST,
);
});
test("start and restart pills share geometry except for width", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [START_AGENT, RESTART_AGENT],
});
await gotoAgentsView(page);
const startAction = page.getByTestId(
`agent-runtime-start-${START_AGENT.pubkey}`,
);
const restartAction = page.getByTestId(
`agent-runtime-start-${RESTART_AGENT.pubkey}`,
);
await expect(startAction).toHaveText("Start");
await expect(restartAction).toHaveText("Restart");
const relativeGeometry = async (locator: typeof startAction) =>
locator.evaluate((element) => {
const frame = element.parentElement?.parentElement?.parentElement;
if (!frame) throw new Error("Could not resolve avatar frame");
const bounds = element.getBoundingClientRect();
const frameBounds = frame.getBoundingClientRect();
return {
centerX: bounds.x + bounds.width / 2 - frameBounds.x,
centerY: bounds.y + bounds.height / 2 - frameBounds.y,
height: bounds.height,
width: bounds.width,
};
});
const [start, restart] = await Promise.all([
relativeGeometry(startAction),
relativeGeometry(restartAction),
]);
expect(start.height).toBeCloseTo(restart.height, 2);
expect(start.centerX).toBeCloseTo(restart.centerX, 2);
expect(start.centerY).toBeCloseTo(restart.centerY, 2);
expect(start.width).toBeCloseTo(56, 2);
expect(restart.width).toBeCloseTo(72, 2);
});
// ── Runtime-tab banner with full uncapped diff ────────────────────────────
test("07-runtime-tab-restart-banner-with-diff", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [STANDALONE_AGENT],
});
await gotoAgentsView(page);
const agentButton = page.getByRole("button", {
name: `${STANDALONE_AGENT.name} agent profile`,
});
await expect(agentButton).toBeVisible({ timeout: 10_000 });
await agentButton.click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible({ timeout: 10_000 });
// Switch to the Runtime tab
await panel.getByRole("tab", { name: "Runtime" }).click();
const banner = panel.getByTestId("needs-restart-banner");
await expect(banner).toBeVisible({ timeout: 10_000 });
// Banner shows the full uncapped diff list (8 entries, no "and N more")
const diffList = banner.getByTestId("restart-diff-list");
await expect(diffList).toBeVisible();
// All 8 entries visible in banner (no cap).
// exact: true prevents substring collision with "Agent args:" label.
await expect(diffList.getByText("Args:", { exact: true })).toBeVisible();
await waitForAnimations(page);
await banner.screenshot({
path: `${SHOTS}/07-runtime-tab-banner-with-diff.png`,
});
});
test("08-runtime-tab-restart-banner-auto-on", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [STANDALONE_AGENT],
});
await gotoAgentsView(page);
const agentButton = page.getByRole("button", {
name: `${STANDALONE_AGENT.name} agent profile`,
});
await agentButton.click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible({ timeout: 10_000 });
await panel.getByRole("tab", { name: "Runtime" }).click();
const banner = panel.getByTestId("needs-restart-banner");
await expect(banner).toBeVisible({ timeout: 10_000 });
await expect(
banner.getByText("Buzz can restart it automatically"),
).toBeVisible();
await waitForAnimations(page);
await banner.screenshot({
path: `${SHOTS}/08-runtime-tab-banner-auto-on.png`,
});
});
test("09-runtime-tab-restart-banner-auto-off", async ({ page }) => {
const agentAutoOff = {
...STANDALONE_AGENT,
autoRestartOnConfigChange: false,
};
await installMockBridge(page, {
managedAgents: [agentAutoOff],
});
await gotoAgentsView(page);
const agentButton = page.getByRole("button", {
name: `${agentAutoOff.name} agent profile`,
});
await agentButton.click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible({ timeout: 10_000 });
await panel.getByRole("tab", { name: "Runtime" }).click();
const banner = panel.getByTestId("needs-restart-banner");
await expect(banner).toBeVisible({ timeout: 10_000 });
await expect(banner.getByText("Automatic restart is off")).toBeVisible();
await waitForAnimations(page);
await banner.screenshot({
path: `${SHOTS}/09-runtime-tab-banner-auto-off.png`,
});
});
// ── Side-panel badge on default (Info) tab ─────────────────────────────────
test("10-profile-panel-badge-on-default-info-tab", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [STANDALONE_AGENT],
});
await gotoAgentsView(page);
// Open profile panel via the agent card button — opens on Info tab by default
const agentButton = page.getByRole("button", {
name: `${STANDALONE_AGENT.name} agent profile`,
});
await expect(agentButton).toBeVisible({ timeout: 10_000 });
await agentButton.click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible({ timeout: 10_000 });
// Tab-independent badge: visible even on the Info tab (not only Runtime)
const heroBadge = panel.getByTestId("restart-diff-badge");
await expect(heroBadge).toBeVisible({ timeout: 5_000 });
// Hero badge tooltip is functional: hover shows the diff list
await heroBadge.hover();
const heroTooltip = page.locator("[role=tooltip]");
await expect(heroTooltip).toBeVisible({ timeout: 5_000 });
await expect(heroTooltip.getByText("Model:")).toBeVisible();
// The Runtime tab banner is NOT visible on Info tab
await expect(panel.getByTestId("needs-restart-banner")).toHaveCount(0);
await waitForAnimations(page);
await panel.screenshot({
path: `${SHOTS}/10-panel-badge-default-info-tab.png`,
});
});
// ── Inactive + friendly-error: panel opens on Runtime, hero badge + banner ─
test("11-inactive-friendly-error-panel-opens-runtime-tab", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [INACTIVE_FRIENDLY_ERROR_AGENT],
});
await gotoAgentsView(page);
// The card for an inactive agent with a friendly error forces Runtime tab on open.
const agentCard = page.getByTestId(
`managed-agent-${INACTIVE_FRIENDLY_ERROR_AGENT.pubkey}`,
);
await expect(agentCard).toBeVisible({ timeout: 10_000 });
await agentCard.click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible({ timeout: 10_000 });
// Panel opens on Runtime tab (opensRuntimeTab = true for inactive+friendlyError)
const runtimeTab = panel.getByRole("tab", { name: "Runtime" });
await expect(runtimeTab).toHaveAttribute("aria-selected", "true", {
timeout: 5_000,
});
// Hero badge is visible on the Runtime tab (tab-independent)
const heroBadge = panel.getByTestId("restart-diff-badge");
await expect(heroBadge).toBeVisible({ timeout: 5_000 });
// Hero badge tooltip works on the Runtime tab too
await heroBadge.hover();
const heroTooltip = page.locator("[role=tooltip]");
await expect(heroTooltip).toBeVisible({ timeout: 5_000 });
await expect(heroTooltip.getByText("Model:")).toBeVisible();
// Tooltip is capped at 6 + "and 2 more"
await expect(heroTooltip.getByText("and 2 more")).toBeVisible();
// Full uncapped banner also visible on Runtime tab
const banner = panel.getByTestId("needs-restart-banner");
await expect(banner).toBeVisible({ timeout: 5_000 });
const diffList = banner.getByTestId("restart-diff-list");
await expect(diffList).toBeVisible();
// All 8 entries visible in banner (no cap) — last entry "args" is present.
// exact: true prevents substring collision with "Agent args:" label.
await expect(diffList.getByText("Args:", { exact: true })).toBeVisible();
await waitForAnimations(page);
await panel.screenshot({
path: `${SHOTS}/11-inactive-error-panel-runtime-tab.png`,
});
});
});
+470
View File
@@ -0,0 +1,470 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
type NostrBindPayload = {
action: string;
audience: string;
callbackUrl?: string;
challengeId: string;
expiresAt: string;
nonce: string;
origin: string;
protocol: string;
returnMode: string;
verificationCode: string;
version: string;
};
const VALID_REQUEST: NostrBindPayload = {
action: "bind_nostr_identity",
audience: "buzz:nostr-identity",
challengeId: "550e8400-e29b-41d4-a716-446655440000",
expiresAt: "2099-01-01T00:00:00Z",
nonce: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567",
origin: "https://admin.example.com",
protocol: "buzz-nostr-identity",
returnMode: "clipboard",
verificationCode: "123456",
version: "1",
};
async function emitNostrBind(page: Page, payload: NostrBindPayload) {
await page.evaluate(async (nextPayload) => {
const internals = (
window as Window & {
__TAURI_INTERNALS__?: {
invoke?: (
command: string,
args: Record<string, unknown>,
) => Promise<unknown>;
};
}
).__TAURI_INTERNALS__;
if (!internals?.invoke) {
throw new Error("Tauri E2E event bridge is unavailable");
}
await internals.invoke("plugin:event|emit", {
event: "deep-link-nostr-bind",
payload: nextPayload,
});
}, payload);
}
async function openNostrBind(
page: Page,
payload: NostrBindPayload = VALID_REQUEST,
mock?: Parameters<typeof installMockBridge>[1],
) {
await installMockBridge(page, mock);
await page.goto("/");
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
);
await emitNostrBind(page, payload);
await expect(page.getByTestId("nostr-bind-page")).toBeVisible();
}
async function pasteCode(input: Locator, code: string) {
await input.evaluate((element, pastedCode) => {
const clipboardData = new DataTransfer();
clipboardData.setData("text", pastedCode);
element.dispatchEvent(
new ClipboardEvent("paste", {
bubbles: true,
cancelable: true,
clipboardData,
}),
);
}, code);
}
async function signCommandPayloads(page: Page): Promise<unknown[]> {
return page.evaluate(() =>
(
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: unknown;
}>;
}
).__BUZZ_E2E_COMMAND_LOG__ ?? []
)
.filter(({ command }) => command === "sign_nostr_identity_binding")
.map(({ payload }) => payload),
);
}
async function installClipboardStub(page: Page, shouldFail: boolean) {
await page.addInitScript(
({ fail }) => {
const testWindow = window as Window & {
__BUZZ_E2E_CLIPBOARD_TEXT__?: string;
};
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: async (text: string) => {
if (fail) {
throw new Error("clipboard unavailable");
}
testWindow.__BUZZ_E2E_CLIPBOARD_TEXT__ = text;
},
},
});
},
{ fail: shouldFail },
);
}
async function installShakeCounter(page: Page) {
await page.addInitScript(() => {
const testWindow = window as Window & {
__BUZZ_E2E_CODE_SHAKE_CALLS__?: number;
};
testWindow.__BUZZ_E2E_CODE_SHAKE_CALLS__ = 0;
const originalAnimate = Element.prototype.animate;
Element.prototype.animate = function animate(keyframes, options) {
if (
this instanceof HTMLElement &&
this.dataset.testid === "nostr-bind-verification-code"
) {
testWindow.__BUZZ_E2E_CODE_SHAKE_CALLS__ =
(testWindow.__BUZZ_E2E_CODE_SHAKE_CALLS__ ?? 0) + 1;
}
return originalAnimate.call(this, keyframes, options);
};
});
}
async function shakeCount(page: Page): Promise<number> {
return page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_CODE_SHAKE_CALLS__?: number;
}
).__BUZZ_E2E_CODE_SHAKE_CALLS__ ?? 0,
);
}
test("supports OTP entry and navigation without signing incomplete input", async ({
page,
}) => {
await openNostrBind(page);
await expect(page.getByText("Requesting origin")).toHaveCount(0);
await expect(page.getByText(VALID_REQUEST.origin)).toHaveCount(0);
const first = page.getByTestId("nostr-bind-code-digit-1");
const second = page.getByTestId("nostr-bind-code-digit-2");
const third = page.getByTestId("nostr-bind-code-digit-3");
const continueButton = page.getByTestId("nostr-bind-sign-and-copy");
await first.click();
await first.press("1");
await expect(second).toBeFocused();
await second.press("2");
await expect(third).toBeFocused();
await third.press("ArrowLeft");
await expect(second).toBeFocused();
await second.press("Backspace");
await expect(second).toHaveValue("");
await expect(continueButton).toBeDisabled();
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
test("auto-signs exactly once when the sixth correct digit is typed", async ({
page,
}) => {
await openNostrBind(page);
await page.getByTestId("nostr-bind-code-digit-1").click();
for (const digit of VALID_REQUEST.verificationCode) {
await page.keyboard.type(digit);
}
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
await page.waitForTimeout(100);
await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
});
test("discards a signature when a newer pairing request arrives", async ({
page,
}) => {
await openNostrBind(page, VALID_REQUEST, { nostrBindSignDelayMs: 150 });
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
const nextRequest = {
...VALID_REQUEST,
challengeId: "550e8400-e29b-41d4-a716-446655440001",
verificationCode: "654321",
};
await emitNostrBind(page, nextRequest);
await expect(page.getByTestId("nostr-bind-code-digit-1")).toHaveValue("");
await page.waitForTimeout(200);
await expect(page.getByTestId("nostr-bind-code-step")).toBeVisible();
await expect(page.getByTestId("nostr-bind-finish-step")).toBeHidden();
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
nextRequest.verificationCode,
);
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
await expect.poll(() => signCommandPayloads(page)).toHaveLength(2);
});
test("repeats mismatch feedback without signing", async ({ page }) => {
await installShakeCounter(page);
await openNostrBind(page);
const first = page.getByTestId("nostr-bind-code-digit-1");
const continueButton = page.getByTestId("nostr-bind-sign-and-copy");
await pasteCode(first, "654321");
await expect(page.getByRole("alert")).toHaveText(
"That code doesn't match. Check the code and try again.",
);
await expect(continueButton).toBeDisabled();
await expect.poll(() => shakeCount(page)).toBe(1);
await pasteCode(first, "654321");
await expect.poll(() => shakeCount(page)).toBe(2);
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
test("honors reduced motion while rejecting a mismatched code", async ({
page,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await installShakeCounter(page);
await openNostrBind(page);
await pasteCode(page.getByTestId("nostr-bind-code-digit-1"), "654321");
await expect(page.getByRole("alert")).toBeVisible();
await expect.poll(() => shakeCount(page)).toBe(0);
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
test("rejects an expired request without signing", async ({ page }) => {
await openNostrBind(page, {
...VALID_REQUEST,
expiresAt: "2000-01-01T00:00:00Z",
});
await expect(
page.getByText(
"This binding link has expired. Request a new one from the requesting app.",
),
).toBeVisible();
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await expect(page.getByTestId("nostr-bind-sign-and-copy")).toBeDisabled();
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
test("cancels a request without signing", async ({ page }) => {
await openNostrBind(page);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByTestId("nostr-bind-page")).toBeHidden();
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
test("signs a valid request, shows the response, and copies it", async ({
page,
}) => {
await installClipboardStub(page, false);
await openNostrBind(page);
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
const response = page.getByTestId("nostr-bind-signed-response");
await expect(response).toContainText("e2e-signed-nostr-binding");
await expect
.poll(() => signCommandPayloads(page))
.toEqual([
{
challengeId: VALID_REQUEST.challengeId,
expiresAt: VALID_REQUEST.expiresAt,
nonce: VALID_REQUEST.nonce,
origin: VALID_REQUEST.origin,
verificationCode: VALID_REQUEST.verificationCode,
},
]);
const signedResponse = await response.textContent();
await page.getByTestId("nostr-bind-copy-response").click();
await expect(
page.getByTestId("nostr-bind-copy-response"),
).toHaveAccessibleName("Copied");
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_CLIPBOARD_TEXT__?: string;
}
).__BUZZ_E2E_CLIPBOARD_TEXT__,
),
)
.toBe(signedResponse);
await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByTestId("nostr-bind-page")).toBeHidden();
});
test("returns a signed response in the callback fragment after consent", async ({
page,
}) => {
await installClipboardStub(page, false);
await openNostrBind(page, {
...VALID_REQUEST,
callbackUrl: "https://admin.example.com/buzz?source=bind#stale",
returnMode: "browser_fragment_v1",
});
await expect
.poll(async () =>
page.evaluate(
() =>
(
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{ command: string }>;
}
).__BUZZ_E2E_COMMAND_LOG__ ?? []
).filter(({ command }) => command === "plugin:opener|open_url")
.length,
),
)
.toBe(0);
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Continue in your browser" }),
).toBeVisible();
const manualFallback = page.getByTestId("nostr-bind-manual-fallback");
await expect(manualFallback).not.toHaveAttribute("open", "");
await expect(page.getByTestId("nostr-bind-signed-response")).toBeHidden();
await expect(page.getByTestId("nostr-bind-copy-response")).toBeHidden();
const fallbackSummary = manualFallback.locator("summary");
await fallbackSummary.focus();
await fallbackSummary.press("Enter");
await expect(manualFallback).toHaveAttribute("open", "");
const signedResponse = page.getByTestId("nostr-bind-signed-response");
await expect(signedResponse).toContainText("e2e-signed-nostr-binding");
const copyResponse = page.getByTestId("nostr-bind-copy-response");
await expect(copyResponse).toBeVisible();
await copyResponse.click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_CLIPBOARD_TEXT__?: string;
}
).__BUZZ_E2E_CLIPBOARD_TEXT__,
),
)
.toBe(await signedResponse.textContent());
const callback = await page.evaluate(() => {
const command = (
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: { url?: string };
}>;
}
).__BUZZ_E2E_COMMAND_LOG__ ?? []
).find(({ command }) => command === "plugin:opener|open_url");
return command?.payload.url;
});
const callbackUrl = new URL(callback ?? "");
expect(callbackUrl.origin).toBe("https://admin.example.com");
expect(callbackUrl.pathname).toBe("/buzz");
expect(callbackUrl.search).toBe("?source=bind");
expect(callbackUrl.searchParams.has("buzz_bind")).toBe(false);
expect(callbackUrl.hash).toMatch(/^#buzz_bind=v1\.[A-Za-z0-9_-]+$/);
});
test("opens the manual fallback when returning to the browser fails", async ({
page,
}) => {
await openNostrBind(
page,
{
...VALID_REQUEST,
callbackUrl: "https://admin.example.com/buzz",
returnMode: "browser_fragment_v1",
},
{ openerError: "browser unavailable" },
);
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await expect(
page.getByText(
"Could not open the browser. Copy the response below to finish manually.",
),
).toBeVisible();
await expect(page.getByTestId("nostr-bind-manual-fallback")).toHaveAttribute(
"open",
"",
);
await expect(page.getByTestId("nostr-bind-copy-response")).toBeVisible();
});
test("keeps the signed response available when clipboard access fails", async ({
page,
}) => {
await installClipboardStub(page, true);
await openNostrBind(page);
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
await page.getByTestId("nostr-bind-copy-response").click();
await expect(
page
.getByTestId("nostr-bind-manual-fallback-content")
.getByText("Buzz couldn't access the clipboard. Try again."),
).toBeVisible();
await expect(page.getByTestId("nostr-bind-signed-response")).toContainText(
"e2e-signed-nostr-binding",
);
await expect(
page.getByTestId("nostr-bind-copy-response"),
).toHaveAccessibleName("Copy response");
});
@@ -0,0 +1,225 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
async function openLocalArchiveSettings(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
await page.getByTestId("settings-nav-local-archive").click();
const card = page.getByTestId("settings-local-archive");
await expect(card).toBeVisible({ timeout: 10_000 });
return card;
}
test.describe("observer archive policy — Settings toggle", () => {
test("fresh identity: observer toggle is enabled and checked by default", async ({
page,
}) => {
// Archive is default-on for all builds. A fresh identity (no stored opt-out)
// should show the toggle enabled and checked after reconciliation seeds the
// kind-24200 subscription.
await installMockBridge(page, {
saveSubscriptions: [
{
scope_type: "owner_p",
scope_value: "deadbeef".repeat(8),
kinds: "[24200]",
},
],
});
const card = await openLocalArchiveSettings(page);
const toggle = card.getByTestId("local-archive-observer-toggle");
await expect(toggle).toBeVisible({ timeout: 5_000 });
await expect(toggle).toBeEnabled();
await expect(toggle).toBeChecked();
});
test("toggle click OFF disables, then ON re-enables", async ({ page }) => {
await installMockBridge(page, {
saveSubscriptions: [
{
scope_type: "owner_p",
scope_value: "deadbeef".repeat(8),
kinds: "[24200]",
},
],
});
const card = await openLocalArchiveSettings(page);
const toggle = card.getByTestId("local-archive-observer-toggle");
await expect(toggle).toBeVisible({ timeout: 5_000 });
await expect(toggle).toBeChecked();
// OFF: removes kind 24200.
await toggle.click();
await expect(toggle).not.toBeChecked();
// ON again: re-creates the row from empty.
await toggle.click();
await expect(toggle).toBeChecked();
});
test("explicit opt-out persists across reload: toggle stays OFF", async ({
page,
}) => {
// Simulate a user who previously clicked OFF: the identity-scoped opt-out
// is recorded in localStorage ("0") and the owner_p/24200 subscription row
// is absent. Reconciliation must honour the stored choice and leave the
// toggle unchecked (user can re-enable via the toggle).
const MOCK_PUBKEY = "deadbeef".repeat(8);
await page.addInitScript(
({ storageKey }) => {
window.localStorage.setItem(storageKey, "0");
},
{
storageKey: `buzz:observer-archive-default-seeded:${MOCK_PUBKEY}`,
},
);
await installMockBridge(page, {
saveSubscriptions: [],
});
const card = await openLocalArchiveSettings(page);
const toggle = card.getByTestId("local-archive-observer-toggle");
await expect(toggle).toBeVisible({ timeout: 5_000 });
await expect(toggle).toBeEnabled();
await expect(toggle).not.toBeChecked();
});
test("no subscriptions, no stored choice: defaults ON then OFF removes, ON re-creates", async ({
page,
}) => {
// A fresh identity with no stored choice and an empty subscription table
// must be seeded to ON by reconciliation. Thereafter the toggle must
// function: OFF removes kind 24200, ON re-creates it.
await installMockBridge(page, {
saveSubscriptions: [],
});
const card = await openLocalArchiveSettings(page);
const toggle = card.getByTestId("local-archive-observer-toggle");
await expect(toggle).toBeVisible({ timeout: 5_000 });
// Default-on: reconciliation seeds the row, toggle must be checked.
await expect(toggle).toBeChecked();
// OFF: removes kind 24200.
await toggle.click();
await expect(toggle).not.toBeChecked();
// ON again: re-creates the row from empty.
await toggle.click();
await expect(toggle).toBeChecked();
});
});
test.describe("observer archive policy — reconciliation gate", () => {
test("archive sync reaches subscription path after reconciliation", async ({
page,
}) => {
// The reconciliation gate (useObserverArchiveReconciliation) must resolve
// successfully for a fresh identity, allowing useArchiveSync to start the
// ArchiveSyncManager, which calls list_save_subscriptions.
await installMockBridge(page, {
saveSubscriptions: [
{
scope_type: "owner_p",
scope_value: "deadbeef".repeat(8),
kinds: "[24200]",
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
// Wait for the channel list (proves AppShell mounted fully).
await expect(page.getByTestId("channel-general")).toBeVisible({
timeout: 10_000,
});
// The IPC counter proves the subscription path was reached.
await page.waitForFunction(
() => {
const counters = (window as Record<string, unknown>)
.__BUZZ_E2E_IPC_COUNTERS__ as Record<string, number> | undefined;
return (counters?.list_save_subscriptions ?? 0) > 0;
},
null,
{ timeout: 10_000 },
);
const count = await page.evaluate(() => {
const counters = (window as Record<string, unknown>)
.__BUZZ_E2E_IPC_COUNTERS__ as Record<string, number> | undefined;
return counters?.list_save_subscriptions ?? 0;
});
expect(count).toBeGreaterThan(0);
// The reconciliation gate must also result in a real `#p` + kind-24200
// live REQ filter.
const hasOwnerKindSubscription = await page.evaluate(
(ownerPubkey) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?: (input: {
ownerPubkey: string;
kind: number;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?.({
ownerPubkey,
kind: 24200,
}) ?? false,
"deadbeef".repeat(8),
);
expect(hasOwnerKindSubscription).toBe(true);
});
test("fresh install with empty subscriptions: reconciliation seeds kind 24200", async ({
page,
}) => {
// A fresh install with no owner_p/24200 row must end up with one after
// startup reconciliation runs — the actual production repair path.
await installMockBridge(page, {
saveSubscriptions: [],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("channel-general")).toBeVisible({
timeout: 10_000,
});
await expect
.poll(
() =>
page.evaluate(
(ownerPubkey) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?: (input: {
ownerPubkey: string;
kind: number;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?.({
ownerPubkey,
kind: 24200,
}) ?? false,
"deadbeef".repeat(8),
),
{ timeout: 10_000 },
)
.toBe(true);
const commands = await page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
);
expect(commands).toContain("merge_save_subscription_kinds");
});
});
@@ -0,0 +1,817 @@
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/observer-feed";
// A running managed agent whose pubkey maps to the "agents" channel.
// Using tyler's pubkey so the mock bridge already knows this identity.
const OBSERVER_AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
const CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; // #agents
const NOW = new Date("2025-06-15T12:00:00Z").toISOString();
const MANAGED_AGENTS = [
{
pubkey: OBSERVER_AGENT_PUBKEY,
name: "Observer Agent",
status: "running" as const,
channelNames: ["agents"],
},
];
// Helper: wait until the seed hook is available in the page.
async function waitForSeedHook(page: import("@playwright/test").Page) {
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function",
null,
{ timeout: 10_000 },
);
}
// Helper: open the observer feed panel by navigating to #agents, clicking the
// agent avatar to open the profile panel, then clicking "View activity".
async function openObserverFeedPanel(
page: import("@playwright/test").Page,
agentPubkey: string,
) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForSeedHook(page);
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
// Click any message row button to open the profile panel.
const messageRow = page
.getByTestId("message-row")
.filter({ has: page.getByText("Observer Agent", { exact: false }) });
await expect(messageRow.first()).toBeVisible({ timeout: 8_000 });
await messageRow.first().getByRole("button").first().click();
const profilePanel = page.getByTestId("user-profile-panel");
await expect(profilePanel).toBeVisible({ timeout: 10_000 });
// Click "View activity" to open the observer feed panel.
const activityBtn = page.getByTestId(
`user-profile-view-activity-${agentPubkey}`,
);
await expect(activityBtn).toBeVisible({ timeout: 5_000 });
await activityBtn.click();
const feedPanel = page.getByTestId("agent-session-thread-panel");
await expect(feedPanel).toBeVisible({ timeout: 10_000 });
return feedPanel;
}
// Helper: seed observer events into the store and wait for the panel to update.
async function seedObserverEvents(
page: import("@playwright/test").Page,
agentPubkey: string,
events: Array<{
seq: number;
timestamp: string;
kind: string;
agentIndex: number | null;
channelId: string | null;
sessionId: string | null;
turnId: string | null;
payload: unknown;
}>,
) {
await page.evaluate(
({ pubkey, evts }) => {
window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({
agentPubkey: pubkey,
events: evts,
});
},
{ pubkey: agentPubkey, evts: events },
);
// Let React re-render after the store update.
await page.waitForTimeout(300);
}
async function settleAnimations(panel: import("@playwright/test").Locator) {
// Only await finite animations — live surfaces (e.g. the turn liveness
// indicator) run infinite loops whose `finished` promise never resolves.
await panel.evaluate((el) =>
Promise.all(
el
.getAnimations({ subtree: true })
.filter((a) => {
const timing = a.effect?.getTiming();
return timing?.iterations !== Number.POSITIVE_INFINITY;
})
.map((a) => a.finished),
),
);
}
test.describe("observer feed screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
page.on("console", (msg) => {
if (msg.type() === "error") {
console.error("CONSOLE ERROR:", msg.text().slice(0, 500));
}
});
});
test("01 — prompt context dialog (via Checks ingress)", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
// session/prompt event: per-turn prompt context. The payload contains
// sections that parsePromptText extracts; the transcript keeps them out
// of the feed until the CheckCheck footer toggle opens the
// PromptContextDialog modal.
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: "turn-001",
payload: {
jsonrpc: "2.0",
id: 1,
method: "session/prompt",
params: {
prompt: [
{
type: "text",
text: "[Buzz event: Kind 9]\nContent: @Observer Agent help me debug this",
},
{
type: "text",
text: "[Thread context]\nThis is the thread history with 3 prior messages.",
},
{
type: "text",
text: "[Channel context]\nYou are in #agents, a channel for AI coordination.",
},
],
},
},
},
]);
// The context stays out of the feed until the CheckCheck ingress opens
// the dialog.
await expect(feedPanel.getByText("Prompt context")).toHaveCount(0);
const contextToggle = feedPanel.getByTestId(
"transcript-prompt-context-toggle",
);
await expect(contextToggle).toBeVisible({ timeout: 5_000 });
await contextToggle.click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(
dialog.getByTestId("transcript-prompt-context-sections"),
).toBeVisible({ timeout: 5_000 });
await settleAnimations(dialog);
await dialog.screenshot({
path: `${SHOTS}/01-prompt-context-dialog.png`,
});
});
test("02 — system prompt title restored", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
// session/new event without a subsequent session/prompt: the system-prompt
// item is never consumed by a turn bucket, so the grouper emits it as a
// standalone single rendered by RawRailActivity with the "System prompt"
// title. (This is the isolated test; see shot 11 for the full turn bundle.)
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: null,
payload: {
jsonrpc: "2.0",
id: 1,
method: "session/new",
params: {
systemPrompt:
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.",
},
},
},
]);
// The system prompt rail item should show the restored title.
await expect(feedPanel.getByText("System prompt")).toBeVisible({
timeout: 5_000,
});
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/02-system-prompt-title-restored.png`,
});
});
test("03 — permission outcome (approved)", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
// Permission request followed by a selected (approved) response,
// correlated by JSON-RPC id (fix #3: was broken for numeric ids before).
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
// Request
{
seq: 1,
timestamp: NOW,
kind: "acp_read",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: "turn-001",
payload: {
jsonrpc: "2.0",
id: 42,
method: "session/request_permission",
params: {
title: "Read file system",
message: "Agent wants to read ~/.config/goose/config.yaml",
options: [
{ optionId: "opt-allow", kind: "allow_once", name: "Allow once" },
{ optionId: "opt-deny", kind: "reject_once", name: "Deny" },
],
},
},
},
// Response (numeric id 42 — the exact case fix #3 restores)
{
seq: 2,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: "turn-001",
payload: {
jsonrpc: "2.0",
id: 42,
result: {
outcome: {
outcome: "selected",
optionId: "opt-allow",
},
},
},
},
]);
// The permission row should show the "Approved (allow_once)" outcome.
await expect(feedPanel.getByText(/Approved.*allow_once/)).toBeVisible({
timeout: 5_000,
});
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/03-permission-approved.png`,
});
});
test("04 — permission outcome (cancelled)", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
// Request
{
seq: 1,
timestamp: NOW,
kind: "acp_read",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: "turn-001",
payload: {
jsonrpc: "2.0",
id: "perm-cancelled-1",
method: "session/request_permission",
params: {
title: "Write to output file",
options: [
{ optionId: "opt-yes", kind: "allow_once", name: "Allow" },
],
},
},
},
// Cancelled response
{
seq: 2,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: "turn-001",
payload: {
jsonrpc: "2.0",
id: "perm-cancelled-1",
result: {
outcome: {
outcome: "cancelled",
},
},
},
},
]);
await expect(feedPanel.getByText("Cancelled")).toBeVisible({
timeout: 5_000,
});
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/04-permission-cancelled.png`,
});
});
test("05 — prompt context dialog (sections expanded)", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: "turn-001",
payload: {
jsonrpc: "2.0",
id: 1,
method: "session/prompt",
params: {
prompt: [
{
type: "text",
text: "[Buzz event: Kind 9]\nContent: @Observer Agent help me debug this",
},
{
type: "text",
text: "[Thread context]\nThis is the thread history with 3 prior messages.",
},
{
type: "text",
text: "[Channel context]\nYou are in #agents, a channel for AI coordination.",
},
],
},
},
},
]);
// Open the dialog via the CheckCheck ingress, then expand every section.
const contextToggle = feedPanel.getByTestId(
"transcript-prompt-context-toggle",
);
await expect(contextToggle).toBeVisible({ timeout: 5_000 });
await contextToggle.click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
const sectionButtons = dialog
.getByTestId("transcript-prompt-context-sections")
.getByRole("button");
for (const btn of await sectionButtons.all()) {
await btn.click();
}
await settleAnimations(dialog);
await dialog.screenshot({
path: `${SHOTS}/05-prompt-context-expanded.png`,
});
});
test("06 — system prompt sections expanded", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: null,
payload: {
jsonrpc: "2.0",
id: 1,
method: "session/new",
params: {
systemPrompt:
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.\n\n[Team Instructions]\nAlways tag on handoff.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\nCanvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301",
},
},
},
]);
await expect(feedPanel.getByText("System prompt")).toBeVisible({
timeout: 5_000,
});
// All five section headings must be present in the card — expand it first.
await feedPanel.getByTestId("transcript-metadata-item").evaluate((el) => {
if (el.tagName === "DETAILS") (el as HTMLDetailsElement).open = true;
for (const details of el.querySelectorAll("details")) {
details.open = true;
}
});
await expect(feedPanel.getByText("Base")).toBeVisible({ timeout: 5_000 });
await expect(feedPanel.getByText("System", { exact: true })).toBeVisible({
timeout: 5_000,
});
await expect(feedPanel.getByText("Team Instructions")).toBeVisible({
timeout: 5_000,
});
await expect(feedPanel.getByText("Core Memory")).toBeVisible({
timeout: 5_000,
});
await expect(feedPanel.getByText("Channel Canvas")).toBeVisible({
timeout: 5_000,
});
// Open the outer ActivityRow <details> to reveal the section content,
// then click each section accordion button to expand it (mirrors shot 05 —
// system-prompt sections now render as React button accordions, not native
// <details> elements).
await feedPanel.getByTestId("transcript-metadata-item").evaluate((el) => {
if (el.tagName === "DETAILS") (el as HTMLDetailsElement).open = true;
for (const details of el.querySelectorAll("details")) {
details.open = true;
}
});
const sectionButtons = feedPanel
.getByTestId("transcript-metadata-item")
.getByTestId("transcript-prompt-context-sections")
.getByRole("button");
const allSectionButtons = await sectionButtons.all();
expect(allSectionButtons.length).toBeGreaterThan(0);
for (const btn of allSectionButtons) {
await btn.click();
}
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/06-system-prompt-expanded.png`,
});
});
test("07 — current_mode_update lifecycle status line", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "acp_read",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-007",
turnId: "turn-007",
payload: {
method: "session/update",
params: {
sessionId: "session-007",
update: {
sessionUpdate: "current_mode_update",
currentModeId: "plan",
},
},
},
},
]);
await expect(feedPanel.getByText("Mode")).toBeVisible({ timeout: 5_000 });
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/07-current-mode-update.png`,
});
});
test("08 — usage_update lifecycle status line (coalesced)", async ({
page,
}) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
// Two usage frames — only the last should be visible.
{
seq: 1,
timestamp: NOW,
kind: "acp_read",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-008",
turnId: "turn-008",
payload: {
method: "session/update",
params: {
sessionId: "session-008",
update: {
sessionUpdate: "usage_update",
used: 1200,
size: 8192,
},
},
},
},
{
seq: 2,
timestamp: NOW,
kind: "acp_read",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-008",
turnId: "turn-008",
payload: {
method: "session/update",
params: {
sessionId: "session-008",
update: {
sessionUpdate: "usage_update",
used: 3450,
size: 8192,
cost: { amount: 0.0018, currency: "USD" },
},
},
},
},
]);
await expect(feedPanel.getByText("Usage")).toBeVisible({ timeout: 5_000 });
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/08-usage-update-coalesced.png`,
});
});
test("09 — available_commands_update lifecycle status line", async ({
page,
}) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "acp_read",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-009",
turnId: "turn-009",
payload: {
method: "session/update",
params: {
sessionId: "session-009",
update: {
sessionUpdate: "available_commands_update",
availableCommands: [
{
name: "create_plan",
description: "Create a structured plan for the task",
},
{
name: "research_codebase",
description: "Research and understand the codebase",
},
{
name: "execute_steps",
description: "Execute the planned steps",
},
],
},
},
},
},
]);
await expect(feedPanel.getByText("Commands", { exact: true })).toBeVisible({
timeout: 5_000,
});
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/09-available-commands-update.png`,
});
});
test("10 — config_option_update lifecycle status line", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "acp_read",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-010",
turnId: "turn-010",
payload: {
method: "session/update",
params: {
sessionId: "session-010",
update: {
sessionUpdate: "config_option_update",
configOptions: [
{
id: "model",
name: "Model",
type: "select",
currentValue: "gpt-4o",
},
{
id: "mode",
name: "Mode",
type: "select",
currentValue: "auto",
},
],
},
},
},
},
]);
await expect(feedPanel.getByText("Config")).toBeVisible({ timeout: 5_000 });
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/10-config-option-update.png`,
});
});
test("11 — first-turn bundle: standalone system-prompt card + Checks ingress (per-turn context only)", async ({
page,
}) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
// Full realistic pool.rs first-turn wire sequence for a team-pack agent:
// turn_started → session/new → session_resolved → session/prompt
// Verifies the consolidated presentation: session/new.systemPrompt always
// renders as a standalone top-level "System prompt" card (never injected into
// the CheckCheck bundle). The CheckCheck dialog contains only per-turn context
// (Buzz event / Thread context) — no Base/System/Team Instructions/Core Memory/Channel Canvas sections.
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "turn_started",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: null,
turnId: "turn-001",
payload: { source: "channel", triggeringEventIds: [] },
},
{
seq: 2,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: null,
turnId: "turn-001",
payload: {
jsonrpc: "2.0",
id: 1,
method: "session/new",
params: {
systemPrompt:
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.\n\n[Team Instructions]\nAlways tag on handoff.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\nCanvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301",
},
},
},
{
seq: 3,
timestamp: NOW,
kind: "session_resolved",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: "turn-001",
payload: { sessionId: "session-001", isNewSession: true },
},
{
seq: 4,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: "turn-001",
payload: {
jsonrpc: "2.0",
id: 2,
method: "session/prompt",
params: {
prompt: [
{
type: "text",
text: "[Buzz event: Kind 9]\nContent: @Observer Agent help me debug this",
},
{
type: "text",
text: "[Thread context]\nThis is the thread history with 3 prior messages.",
},
],
},
},
},
]);
// User message bubble should be visible (anchors the prompt bundle).
await expect(feedPanel.getByTestId("transcript-prompt-bundle")).toBeVisible(
{ timeout: 5_000 },
);
// Scroll to the top of the feed to bring the standalone "System prompt"
// card (which precedes the user bubble) into view.
await feedPanel.evaluate((el) => el.scrollTo({ top: 0 }));
// Standalone "System prompt" card is visible as a top-level feed row —
// it must remain present even after the first prompt arrives.
await expect(feedPanel.getByText("System prompt")).toBeVisible({
timeout: 5_000,
});
// The standalone card shows "5 sections" collapsed — expand it to reveal
// the section headings, then assert all five are present.
await feedPanel.getByTestId("transcript-metadata-item").evaluate((el) => {
if (el.tagName === "DETAILS") (el as HTMLDetailsElement).open = true;
for (const details of el.querySelectorAll("details")) {
details.open = true;
}
});
await expect(feedPanel.getByText("Base")).toBeVisible({ timeout: 5_000 });
await expect(feedPanel.getByText("System", { exact: true })).toBeVisible({
timeout: 5_000,
});
await expect(feedPanel.getByText("Team Instructions")).toBeVisible({
timeout: 5_000,
});
await expect(feedPanel.getByText("Core Memory")).toBeVisible({
timeout: 5_000,
});
await expect(feedPanel.getByText("Channel Canvas")).toBeVisible({
timeout: 5_000,
});
// Per-turn prompt context (Buzz event / Thread context) does NOT appear
// as a standalone feed row — it lives behind the CheckCheck toggle.
await expect(feedPanel.getByText("Prompt context")).toHaveCount(0);
// Open the CheckCheck dialog: it contains ONLY per-turn context sections
// (Buzz event, Thread context). Base/System/Team Instructions/Core Memory/Channel Canvas must NOT appear.
await feedPanel.getByTestId("transcript-prompt-context-toggle").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
const sectionArticles = dialog
.getByTestId("transcript-prompt-context-sections")
.locator("article");
const sectionTitles = await sectionArticles.allInnerTexts();
// Only per-turn context sections (Buzz event + Thread context) — no system-prompt sections.
expect(sectionTitles.length).toBe(2);
expect(sectionTitles[0]).toContain("Buzz event");
expect(sectionTitles[1]).toContain("Thread context");
// Collect all article heading text and assert none of the five
// system-prompt section labels appear — including exact "System" which
// would be ambiguous via substring search on the full dialog text.
const forbidden = [
"Base",
"System",
"Team Instructions",
"Core Memory",
"Channel Canvas",
];
for (const title of sectionTitles) {
for (const label of forbidden) {
expect(title).not.toContain(label);
}
}
await settleAnimations(dialog);
await dialog.screenshot({
path: `${SHOTS}/11-first-turn-ordering.png`,
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,112 @@
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
import { seedActiveIdentity } from "../helpers/onboarding";
const BLANK_TYLER_IDENTITY = {
...TEST_IDENTITIES.tyler,
username: "",
};
const SHOTS = "test-results/screenshots-onboarding";
test("avatar step always shows Skip for now button without an error", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
// Skip button must be visible before any avatar is chosen (no error path).
const skipBtn = page.getByTestId("onboarding-skip");
await expect(skipBtn).toBeVisible();
await expect(skipBtn).toBeEnabled();
await expect(skipBtn).toHaveText("Skip for now");
// Capture the whole viewport: the Skip/Next/Back CTAs are portaled into the
// docked footer (a sibling of the step subtree), so a section-scoped shot
// would omit the very buttons this artifact is meant to show.
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/01-avatar-skip-button.png`,
});
});
test("avatar step skip button completes community profile setup", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page.getByTestId("onboarding-skip").click();
await expect(page.getByTestId("onboarding-gate")).not.toBeVisible();
});
test("avatar Next button still requires an avatar to be chosen", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
// Next is disabled until an avatar is set.
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
// Once an avatar URL is provided, Next enables.
await page
.getByTestId("onboarding-avatar-url")
.fill("https://example.com/avatar.png");
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
});
// ---------------------------------------------------------------------------
// B4: Routing tests
// ---------------------------------------------------------------------------
test("normal profile setup keeps the existing identity", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await expect(page.getByTestId("onboarding-import-key")).toHaveCount(0);
await expect(page.getByText("Create an identity key")).toHaveCount(0);
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
});
test("Back from the community avatar step returns to profile", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page.getByTestId("onboarding-back").click();
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
});
+392
View File
@@ -0,0 +1,392 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
import {
dropFileOnTestId,
endWindowFileDrag,
startWindowFileDrag,
} from "../helpers/fileDrag";
async function enterMachineBackup(page: import("@playwright/test").Page) {
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
}
async function openBackupOptions(page: import("@playwright/test").Page) {
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
await page.getByTestId("backup-options-link").click();
await expect(
page.getByTestId("onboarding-page-backup-options"),
).toBeVisible();
}
async function openPasswordBackup(page: import("@playwright/test").Page) {
await openBackupOptions(page);
await page.getByTestId("backup-option-password").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
}
async function invokedCommands(page: import("@playwright/test").Page) {
return page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
);
}
const SHOTS = "test-results/screenshots-onboarding";
// Mirrors the mock bridge's MOCK_NCRYPTSEC (e2eBridge.ts): the blob the
// mocked `create_ncryptsec_backup` returns, i.e. the "downloaded file"
// contents the test-your-backup dropzone expects.
const MOCK_NCRYPTSEC =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
test("backup step appears on fresh-key path after profile submit", async ({
page,
}) => {
await enterMachineBackup(page);
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
// Perceived-loading intro: the animated logo and "Creating" title show
// first, then the finished state replaces them after the hold.
await expect(
page.getByRole("heading", { name: "Creating your identity key" }),
).toBeVisible();
await expect(page.getByTestId("backup-intro-logo")).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
await expect(page.getByTestId("onboarding-back")).toBeEnabled();
await expect(
page.getByRole("heading", {
name: "Your unique identity key has been created",
}),
).toBeVisible();
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
});
// ---------------------------------------------------------------------------
// Key-created view: masked key with reveal toggle. Backup options open the
// dark security view; the raw key is fetched only on explicit reveal/copy.
// ---------------------------------------------------------------------------
test("key view reveals explicitly; options copy explicitly", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await enterMachineBackup(page);
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
// Masked by default: decorative mask only, no key material in the DOM.
const key = page.getByTestId("backup-key-value");
await expect(key).toBeVisible();
await expect(key).toHaveClass(/blur/);
await expect(key).not.toContainText("nsec1");
expect(await invokedCommands(page)).not.toContain("get_nsec");
// Reveal fetches the key; box must not reflow (same-length monospace mask).
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(key).toContainText("nsec1mock");
await expect(key).toHaveClass(/select-text/);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/02-backup-chooser-revealed.png` });
// Hide again.
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(key).not.toContainText("nsec1");
// Copy is available only after opening the dark backup-options view.
await page.getByTestId("backup-options-link").click();
await expect(
page.getByTestId("onboarding-page-backup-options"),
).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await page.getByTestId("backup-copy-key").click();
await expect
.poll(async () => invokedCommands(page))
.toContain("copy_text_to_clipboard");
expect(await invokedCommands(page)).toContain("get_nsec");
// Return restores the yellow key-created view; its Next skips backup and
// continues directly to setup.
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
// ---------------------------------------------------------------------------
// Encrypted download path ("Backup your key" step): password → encrypt
// locally → native save → saved confirmation. The raw key must never be
// fetched on this path.
// ---------------------------------------------------------------------------
test("download happy path: generated password, encrypt, native save, Next", async ({
page,
}) => {
await enterMachineBackup(page);
// Password backup opens from the dark options state without adding an
// onboarding progress step.
await openPasswordBackup(page);
// The password field starts empty; the create button sits in the footer's
// primary slot and stays disabled until a valid password exists.
const input = page.getByTestId("backup-passphrase-input");
await expect(input).toHaveValue("");
await expect(page.getByTestId("encrypted-backup-create")).toBeDisabled();
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await expect(page.getByTestId("backup-return-to-onboarding")).toBeVisible();
const passwordPanel = page.getByTestId("backup-password-panel");
await expect(passwordPanel).toBeVisible();
await expect(passwordPanel).not.toHaveClass(/buzz-card-textured/);
await expect(passwordPanel).toHaveCSS("padding-left", "24px");
// The inset refresh icon opens the generator popover and immediately
// fills the field (mock default: 3 words, spaces).
await page.getByTestId("backup-passphrase-generate").click();
await expect(input).toHaveValue("mock horse battery");
const generatorPopover = page.getByRole("dialog");
await expect(generatorPopover).toBeVisible();
await expect(generatorPopover).not.toHaveClass(/buzz-card-textured/);
// Popover controls regenerate in place: word count (slider) and separator.
await page.getByTestId("backup-passphrase-words").focus();
await page.keyboard.press("ArrowRight");
await expect(input).toHaveValue("mock horse battery staple");
await page
.getByTestId("backup-passphrase-separator")
.selectOption({ label: "Hyphens" });
await expect(input).toHaveValue("mock-horse-battery-staple");
// Clicking the inset icon again re-rolls without closing the popover.
await page.getByTestId("backup-passphrase-generate").click();
await expect(page.getByTestId("backup-passphrase-separator")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/03-backup-download-passphrase.png` });
// Encryption may still be running when the user commits the download. The
// explicit click queues the native save without exposing the password or
// fetching the raw key.
await page.keyboard.press("Escape");
await expect(page.getByTestId("backup-passphrase-separator")).toHaveCount(0);
// Saving commits the encrypted payload only after this explicit action.
await page.getByTestId("encrypted-backup-create").click();
// Only a successful save (the mock "picks" a path) advances to the
// "Optionally, test your backup" flow: a select-file button for the saved file
// (a composer-style drop overlay takes over the card while a file drag is
// over the window), then the password to unlock it.
await expect(
page.getByRole("heading", { name: "Optionally, test your backup" }),
).toBeVisible();
const dropzone = page.getByTestId("backup-test-dropzone");
await expect(dropzone).toBeVisible();
await expect(
page.getByRole("button", { name: "Re-download backup" }),
).toBeVisible();
// The optional security subview has no onboarding Next action. Returning to
// the yellow key view is the single exit throughout the ceremony.
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await expect(page.getByTestId("backup-return-to-onboarding")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-backup-test-dropzone.png` });
// A wrong file is rejected with an inline error; the dropzone stays.
await page.getByTestId("backup-test-file-input").setInputFiles({
name: "notes.txt",
mimeType: "text/plain",
buffer: Buffer.from("not a key backup"),
});
await expect(page.getByTestId("backup-test-error")).toBeVisible();
// A file drag over the window swaps in the drop overlay; leaving without
// dropping restores the select button.
const dropOverlay = page.getByTestId("backup-test-drop-overlay");
await startWindowFileDrag(page);
await expect(dropOverlay).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04b-backup-test-drop-overlay.png` });
await endWindowFileDrag(page);
await expect(dropOverlay).toHaveCount(0);
// Dropping the freshly downloaded file on the overlay advances to the
// password check.
await startWindowFileDrag(page);
await expect(dropOverlay).toBeVisible();
await dropFileOnTestId(page, "backup-test-drop-overlay", MOCK_NCRYPTSEC);
const password = page.getByTestId("backup-test-password");
await expect(password).toBeVisible();
await expect(dropOverlay).toHaveCount(0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/05-backup-test-password.png` });
// Verification is explicit and clears every submitted attempt.
await password.fill("mock-horse-battery-staplX");
await page.getByTestId("backup-test-verify").click();
await expect(page.getByTestId("backup-test-error")).toBeVisible();
await expect(password).toHaveValue("");
await password.fill("mock horse battery staple lake orbit");
await page.getByTestId("backup-test-verify").click();
await expect(page.getByTestId("backup-test-success")).toBeVisible();
// The celebration is driven by motion's rAF loop, which
// `waitForAnimations` (WAAPI-only) cannot observe — hold until the badge
// and copy have faded in before capturing.
await page.waitForTimeout(1200);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/06-backup-test-success.png` });
// The download path must never have fetched the raw key.
const commands = await invokedCommands(page);
expect(commands).not.toContain("get_nsec");
expect(commands).toContain("create_ncryptsec_backup");
// Completion remains inside the optional security subview. Return to the
// yellow key view, whose standard Next action continues onboarding.
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
test("security view returns to the yellow onboarding view", async ({
page,
}) => {
await enterMachineBackup(page);
await openPasswordBackup(page);
await expect(page.getByTestId("backup-passphrase-input")).toBeVisible();
await expect(page.getByTestId("onboarding-step-dots")).toHaveCount(0);
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("backup-key-value")).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toBeVisible();
});
test("returning to onboarding resets password-backup progress", async ({
page,
}) => {
await enterMachineBackup(page);
await openPasswordBackup(page);
const input = page.getByTestId("backup-passphrase-input");
await input.fill("mock-horse-battery-staple");
await page.getByTestId("encrypted-backup-create").click();
await expect(
page.getByRole("heading", { name: "Optionally, test your backup" }),
).toBeVisible();
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
// Re-entering the security flow intentionally starts a fresh optional
// backup session so no password or completed state leaks across navigation.
await openPasswordBackup(page);
await expect(
page.getByRole("heading", { name: "Backup your key with a password" }),
).toBeVisible();
await expect(page.getByTestId("backup-passphrase-input")).toHaveValue("");
await expect(page.getByTestId("encrypted-backup-create")).toBeDisabled();
});
test("typed password requires 12 characters", async ({ page }) => {
await enterMachineBackup(page);
await openPasswordBackup(page);
const create = page.getByTestId("encrypted-backup-create");
await expect(create).toBeDisabled(); // empty field
await page.getByTestId("backup-passphrase-input").fill("short");
await expect(page.getByTestId("backup-passphrase-issue")).toBeVisible();
await expect(create).toBeDisabled();
await page
.getByTestId("backup-passphrase-input")
.fill("a much longer passphrase");
await expect(page.getByTestId("backup-passphrase-issue")).toHaveCount(0);
await expect(create).toBeEnabled();
});
test("backup step back button returns to machine identity choice", async ({
page,
}) => {
await enterMachineBackup(page);
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await page.getByTestId("onboarding-back").click();
// Backing out preserves the loaded key — primary CTA continues setup rather
// than minting another identity (#2318).
await expect(
page.getByRole("button", { name: "Continue setup" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Use a different key instead" }),
).toBeVisible();
});
// ---------------------------------------------------------------------------
// B4: Error path coverage (reveal/copy)
// ---------------------------------------------------------------------------
test("reveal shows inline error when get_nsec fails and Next still advances", async ({
page,
}) => {
await installMockBridge(
page,
{ nsecError: "Keychain locked" },
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(page.getByTestId("backup-copy-error")).toBeVisible();
// Keychain failure does not trap the user: Next still skips backup and
// advances directly to setup.
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
test("reveal retry succeeds after initial failure", async ({ page }) => {
// First call fails, second succeeds (sequenced via nsecErrors).
await installMockBridge(
page,
{ nsecErrors: ["Keychain locked", null] },
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(page.getByTestId("backup-copy-error")).toBeVisible();
// Retry — second call succeeds and clears the error.
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(page.getByTestId("backup-key-value")).toContainText("nsec1mock");
await expect(page.getByTestId("backup-copy-error")).not.toBeVisible();
});
@@ -0,0 +1,228 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { seedActiveIdentity } from "../helpers/onboarding";
const BLANK_TYLER_IDENTITY = {
...TEST_IDENTITIES.tyler,
username: "",
};
const SHOT_DIR = "test-results/onboarding-docked-cta";
const NCRYPTSEC =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
test.use({ viewport: { width: 1280, height: 800 } });
test("machine onboarding: landing, backup, setup docked CTAs", async ({
page,
}) => {
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
const gate = page.getByTestId("machine-onboarding-gate");
await expect(gate).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/01-landing.png` });
await page.getByRole("button", { name: "Use an existing key" }).click();
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
const importCard = page.getByTestId("nostr-import-card");
await expect(importCard).toBeVisible();
await expect(page.getByLabel("Private key", { exact: true })).toBeVisible();
// The production card uses a baked nine-slice texture: no runtime SVG
// filter, measurement, or texture regeneration during resize.
await expect(importCard).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(importCard).toHaveCSS("border-top-width", "0px");
await expect(importCard).toHaveCSS("border-image-repeat", "repeat");
await expect(importCard).toHaveCSS("border-image-outset", "96px");
// Icon SVGs (e.g. the reveal toggle) are fine; a filter would mean the
// texture regressed to the runtime SVG pipeline.
await expect(importCard.locator("svg filter")).toHaveCount(0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/01b-enter-key.png` });
await page.getByTestId("nostr-import-nsec-input").fill(NCRYPTSEC);
await expect(
page.getByRole("heading", { name: "Unlock your account" }),
).toBeVisible();
await expect(page.getByTestId("backup-password-timeline")).toBeVisible();
await expect(page.getByTestId("restore-ncryptsec-affordance")).toBeVisible();
await expect(page.getByTestId("restore-unlock-icon")).toBeVisible();
await expect(page.getByTestId("nostr-import-passphrase")).toBeFocused();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/01c-restore-backup.png` });
// The first Back returns to key selection; the second leaves import.
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(importCard).toBeVisible();
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(
page.getByRole("button", { name: "Create a new identity key" }),
).toBeVisible();
await page.getByRole("button", { name: "Create a new identity key" }).click();
await expect(
page.getByRole("heading", {
name: "Your unique identity key has been created",
}),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02-backup.png` });
// The key stays masked behind an explicit reveal toggle.
await expect(page.getByTestId("backup-key-value")).toBeVisible();
// Reveal the key: box must not reflow (same-length monospace mask).
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(page.getByTestId("backup-key-value")).toHaveClass(/select-text/);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02b-backup-revealed.png` });
// Backup options leave the yellow flow for the dark security view without
// adding a progress step or a generic Next action.
await page.getByTestId("backup-options-link").click();
await expect(
page.getByTestId("onboarding-page-backup-options"),
).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
const optionPanels = page.getByTestId("backup-option-panel");
await expect(optionPanels).toHaveCount(3);
await expect(
page.getByTestId("backup-options").locator(".buzz-card-textured"),
).toHaveCount(0);
await expect(optionPanels.first()).toHaveCSS("padding-left", "24px");
const titleTops = await optionPanels
.locator("span.text-lg")
.evaluateAll((titles) =>
titles.map((title) => title.getBoundingClientRect().top),
);
expect(Math.max(...titleTops) - Math.min(...titleTops)).toBeLessThan(1);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02c-backup-options.png` });
await page.getByTestId("backup-option-password").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
const passwordPanel = page.getByTestId("backup-password-panel");
await expect(passwordPanel).toBeVisible();
await expect(passwordPanel).not.toHaveClass(/buzz-card-textured/);
await expect(passwordPanel).toHaveCSS("padding-left", "24px");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02d-backup-password.png` });
await page.getByTestId("backup-passphrase-generate").click();
const generatorPopover = page.getByRole("dialog");
await expect(generatorPopover).toBeVisible();
await expect(generatorPopover).not.toHaveClass(/buzz-card-textured/);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02e-backup-generator.png` });
await page.keyboard.press("Escape");
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await page.getByTestId("onboarding-next").click();
await expect(
page.getByRole("heading", { name: "Set up your agent harnesses" }),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/03-setup.png` });
});
test("machine key import remains usable in a short viewport", async ({
page,
}) => {
await page.setViewportSize({ width: 900, height: 620 });
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
const heading = page.getByRole("heading", { name: "Enter your private key" });
const input = page.getByLabel("Private key", { exact: true });
const footer = page.getByTestId("onboarding-footer-slot");
await expect(heading).toBeVisible();
await expect(input).toBeVisible();
await expect(footer).toBeVisible();
const layout = await page.evaluate(() => {
const heading = document.querySelector("h1")?.getBoundingClientRect();
const input = document
.querySelector<HTMLInputElement>("#nostr-private-key")
?.getBoundingClientRect();
const footer = document
.querySelector('[data-testid="onboarding-footer-slot"]')
?.getBoundingClientRect();
return {
footerTop: footer?.top ?? 0,
headingBottom: heading?.bottom ?? 0,
inputBottom: input?.bottom ?? 0,
inputTop: input?.top ?? 0,
clientWidth: document.documentElement.clientWidth,
scrollHeight: document.documentElement.scrollHeight,
scrollWidth: document.documentElement.scrollWidth,
};
});
expect(layout.inputTop).toBeGreaterThan(layout.headingBottom);
expect(layout.footerTop).toBeGreaterThan(layout.inputBottom);
expect(layout.scrollHeight).toBeGreaterThanOrEqual(620);
expect(layout.scrollWidth).toBe(layout.clientWidth);
});
test("backup options keep one-column geometry on narrow windows", async ({
page,
}) => {
await page.setViewportSize({ width: 600, height: 700 });
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await expect(
page.getByRole("heading", {
name: "Your unique identity key has been created",
}),
).toBeVisible();
await page.getByTestId("backup-options-link").click();
const panels = page.getByTestId("backup-option-panel");
await expect(panels).toHaveCount(3);
const geometry = await panels.evaluateAll((elements) => ({
clientWidth: document.documentElement.clientWidth,
lefts: elements.map((element) => element.getBoundingClientRect().left),
rights: elements.map((element) => element.getBoundingClientRect().right),
scrollWidth: document.documentElement.scrollWidth,
}));
expect(new Set(geometry.lefts.map(Math.round)).size).toBe(1);
expect(geometry.lefts.every((left) => left >= 0)).toBe(true);
expect(geometry.rights.every((right) => right <= geometry.clientWidth)).toBe(
true,
);
expect(geometry.scrollWidth).toBe(geometry.clientWidth);
});
test("relay onboarding: profile and avatar docked CTAs", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await page.getByTestId("onboarding-display-name").fill("Ada Lovelace");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/04-profile.png` });
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page
.getByTestId("onboarding-avatar-url")
.fill("https://example.com/onboarding-avatar.png");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/05-avatar.png` });
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,160 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
async function dispatchWheelPrevented(
page: import("@playwright/test").Page,
selector: string,
deltas: { deltaX?: number; deltaY?: number },
) {
return page.evaluate(
({ selector, deltaX, deltaY }) => {
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Missing element for selector: ${selector}`);
}
const event = new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
deltaX,
deltaY,
});
element.dispatchEvent(event);
return event.defaultPrevented;
},
{ selector, deltaX: deltas.deltaX ?? 0, deltaY: deltas.deltaY ?? 0 },
);
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
test("locks viewport rubber-band outside conversation scrollers", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("message-timeline")).toBeVisible();
await expect(
dispatchWheelPrevented(page, '[data-testid="app-top-chrome"]', {
deltaY: -120,
}),
).resolves.toBe(true);
await expect(
dispatchWheelPrevented(page, '[data-testid="sidebar-pinned-header"]', {
deltaY: -120,
}),
).resolves.toBe(true);
await expect(
dispatchWheelPrevented(page, '[data-testid="app-sidebar-scroll-anchor"]', {
deltaY: -120,
}),
).resolves.toBe(true);
await expect(
dispatchWheelPrevented(page, '[data-testid="chat-title"]', {
deltaY: -120,
}),
).resolves.toBe(true);
await expect(
dispatchWheelPrevented(page, '[data-testid="message-timeline"]', {
deltaY: -120,
}),
).resolves.toBe(false);
// Buzz Term consumes wheel gestures as custom scrollback rather than through
// a native scroll container. The viewport lock must leave that vertical
// gesture alone so the substrate's own handler can receive it.
await page.evaluate(() => {
const terminal = document.createElement("section");
terminal.dataset.terminalOwner = "terminal";
terminal.dataset.testid = "terminal-wheel-target";
document.body.append(terminal);
});
await expect(
dispatchWheelPrevented(page, '[data-testid="terminal-wheel-target"]', {
deltaY: -120,
}),
).resolves.toBe(false);
});
test("locks horizontal viewport pan everywhere", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("message-timeline")).toBeVisible();
for (const deltaX of [-120, 120]) {
await expect(
dispatchWheelPrevented(page, '[data-testid="app-top-chrome"]', {
deltaX,
}),
).resolves.toBe(true);
await expect(
dispatchWheelPrevented(page, '[data-testid="sidebar-pinned-header"]', {
deltaX,
}),
).resolves.toBe(true);
await expect(
dispatchWheelPrevented(page, '[data-testid="chat-title"]', { deltaX }),
).resolves.toBe(true);
// Unlike vertical, horizontal pans over the conversation pane are locked
// too — there is no horizontal elastic affordance.
await expect(
dispatchWheelPrevented(page, '[data-testid="message-timeline"]', {
deltaX,
}),
).resolves.toBe(true);
}
await page.evaluate(() => {
const terminal = document.createElement("section");
terminal.dataset.terminalOwner = "terminal";
terminal.dataset.testid = "terminal-wheel-target";
document.body.append(terminal);
});
for (const [deltaX, deltaY, prevented] of [
[120, 0, true],
[0, 120, false],
[120, 120, false],
[120, 20, true],
] as const) {
await expect(
dispatchWheelPrevented(page, '[data-testid="terminal-wheel-target"]', {
deltaX,
deltaY,
}),
).resolves.toBe(prevented);
}
// A concealed substrate is not an active custom wheel consumer. Keep dead
// space locked even if a future layout places Buzz content inside it.
await page.evaluate(() => {
const terminal = document.querySelector<HTMLElement>(
'[data-testid="terminal-wheel-target"]',
);
const buzzContent = document.createElement("div");
buzzContent.dataset.testid = "concealed-terminal-dead-space";
terminal?.append(buzzContent);
if (terminal) terminal.dataset.terminalOwner = "buzz";
});
await expect(
dispatchWheelPrevented(
page,
'[data-testid="concealed-terminal-dead-space"]',
{ deltaY: 120 },
),
).resolves.toBe(true);
// A predominantly vertical gesture with slight horizontal drift still
// reaches the conversation scroller.
await expect(
dispatchWheelPrevented(page, '[data-testid="message-timeline"]', {
deltaX: -10,
deltaY: -120,
}),
).resolves.toBe(false);
});
@@ -0,0 +1,161 @@
import { expect, test } from "@playwright/test";
import { installRelayBridge } from "../helpers/bridge";
import { assertRelaySeeded } from "../helpers/seed";
import { ancestorIsland, seedScenario } from "../helpers/seedRelay";
// =============================================================================
// LIVE-RELAY parity — ancestor-island cursor poisoning (GUI read-model overhaul)
// =============================================================================
//
// The deterministic current-main RED (Dawn's Jul-2 root cause, Wren's Jul-3
// proven repro `239cc161`). The dense-second wall is NOT the disease — main
// already drains a tied second via the PR #1418 keyset fallback. THIS is the
// flaw the server-assembled windowed read model deletes:
//
// 1. Cold channel load paints the newest CHANNEL_HISTORY_LIMIT (60) rows.
// 2. One of those rows is a reply whose root/parent is OUTSIDE that window.
// 3. useLoadMissingAncestors fetches that old root by id and merges it into
// the SAME channel cache — a non-contiguous "island" days older than the
// contiguous window.
// 4. pageOlderMessages anchors `oldestTimestamp = baseline[0].created_at` on
// the island, so every scroll-up pages backward FROM the island and
// permanently skips the real history between it and the true frontier.
// 5. CLI `/query` returns those `gap-*` rows; the GUI never requests them.
//
// Contract: every top-level `gap-*` row the relay holds must be reachable in the
// timeline. RED on main (the pager anchors on the island and skips the gap
// entirely — `gap` rows reached === 0); GREEN on the windowed read model, whose
// relay-owned cursor cannot be moved by an out-of-band ancestor merge.
const RELAY_HTTP = process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000";
// uuid5(NAMESPACE_DNS, "buzz.channel.general") — the seeded `general` channel.
const GENERAL_CHANNEL_ID = "9f28288a-d724-587a-9709-92dc7f967110";
// >60 newest rows so the cold window (CHANNEL_HISTORY_LIMIT=60) does NOT reach
// the gap; the gap sits below the frontier, the old root below the gap.
const GAP_COUNT = 100;
const NEWEST_COUNT = 70;
test.beforeAll(async () => {
test.setTimeout(90_000);
await assertRelaySeeded();
});
test("live relay: an ancestor island does not strand the history frontier", async ({
page,
}, testInfo) => {
testInfo.setTimeout(180_000);
// Per-run isolation: the suite seeds into shared `general`, which accumulates
// rows across runs. A generic `gap \d+` match lets a prior run's rows inflate
// the reachable set and false-green the parity assertion (observed: a
// contaminated relay "passed" in 3.3s while a clean channel is RED with
// seen.size === 0). Tag this run's rows and assert only against them.
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const scenario = ancestorIsland({
channelId: GENERAL_CHANNEL_ID,
gapCount: GAP_COUNT,
newestCount: NEWEST_COUNT,
nonce,
});
const expected = await seedScenario(scenario, { relayHttpUrl: RELAY_HTTP });
const expectedGap = new Set(expected.map((e) => e.content));
expect(expectedGap.size).toBe(GAP_COUNT);
await installRelayBridge(page, "tyler");
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
// Establish the poison precondition on the diseased client: on main,
// useLoadMissingAncestors fetches the old root by id and merges it into the
// channel cache, and "island root" appears in the timeline — that injected
// island is what strands the pager. The overhaul DELETES useLoadMissingAncestors
// (the relay-owned window cursor can't be moved by an out-of-band merge), so
// the island is never injected and this row never appears. Best-effort, not a
// hard gate: on main it settles the poison before we scroll; on the overhaul
// it just times out harmlessly and we proceed to the reachability invariant —
// which is the assertion that actually flips 0/100 (stranded) → 100/100.
await timeline
.getByText(`island root ${nonce}`, { exact: false })
.first()
.waitFor({ state: "visible", timeout: 15_000 })
.catch(() => {
/* cured client: no ancestor fetch, no island — expected on the overhaul */
});
// Union of THIS run's `gap` contents ever rendered. Virtualization only
// mounts a window, so accumulate across scroll passes rather than snapshot
// once. Match on the run nonce so a prior run's rows can't inflate the set.
const gapPattern = new RegExp(`gap ${nonce} \\d+`);
const renderedGapContents = async () =>
timeline.evaluate((element, pattern: string) => {
const re = new RegExp(pattern);
const found: string[] = [];
for (const row of (
element as HTMLDivElement
).querySelectorAll<HTMLElement>("[data-message-id]")) {
const match = row.textContent?.match(re);
if (match) found.push(match[0]);
}
return found;
}, gapPattern.source);
// A real wheel-up gesture per pass: the older-history sentinel arms on a
// genuine leave→enter transition (IntersectionObserver), so a raw scrollTop=0
// write can fail to re-fire. A wheel event is what a real user issues.
const wheelToTop = async () => {
for (let step = 0; step < 12; step += 1) {
const atTop = await timeline.evaluate(
(element) => (element as HTMLDivElement).scrollTop <= 1,
);
if (atTop) break;
await page.mouse.wheel(0, -6000);
await page.waitForTimeout(40);
}
};
const seen = new Set<string>();
const collect = async () => {
for (const content of await renderedGapContents()) seen.add(content);
};
await timeline.hover();
let stallStreak = 0;
for (let attempt = 0; attempt < 200 && seen.size < GAP_COUNT; attempt += 1) {
const before = seen.size;
await wheelToTop();
try {
await expect
.poll(
async () => {
await collect();
return seen.size;
},
{ timeout: 4_000 },
)
.toBeGreaterThan(before);
} catch {
// No growth this pass — count toward a genuine stall.
}
await collect();
if (seen.size > before) {
stallStreak = 0;
} else {
stallStreak += 1;
if (stallStreak > 8) break;
}
}
// Parity: every gap row the relay holds must be reachable. RED on main — the
// pager anchors on the injected island (old root) and pages backward from it,
// skipping the entire gap span (reaches ~0 of GAP_COUNT). GREEN on the
// windowed read model, whose relay-owned cursor an ancestor merge can't move.
expect(seen.size).toBeGreaterThan(GAP_COUNT * 0.9);
});
@@ -0,0 +1,290 @@
import { expect, test, type Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/persistent-agent-audience";
const OWNER = "deadbeef".repeat(8);
const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const AGENT_A = "a".repeat(64);
const AGENT_B = "b".repeat(64);
const THREAD_ROOT_ID = "mock-general-welcome";
const SCOPE = `${OWNER}:${CHANNEL_ID}:thread:${THREAD_ROOT_ID}`;
async function seedAudience(page: Page, pubkeys: string[], theme = "buzz") {
await page.addInitScript(
({ audience, scope, selectedTheme }) => {
window.localStorage.setItem("buzz:keep-addressed-agents-active", "1");
window.localStorage.setItem(
"buzz:persistent-agent-audiences:v2",
JSON.stringify({ [scope]: audience }),
);
window.localStorage.setItem("buzz-theme", selectedTheme);
},
{ audience: pubkeys, scope: SCOPE, selectedTheme: theme },
);
}
async function openGeneral(page: Page) {
await page.goto(`/#/channels/${CHANNEL_ID}`, {
waitUntil: "domcontentloaded",
});
await expect(page.getByTestId("chat-title")).toHaveText("general");
}
async function openThread(page: Page, threadRootId = THREAD_ROOT_ID) {
await page.goto(
`/#/channels/${CHANNEL_ID}?messageId=${threadRootId}&thread=${threadRootId}`,
{ waitUntil: "domcontentloaded" },
);
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
}
async function emitRootMessage(
page: Page,
content: string,
mentionPubkeys: string[],
) {
const event = await page.evaluate(
({ message, pubkeys }) =>
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
mentionPubkeys: string[];
}) => { id: string };
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: message,
mentionPubkeys: pubkeys,
}),
{ message: content, pubkeys: mentionPubkeys },
);
if (!event) throw new Error("Mock message emitter is not installed");
return event;
}
function channelComposer(page: Page) {
return page.getByTestId("channel-composer-overlay");
}
function threadComposer(page: Page) {
return page.getByTestId("thread-composer-overlay");
}
async function installAudienceFixtures(
page: Page,
options: { sendMessageDelayMs?: number } = {},
) {
await installMockBridge(page, {
...options,
managedAgents: [
{
pubkey: AGENT_A,
name: "Morgarita",
status: "running",
channelNames: ["general"],
},
{
pubkey: AGENT_B,
name: "Vogue",
status: "running",
channelNames: ["general"],
},
],
});
}
test("first thread open inherits explicitly addressed agents in authored order", async ({
page,
}) => {
await page.addInitScript(() => {
window.localStorage.setItem("buzz:keep-addressed-agents-active", "1");
});
await installAudienceFixtures(page);
await openGeneral(page);
const root = await emitRootMessage(
page,
"@Vogue please pair with @Morgarita",
// Event tag order deliberately opposes authored mention order.
[AGENT_A, AGENT_B],
);
await openThread(page, root.id);
const input = threadComposer(page).getByTestId("message-input");
await expect(input).toHaveText("@Vogue @Morgarita ");
await expect(input.locator(".agent-mention-highlight")).toHaveCount(2);
await expect
.poll(() =>
page.evaluate(
({ owner, channelId, rootId }) => {
const stored = JSON.parse(
localStorage.getItem("buzz:persistent-agent-audiences:v2") ?? "{}",
);
return stored[`${owner}:${channelId}:thread:${rootId}`] ?? null;
},
{ owner: OWNER, channelId: CHANNEL_ID, rootId: root.id },
),
)
.toEqual([AGENT_B, AGENT_A]);
});
test("persistent agents transition atomically before Enter-send resolves", async ({
page,
}) => {
await seedAudience(page, [AGENT_A]);
await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 });
await openThread(page);
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
const send = composer.getByTestId("send-message");
await input.fill("@Morgarita hello");
await input.press("Enter");
// The network send is still pending, so this is the first observable
// post-submit editor state rather than the later success hydration pass.
await expect(input).toHaveText("@Morgarita ", { timeout: 500 });
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1, {
timeout: 500,
});
await expect(input).toBeFocused();
await page.waitForTimeout(200);
await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0);
await expect(send).toBeEnabled();
await expect
.poll(() =>
input.evaluate((element) => {
const selection = window.getSelection();
const viewDesc = (
element as HTMLElement & {
pmViewDesc?: {
posFromDOM: (node: Node, offset: number, bias: number) => number;
size: number;
};
}
).pmViewDesc;
if (!selection?.anchorNode || !viewDesc) return null;
const position = viewDesc.posFromDOM(
selection.anchorNode,
selection.anchorOffset,
1,
);
// The root view desc includes the document's two boundary tokens,
// while posFromDOM is relative to the editable root. Converting both
// to ProseMirror coordinates proves selection.from/to === doc.content.size.
return {
empty: selection.isCollapsed,
atDocumentEnd: position + 1 === viewDesc.size - 2,
};
}),
)
.toEqual({ empty: true, atDocumentEnd: true });
});
test("timeline agent send remains one-shot and returns to the placeholder", async ({
page,
}) => {
await seedAudience(page, [AGENT_A]);
await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 });
await openGeneral(page);
const composer = channelComposer(page);
const input = composer.getByTestId("message-input");
await input.fill("@Mor");
await composer
.getByTestId("mention-autocomplete")
.getByText("Morgarita", { exact: true })
.click();
await input.pressSequentially("hello");
await expect(input).toHaveText("@Morgarita hello");
await input.press("Enter");
await expect(input).toHaveText("", { timeout: 500 });
await expect(input.locator("[data-placeholder]").first()).toHaveAttribute(
"data-placeholder",
"Message #general",
{ timeout: 500 },
);
await expect(input).toBeFocused();
await expect
.poll(() =>
input.evaluate((element) => {
const selection = window.getSelection();
return {
collapsed: selection?.isCollapsed ?? false,
inside: Boolean(
selection?.anchorNode && element.contains(selection.anchorNode),
),
};
}),
)
.toEqual({ collapsed: true, inside: true });
});
test("persistent agents restore through the native inline mention UI", async ({
page,
}) => {
await seedAudience(page, [AGENT_B, AGENT_A]);
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
await expect(input).toHaveText("@Vogue @Morgarita ");
await expect(page.getByText("Talking to", { exact: true })).toHaveCount(0);
await expect(input.locator(".agent-mention-highlight")).toHaveCount(2);
await input.fill("@Morgarita hello");
await expect
.poll(() =>
page.evaluate(
({ scope }) => {
const stored = JSON.parse(
localStorage.getItem("buzz:persistent-agent-audiences:v2") ?? "{}",
);
return stored[scope] ?? [];
},
{ scope: SCOPE },
),
)
.toEqual([AGENT_A]);
await composer.getByTestId("send-message").click();
await expect(input).toContainText("@Morgarita");
await expect(input).not.toContainText("@Vogue");
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
});
for (const theme of ["buzz", "buzz-dark"]) {
test(`captures native persistent mentions in ${theme}`, async ({ page }) => {
await seedAudience(page, [AGENT_A, AGENT_B], theme);
await installAudienceFixtures(page);
await openThread(page);
const overlay = threadComposer(page);
const composer = overlay.getByTestId("message-composer");
await overlay.getByTestId("message-input").focus();
await waitForAnimations(page);
await composer.screenshot({
path: `${SHOTS}/${theme}-native-mentions.png`,
});
});
}
test("native persistent mentions fit the narrow composer", async ({ page }) => {
await page.setViewportSize({ width: 700, height: 760 });
await seedAudience(page, [AGENT_A, AGENT_B]);
await installAudienceFixtures(page);
await openThread(page);
const overlay = threadComposer(page);
const composer = overlay.getByTestId("message-composer");
await expect(overlay.getByTestId("message-input")).toContainText(
"@Morgarita",
);
await waitForAnimations(page);
await composer.screenshot({ path: `${SHOTS}/narrow-native-mentions.png` });
});
+353
View File
@@ -0,0 +1,353 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
async function gotoApp(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForInvokeBridge(page);
await expect(page.getByTestId("open-agents-view")).toBeVisible({
timeout: 10_000,
});
}
async function waitForInvokeBridge(page: import("@playwright/test").Page) {
await page.waitForFunction(
() => {
const w = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
__TAURI_INTERNALS__?: { invoke?: unknown };
};
return (
typeof w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" ||
typeof w.__TAURI_INTERNALS__?.invoke === "function"
);
},
null,
{ timeout: 5_000 },
);
}
async function invokeTauri<T>(
page: import("@playwright/test").Page,
command: string,
payload?: Record<string, unknown>,
): Promise<T> {
await waitForInvokeBridge(page);
return page.evaluate(
async ({ command: c, payload: p }) => {
const w = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
c: string,
p?: Record<string, unknown>,
) => Promise<unknown>;
__TAURI_INTERNALS__?: {
invoke?: (c: string, p?: Record<string, unknown>) => Promise<unknown>;
};
};
const invoke =
w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ?? w.__TAURI_INTERNALS__?.invoke;
if (!invoke) throw new Error("Mock invoke bridge is unavailable.");
return (await invoke(c, p)) as T;
},
{ command, payload },
);
}
async function openModelCombobox(
page: import("@playwright/test").Page,
model: import("@playwright/test").Locator,
) {
// PersonaModelCombobox renders a role="combobox" trigger + a Radix Popover
// with a search <input> and plain <button> options — not a role="menu".
await model.click();
const searchInput = page.getByPlaceholder("Search models…");
await expect(searchInput).toBeVisible({ timeout: 5_000 });
// Return the popover content container so callers can scope option clicks.
return page.locator("[data-radix-popper-content-wrapper]").last();
}
async function selectDropdownOption(
page: import("@playwright/test").Page,
trigger: import("@playwright/test").Locator,
optionName: string,
) {
for (let attempt = 0; attempt < 2; attempt += 1) {
await expect(trigger).toBeVisible({ timeout: 2_000 });
await trigger.press("Enter", { timeout: 2_000 });
const menu = page
.getByRole("menu")
.filter({
has: page.getByRole("menuitemradio", {
exact: true,
name: optionName,
}),
})
.last();
try {
await expect(menu).toBeVisible({ timeout: 1_500 });
await menu
.getByRole("menuitemradio", { exact: true, name: optionName })
.click({ force: true, timeout: 1_500 });
return;
} catch (error) {
if (attempt === 1) throw error;
await page.keyboard.press("Escape", { timeout: 1_000 });
await waitForAnimations(page);
}
}
}
test("persona env_vars round-trip through create_persona + update_persona", async ({
page,
}) => {
await gotoApp(page);
const created = await invokeTauri<{
id: string;
env_vars?: Record<string, string>;
}>(page, "create_persona", {
input: {
displayName: "Coder",
systemPrompt: "You are Coder.",
envVars: {
ANTHROPIC_API_KEY: "sk-ant-test-001",
ANTHROPIC_MODEL: "claude-sonnet-4-5",
},
},
});
expect(created.env_vars).toEqual({
ANTHROPIC_API_KEY: "sk-ant-test-001",
ANTHROPIC_MODEL: "claude-sonnet-4-5",
});
// Update: drop one, add one, change one.
const updated = await invokeTauri<{ env_vars?: Record<string, string> }>(
page,
"update_persona",
{
input: {
id: created.id,
displayName: "Coder",
systemPrompt: "You are Coder.",
envVars: {
ANTHROPIC_API_KEY: "sk-ant-test-002", // changed
OPENAI_COMPAT_API_KEY: "sk-new", // added
// ANTHROPIC_MODEL dropped
},
},
},
);
expect(updated.env_vars).toEqual({
ANTHROPIC_API_KEY: "sk-ant-test-002",
OPENAI_COMPAT_API_KEY: "sk-new",
});
// list_personas returns the updated map.
const list = await invokeTauri<
Array<{ id: string; env_vars?: Record<string, string> }>
>(page, "list_personas");
const reloaded = list.find((p) => p.id === created.id);
expect(reloaded?.env_vars).toEqual({
ANTHROPIC_API_KEY: "sk-ant-test-002",
OPENAI_COMPAT_API_KEY: "sk-new",
});
});
test("update_persona preserves env_vars when caller omits the field", async ({
page,
}) => {
// Regression: codex R2 P1. Editing display_name / system_prompt via a
// helper that doesn't populate envVars must NOT wipe stored credentials.
await gotoApp(page);
const created = await invokeTauri<{
id: string;
env_vars?: Record<string, string>;
}>(page, "create_persona", {
input: {
displayName: "WithSecrets",
systemPrompt: "You have secrets.",
envVars: {
ANTHROPIC_API_KEY: "sk-keep-me",
},
},
});
expect(created.env_vars).toEqual({ ANTHROPIC_API_KEY: "sk-keep-me" });
// Update WITHOUT including envVars at all. Stored value must survive.
const updated = await invokeTauri<{ env_vars?: Record<string, string> }>(
page,
"update_persona",
{
input: {
id: created.id,
displayName: "WithSecrets (renamed)",
systemPrompt: "You have secrets.",
// envVars intentionally omitted
},
},
);
expect(updated.env_vars).toEqual({ ANTHROPIC_API_KEY: "sk-keep-me" });
// Explicit empty map still clears (intentional).
const cleared = await invokeTauri<{ env_vars?: Record<string, string> }>(
page,
"update_persona",
{
input: {
id: created.id,
displayName: "WithSecrets (renamed)",
systemPrompt: "You have secrets.",
envVars: {},
},
},
);
expect(cleared.env_vars ?? {}).toEqual({});
});
test("agent env_vars override persona env_vars on the agent record", async ({
page,
}) => {
await gotoApp(page);
const persona = await invokeTauri<{ id: string }>(page, "create_persona", {
input: {
displayName: "Provider",
systemPrompt: "You are Provider.",
envVars: {
ANTHROPIC_API_KEY: "persona-key",
SHARED_VAR: "from-persona",
},
},
});
const created = await invokeTauri<{
agent: { pubkey: string; env_vars?: Record<string, string> };
}>(page, "create_managed_agent", {
input: {
name: "agent-with-overrides",
personaId: persona.id,
backend: { type: "local" },
envVars: {
ANTHROPIC_API_KEY: "agent-key", // overrides persona
AGENT_ONLY: "1", // agent-only
},
},
});
expect(created.agent.env_vars).toEqual({
ANTHROPIC_API_KEY: "agent-key",
AGENT_ONLY: "1",
});
const updated = await invokeTauri<{
agent: { env_vars?: Record<string, string> };
}>(page, "update_managed_agent", {
input: {
pubkey: created.agent.pubkey,
envVars: {
AGENT_ONLY: "2",
},
},
});
expect(updated.agent.env_vars).toEqual({ AGENT_ONLY: "2" });
});
test("env vars editor renders in PersonaDialog new-persona form", async ({
page,
}) => {
await gotoApp(page);
// Open the Agents view; the new-agent card opens the embedded create pane.
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
// Scope all env-vars queries to the dialog: AgentDefaultsSettingsCard
// also renders an EnvVarsEditor in the background settings pane (introduced
// by this branch), so page-wide testid queries would match both.
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Advanced", exact: true }).click();
await expect(dialog.getByTestId("env-vars-editor")).toBeVisible();
// Initially empty (no rows — buzz-agent with no provider has no required keys).
await expect(dialog.getByTestId("env-vars-key")).toHaveCount(0);
// Add a row.
await dialog.getByTestId("env-vars-add").click();
await expect(dialog.getByTestId("env-vars-key")).toHaveCount(1);
// Fill it in with realistic-looking keys/values to cover masked secrets and row controls.
const keys = dialog.getByTestId("env-vars-key");
const values = dialog.getByTestId("env-vars-value");
await keys.nth(0).fill("ANTHROPIC_API_KEY");
await values.nth(0).fill("sk-ant-abc");
await dialog.getByTestId("env-vars-add").click();
await keys.nth(1).fill("GOOSE_PROVIDER");
await values.nth(1).fill("anthropic");
await dialog.getByTestId("env-vars-add").click();
await keys.nth(2).fill("OPENAI_BASE_URL");
await values.nth(2).fill("https://api.openai.com/v1");
// Capture a screenshot of the dialog with three env vars filled. Helps
// reviewers see the UI at a glance.
await waitForAnimations(page);
await dialog.screenshot({ path: "test-results/persona-env-dialog.png" });
// Remove the first row to verify per-row removal still works.
await dialog.getByTestId("env-vars-remove").first().click();
await expect(keys).toHaveCount(2);
});
test("persona model options follow the selected LLM provider", async ({
page,
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
const provider = page.locator("#persona-runtime");
await page.getByRole("tab", { name: "Customize for this agent" }).click();
const llmProvider = page.locator("#persona-llm-provider");
const model = page.locator("#persona-model");
await expect(provider).toContainText("Buzz Agent (default)");
await expect(llmProvider).toBeVisible();
await expect(model).toBeVisible();
// Custom mode requires a model selection until a provider is chosen.
await expect(model).toContainText("Choose a model");
await selectDropdownOption(page, llmProvider, "OpenAI");
const dialog = page.getByRole("dialog");
await expect(dialog.getByLabel("OpenAI Runtime API Key")).toBeVisible();
await expect(
dialog.getByRole("button", { name: "Advanced", exact: true }),
).toHaveAttribute("aria-expanded", "false");
await expect(model).toBeVisible();
// OpenAI requires an explicit model, so "Default model" is filtered out.
// The combobox offers only "Custom model..." — verify it is present and selectable.
const openAiModelPopover = await openModelCombobox(page, model);
await openAiModelPopover
.getByRole("button", { name: "Custom model...", exact: true })
.click();
await selectDropdownOption(page, llmProvider, "Anthropic");
await expect(dialog.getByLabel("Anthropic API Key")).toBeVisible();
await expect(dialog.getByLabel("OpenAI Runtime API Key")).not.toBeVisible();
await expect(model).toBeVisible();
// Switch back to inherited defaults — per-agent provider, credential, and
// model controls disappear together.
await page.getByRole("tab", { name: "Use agent defaults" }).click();
await expect(llmProvider).not.toBeVisible();
await expect(dialog.getByLabel("Anthropic API Key")).not.toBeVisible();
await expect(model).not.toBeVisible();
});
@@ -0,0 +1,151 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
const SHOTS = "test-results/persona-model-combobox";
async function waitForInvokeBridge(page: import("@playwright/test").Page) {
await page.waitForFunction(
() => {
const w = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
__TAURI_INTERNALS__?: { invoke?: unknown };
};
return (
typeof w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" ||
typeof w.__TAURI_INTERNALS__?.invoke === "function"
);
},
null,
{ timeout: 8_000 },
);
}
/**
* Open the Agents view, click "New agent", and open the persona create dialog.
* Returns the dialog locator.
*/
async function openNewPersonaDialog(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForInvokeBridge(page);
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId("agents-library-personas")).toBeVisible({
timeout: 8_000,
});
await page.getByTestId("new-agent-card").click();
const dialog = page.getByTestId("persona-dialog");
await expect(dialog).toBeVisible({ timeout: 8_000 });
await dialog.getByRole("tab", { name: "Customize for this agent" }).click();
return dialog;
}
/**
* The mock bridge selects Goose as the default runtime (available + preferred).
* Wait for model discovery to populate the combobox options so the list is
* non-trivial before opening the popover.
*/
async function waitForModelCombobox(
dialog: import("@playwright/test").Locator,
) {
// The model field only appears after a runtime is selected. Goose is the
// mock default; the field should appear without manual interaction.
const trigger = dialog.getByRole("combobox", { name: /model/i });
await expect(trigger).toBeVisible({ timeout: 8_000 });
return trigger;
}
test.describe("persona model combobox screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
page.on("console", (msg) => {
if (msg.type() === "error") {
console.error("CONSOLE ERROR:", msg.text().slice(0, 500));
}
});
await installMockBridge(page);
});
test("01 — closed trigger (model not yet selected)", async ({ page }) => {
const dialog = await openNewPersonaDialog(page);
await waitForModelCombobox(dialog);
await waitForAnimations(page);
await dialog.screenshot({ path: `${SHOTS}/01-closed-trigger.png` });
});
test("02 — open popover with full model list", async ({ page }) => {
const dialog = await openNewPersonaDialog(page);
const trigger = await waitForModelCombobox(dialog);
await trigger.click();
// Wait for the search input to appear (popover is open).
await expect(page.getByPlaceholder("Search models…")).toBeVisible({
timeout: 5_000,
});
// Wait for model discovery to populate at least one non-loading row.
await expect(
page.getByRole("button", { name: /claude|gpt|default/i }).first(),
).toBeVisible({ timeout: 8_000 });
await waitForAnimations(page);
await dialog.screenshot({ path: `${SHOTS}/02-open-full-list.png` });
});
test("03 — filtered results (query: gpt)", async ({ page }) => {
const dialog = await openNewPersonaDialog(page);
const trigger = await waitForModelCombobox(dialog);
await trigger.click();
const searchInput = page.getByPlaceholder("Search models…");
await expect(searchInput).toBeVisible({ timeout: 5_000 });
await expect(
page.getByRole("button", { name: /claude|gpt|default/i }).first(),
).toBeVisible({ timeout: 8_000 });
await searchInput.fill("gpt");
// At least one GPT option should be visible; Claude options gone.
await expect(
page.getByRole("button", { name: /gpt/i }).first(),
).toBeVisible({ timeout: 3_000 });
await waitForAnimations(page);
await dialog.screenshot({ path: `${SHOTS}/03-filtered-gpt.png` });
});
test("04 — empty state (no models match)", async ({ page }) => {
const dialog = await openNewPersonaDialog(page);
const trigger = await waitForModelCombobox(dialog);
await trigger.click();
const searchInput = page.getByPlaceholder("Search models…");
await expect(searchInput).toBeVisible({ timeout: 5_000 });
await expect(
page.getByRole("button", { name: /claude|gpt|default/i }).first(),
).toBeVisible({ timeout: 8_000 });
await searchInput.fill("zzznomatch");
await expect(page.getByText("No models match")).toBeVisible({
timeout: 3_000,
});
await waitForAnimations(page);
await dialog.screenshot({ path: `${SHOTS}/04-empty-state.png` });
});
});
+224
View File
@@ -0,0 +1,224 @@
import { expect, test } from "@playwright/test";
import { hexToBytes } from "@noble/hashes/utils.js";
import { finalizeEvent } from "nostr-tools/pure";
import { installMockBridge } from "../helpers/bridge";
// Tyler's test identity — from TEST_IDENTITIES.tyler in tests/helpers/bridge.ts
const TYLER_PRIVATE_KEY = hexToBytes(
"3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03",
);
const TYLER_PUBKEY =
"e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34";
const D_TAG = "sync-test-persona";
const KIND_PERSONA = 30175;
const KIND_DELETION = 5;
// The command scopes an inbound event to the community it arrived on. Under the
// mock bridge the app subscribes on e2eBridge's DEFAULT_RELAY_WS_URL, so that is
// the arrival relay these direct invocations stand in for.
const ARRIVAL_RELAY_URL = "ws://localhost:3000";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
async function gotoApp(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForInvokeBridge(page);
await expect(page.getByTestId("open-agents-view")).toBeVisible({
timeout: 10_000,
});
}
async function waitForInvokeBridge(page: import("@playwright/test").Page) {
await page.waitForFunction(
() => {
const w = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
__TAURI_INTERNALS__?: { invoke?: unknown };
};
return (
typeof w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" ||
typeof w.__TAURI_INTERNALS__?.invoke === "function"
);
},
null,
{ timeout: 5_000 },
);
}
async function invokeTauri<T>(
page: import("@playwright/test").Page,
command: string,
payload?: Record<string, unknown>,
): Promise<T> {
await waitForInvokeBridge(page);
return page.evaluate(
async ({ command: c, payload: p }) => {
const w = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
c: string,
p?: Record<string, unknown>,
) => Promise<unknown>;
__TAURI_INTERNALS__?: {
invoke?: (c: string, p?: Record<string, unknown>) => Promise<unknown>;
};
};
const invoke =
w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ?? w.__TAURI_INTERNALS__?.invoke;
if (!invoke) throw new Error("Mock invoke bridge is unavailable.");
return (await invoke(c, p)) as T;
},
{ command, payload },
);
}
/**
* Register a one-shot `agents-data-changed` listener BEFORE the reconcile call
* and wait up to 500 ms for it to fire (covers the 200 ms debounce coalesce).
* Returns true if the event fired, false on timeout.
*
* Must be called before the invokeTauri that triggers the emit so the listener
* is registered before the event fires no race.
*/
async function listenForAgentsDataChanged(
page: import("@playwright/test").Page,
): Promise<() => Promise<boolean>> {
// Inject a Promise into the page that resolves when the event fires.
await page.evaluate(() => {
(
window as Window & { __agentsDataChangedFired?: Promise<boolean> }
).__agentsDataChangedFired = new Promise<boolean>((resolve) => {
const internals = (
window as Window & {
__TAURI_INTERNALS__?: {
listen?: (event: string, cb: () => void) => Promise<() => void>;
};
}
).__TAURI_INTERNALS__;
if (!internals?.listen) {
resolve(false);
return;
}
void internals.listen("agents-data-changed", () => resolve(true));
// Timeout guard: 500 ms covers the 200 ms debounce coalesce with margin.
setTimeout(() => resolve(false), 500);
});
});
// Return a thunk the caller invokes after the reconcile to await the result.
return () =>
page.evaluate(
() =>
(window as Window & { __agentsDataChangedFired?: Promise<boolean> })
.__agentsDataChangedFired ?? Promise.resolve(false),
);
}
test("upsert round-trip: reconcile_inbound_persona_event writes record and emits agents-data-changed", async ({
page,
}) => {
await gotoApp(page);
const createdAt = Math.floor(Date.now() / 1000);
// Build + sign the kind:30175 persona event using nostr-tools.
const personaEvent = finalizeEvent(
{
kind: KIND_PERSONA,
content: JSON.stringify({
display_name: "Sync Test Persona",
system_prompt: "You are a sync test.",
}),
tags: [["d", D_TAG]],
created_at: createdAt,
},
TYLER_PRIVATE_KEY,
);
// Register the listener BEFORE the reconcile call — no race.
const awaitFired = await listenForAgentsDataChanged(page);
// Drive the inbound reconcile path.
await invokeTauri(page, "reconcile_inbound_persona_event", {
eventJson: JSON.stringify(personaEvent),
arrivalRelayUrl: ARRIVAL_RELAY_URL,
});
// Assert the record landed on disk.
const personas = await invokeTauri<
Array<{ id: string; display_name: string }>
>(page, "list_personas");
const record = personas.find((p) => p.id === D_TAG);
expect(record?.display_name).toBe("Sync Test Persona");
// Assert the emit fired (debounce settles within 500 ms timeout guard).
const fired = await awaitFired();
expect(fired).toBe(true);
});
test("tombstone round-trip: reconcile_inbound_persona_event removes record and emits agents-data-changed", async ({
page,
}) => {
await gotoApp(page);
const upsertCreatedAt = Math.floor(Date.now() / 1000);
// Step 1: upsert the persona first.
const personaEvent = finalizeEvent(
{
kind: KIND_PERSONA,
content: JSON.stringify({
display_name: "Sync Test Persona",
system_prompt: "You are a sync test.",
}),
tags: [["d", D_TAG]],
created_at: upsertCreatedAt,
},
TYLER_PRIVATE_KEY,
);
await invokeTauri(page, "reconcile_inbound_persona_event", {
eventJson: JSON.stringify(personaEvent),
arrivalRelayUrl: ARRIVAL_RELAY_URL,
});
// Step 2: confirm it landed.
const afterUpsert = await invokeTauri<Array<{ id: string }>>(
page,
"list_personas",
);
expect(afterUpsert.some((p) => p.id === D_TAG)).toBe(true);
// Step 3: build + sign a kind:5 deletion event. created_at must be strictly
// after the upsert so the retention db does not skip it.
const tombstoneEvent = finalizeEvent(
{
kind: KIND_DELETION,
content: "",
tags: [["a", `${KIND_PERSONA}:${TYLER_PUBKEY}:${D_TAG}`]],
created_at: upsertCreatedAt + 1,
},
TYLER_PRIVATE_KEY,
);
// Register the listener BEFORE the tombstone reconcile call — no race.
const awaitFired = await listenForAgentsDataChanged(page);
await invokeTauri(page, "reconcile_inbound_persona_event", {
eventJson: JSON.stringify(tombstoneEvent),
arrivalRelayUrl: ARRIVAL_RELAY_URL,
});
// Step 4: assert the record is gone.
const afterTombstone = await invokeTauri<Array<{ id: string }>>(
page,
"list_personas",
);
expect(afterTombstone.some((p) => p.id === D_TAG)).toBe(false);
// Step 5: assert the emit fired for the tombstone path too.
const fired = await awaitFired();
expect(fired).toBe(true);
});
@@ -0,0 +1,148 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// Charlie is a `bot` member of #agents and authors the seeded "Indexing the
// channel catalog now." message (see e2eBridge.ts). Seeding a managed agent
// with this same pubkey makes the message avatar open a managed-agent profile
// panel — the surface that renders the active-turn badges.
const AGENT_PUBKEY =
"554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea";
// Channel IDs the seeded turns point at. The badge labels resolve these to
// #general / #engineering via the channels query.
const CHANNEL_GENERAL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const CHANNEL_ENGINEERING = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9";
function seedAgent() {
return {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: "Charlie",
status: "running" as const,
channelNames: ["agents"],
},
],
};
}
async function waitForBridge(page: import("@playwright/test").Page) {
await page.waitForFunction(
() =>
typeof (window as Window & { __BUZZ_E2E_SEED_ACTIVE_TURNS__?: unknown })
.__BUZZ_E2E_SEED_ACTIVE_TURNS__ === "function",
null,
{ timeout: 10_000 },
);
}
async function openAgentsChannel(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForBridge(page);
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
}
async function seedActiveTurns(
page: import("@playwright/test").Page,
turns: { channelId: string; turnId: string }[],
) {
await page.evaluate(
({ pubkey, seeds }) => {
const win = window as Window & {
__BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: {
agentPubkey: string;
channelId: string;
turnId: string;
}) => void;
};
for (const { channelId, turnId } of seeds) {
win.__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({
agentPubkey: pubkey,
channelId,
turnId,
});
}
},
{ pubkey: AGENT_PUBKEY, seeds: turns },
);
}
// The agent's avatar is the popover trigger inside its message row; clicking it
// opens the profile panel, hovering opens the popover.
function agentAvatar(page: import("@playwright/test").Page) {
return page
.getByTestId("message-row")
.filter({ has: page.locator('[data-testid^="message-avatar-"]') })
.last()
.getByRole("button")
.first();
}
test.describe("profile active turn indicator", () => {
test.use({ viewport: { width: 1280, height: 720 } });
test("01 — profile panel: agent working in one channel", async ({ page }) => {
await installMockBridge(page, seedAgent());
await openAgentsChannel(page);
await seedActiveTurns(page, [
{ channelId: CHANNEL_GENERAL, turnId: "turn-101" },
]);
await agentAvatar(page).click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible();
const liveActivity = panel.getByTestId(
`user-profile-live-activity-${AGENT_PUBKEY}`,
);
await expect(liveActivity).toBeVisible({ timeout: 5_000 });
await expect(liveActivity).toContainText("Latest Activity");
await expect(
liveActivity.getByTestId("user-profile-activity-channel-label"),
).toContainText("#general");
});
test("02 — profile panel: agent working in two channels", async ({
page,
}) => {
await installMockBridge(page, seedAgent());
await openAgentsChannel(page);
await seedActiveTurns(page, [
{ channelId: CHANNEL_GENERAL, turnId: "turn-201" },
{ channelId: CHANNEL_ENGINEERING, turnId: "turn-202" },
]);
await agentAvatar(page).click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible();
const liveActivity = panel.getByTestId(
`user-profile-live-activity-${AGENT_PUBKEY}`,
);
await expect(liveActivity).toBeVisible({ timeout: 5_000 });
await expect(liveActivity).toContainText("Latest Activity");
// One carousel dot per working channel.
await expect(
panel.getByTestId(`user-profile-activity-dot-${CHANNEL_GENERAL}`),
).toBeVisible();
await expect(
panel.getByTestId(`user-profile-activity-dot-${CHANNEL_ENGINEERING}`),
).toBeVisible();
});
test("03 — hover popover: agent working", async ({ page }) => {
await installMockBridge(page, seedAgent());
await openAgentsChannel(page);
await seedActiveTurns(page, [
{ channelId: CHANNEL_GENERAL, turnId: "turn-301" },
]);
await agentAvatar(page).hover();
const popover = page.getByTestId("user-profile-popover");
await expect(popover).toBeVisible({ timeout: 5_000 });
await expect(popover).toContainText("Working in #general");
});
});
@@ -0,0 +1,259 @@
import { expect, test, type Page } from "@playwright/test";
import { npubEncode } from "nostr-tools/nip19";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
const CURRENT_PUBKEY = "deadbeef".repeat(8);
const DIFFERENT_PUBKEY = "c0ffee00".repeat(8);
const BACKUP_FILE = {
name: "identity.ncryptsec",
mimeType: "text/plain",
buffer: Buffer.from("ncryptsec1mockbackupmaterial"),
};
async function openIdentity(page: Page) {
const identity = page.getByTestId("profile-identity-card");
if (
!(await identity.evaluate(
(element) => element instanceof HTMLDetailsElement && element.open,
))
) {
await page.getByTestId("profile-identity-toggle").click();
}
}
async function openBackupSettings(
page: Page,
mock?: Parameters<typeof installMockBridge>[1],
) {
await installMockBridge(page, mock);
await page.goto("/");
await openSettings(page, "profile");
await openIdentity(page);
}
async function openPrivateKeyMenu(page: Page) {
const reveal = page.getByTestId("profile-private-key-toggle");
if ((await reveal.textContent())?.trim() === "Reveal") {
await reveal.click();
}
await page.getByTestId("nsec-actions").click();
await expect(page.getByTestId("private-key-create-backup")).toBeVisible();
}
async function openCreateBackup(page: Page) {
await openPrivateKeyMenu(page);
await page.getByTestId("private-key-create-backup").click();
const dialog = page.getByTestId("encrypted-backup-dialog");
await expect(dialog).toBeVisible();
return dialog;
}
async function openTestBackup(page: Page) {
await openPrivateKeyMenu(page);
await page.getByTestId("private-key-test-backup").click();
const dialog = page.getByTestId("backup-test-dialog");
await expect(dialog).toBeVisible();
return dialog;
}
async function selectBackupFile(page: Page) {
await page.getByTestId("backup-test-file-input").setInputFiles(BACKUP_FILE);
await expect(page.getByTestId("backup-test-file-accepted")).toContainText(
BACKUP_FILE.name,
);
}
async function verifyBackup(page: Page, password: string) {
await page.getByTestId("backup-test-password").fill(password);
await page.getByTestId("backup-test-verify").click();
}
async function backupSaveCallCount(page: Page) {
return page.evaluate(
() =>
window.__BUZZ_E2E_COMMANDS__?.filter(
(command) => command === "save_ncryptsec_copy",
).length ?? 0,
);
}
test("private key menu replaces the backup settings rows", async ({ page }) => {
await openBackupSettings(page);
await expect(page.getByTestId("profile-encrypted-backup-row")).toHaveCount(0);
await expect(page.getByTestId("profile-backup-test-row")).toHaveCount(0);
await openPrivateKeyMenu(page);
await expect(page.getByTestId("nsec-copy")).toContainText("Copy");
await expect(page.getByTestId("private-key-create-backup")).toHaveText(
"Create backup",
);
await expect(page.getByTestId("private-key-test-backup")).toHaveText(
"Test backup",
);
await page.getByTestId("nsec-copy").click();
await expect(page.getByText(/clipboard$/i)).toBeVisible();
await expect(page.getByTestId("private-key-create-backup")).toHaveCount(0);
await openPrivateKeyMenu(page);
await page.getByTestId("private-key-test-backup").click();
const testDialog = page.getByTestId("backup-test-dialog");
await expect(testDialog).toContainText("Test a key backup");
await expect(testDialog.getByText("Select your backup file")).toBeVisible();
await expect(testDialog).toContainText("standard NIP-49 format");
});
test("creation requires a sufficiently long password and exposes a temporary header download", async ({
page,
}) => {
await openBackupSettings(page, {
backupSavePaths: [
"/Users/test/Downloads/identity.ncryptsec",
"/Users/test/Desktop/identity-copy.ncryptsec",
],
});
const dialog = await openCreateBackup(page);
const password = dialog.getByTestId("backup-passphrase-input");
const submit = dialog.getByTestId("encrypted-backup-create");
await expect(password).toHaveAttribute(
"placeholder",
"Password (min 12 characters)",
);
await expect(submit).toBeDisabled();
await password.fill("short");
await expect(submit).toBeDisabled();
await password.fill("custom password");
await expect(submit).toBeEnabled();
await submit.click();
await expect.poll(() => backupSaveCallCount(page)).toBe(1);
await expect(dialog).toBeHidden();
const keyRow = page.getByTestId("profile-private-key-row");
const download = keyRow.getByTestId("encrypted-backup-download");
await expect(download).toBeVisible();
await expect(download).toHaveText("Download backup");
await expect(download).toHaveClass(/bg-primary/);
await expect(
download.getByTestId("encrypted-backup-availability-fill"),
).toBeVisible();
await expect(keyRow.getByTestId("profile-private-key-toggle")).toBeVisible();
await download.click();
await expect.poll(() => backupSaveCallCount(page)).toBe(2);
});
test("encryption and native save continue after closing the dialog and settings", async ({
page,
}) => {
await openBackupSettings(page, {
backupEncryptionDelayMs: 750,
backupSavePaths: [null],
});
const dialog = await openCreateBackup(page);
await dialog
.getByTestId("backup-passphrase-input")
.fill("background password");
await dialog.getByTestId("encrypted-backup-create").click();
await expect(dialog.getByTestId("encrypted-backup-progress")).toBeVisible();
await dialog.getByRole("button", { name: "Close" }).click();
await page.getByTestId("settings-back-to-app").click();
await expect(page.getByTestId("settings-back-to-app")).toHaveCount(0);
await expect(
page.getByText("Preparing backup…", { exact: true }),
).toBeVisible();
await expect.poll(() => backupSaveCallCount(page)).toBe(1);
const readyToast = page.getByText("Backup ready to download", {
exact: true,
});
await expect(readyToast).toBeVisible();
await expect(
page.getByText("Your backup will be available to download for 5 minutes.", {
exact: true,
}),
).toBeVisible();
await page.getByRole("button", { name: "Open settings" }).click();
await expect(page.getByTestId("settings-back-to-app")).toBeVisible();
await openIdentity(page);
await expect(page.getByTestId("encrypted-backup-download")).toBeVisible();
});
test("the temporary download expires after five minutes", async ({ page }) => {
await page.clock.install({ time: new Date("2026-07-29T12:00:00Z") });
await openBackupSettings(page);
const dialog = await openCreateBackup(page);
await dialog.getByTestId("backup-passphrase-input").fill("expiring password");
await dialog.getByTestId("encrypted-backup-create").click();
await page.clock.fastForward(1);
await expect.poll(() => backupSaveCallCount(page)).toBe(1);
const download = page.getByTestId("encrypted-backup-download");
await expect(download).toHaveText("Download backup");
await expect(
download.getByTestId("encrypted-backup-availability-fill"),
).toBeVisible();
await page.clock.fastForward(5 * 60 * 1000 + 1);
await expect(page.getByTestId("encrypted-backup-download")).toHaveCount(0);
});
test("wrong backup password permits a successful retry in the test modal", async ({
page,
}) => {
await openBackupSettings(page, {
backupVerificationErrors: ["Wrong password.", null],
});
const dialog = await openTestBackup(page);
await selectBackupFile(page);
await verifyBackup(page, "wrong password");
await expect(dialog.getByTestId("backup-test-error")).toHaveText(
"Wrong password.",
);
await expect(dialog.getByTestId("backup-test-password")).toHaveValue("");
await expect(dialog.getByTestId("backup-test-verify")).toBeDisabled();
await verifyBackup(page, "correct password");
await expect(dialog.getByTestId("backup-test-success")).toContainText(
"It restores your current Buzz identity.",
);
});
for (const identity of [
{
label: "current",
pubkey: CURRENT_PUBKEY,
message: "It restores your current Buzz identity.",
},
{
label: "different",
pubkey: DIFFERENT_PUBKEY,
message: "It restores a different identity than the one signed in here.",
},
]) {
test(`successful modal verification identifies the ${identity.label} identity using only its npub`, async ({
page,
}) => {
await openBackupSettings(page, {
backupVerificationPubkeys: [identity.pubkey],
});
const dialog = await openTestBackup(page);
await selectBackupFile(page);
await verifyBackup(page, "one-time password");
const success = dialog.getByTestId("backup-test-success");
await expect(success).toContainText(identity.message);
await expect(success.getByTestId("backup-test-npub")).toContainText(
npubEncode(identity.pubkey),
);
await expect(success).not.toContainText(identity.pubkey);
await expect(success).not.toContainText("one-time password");
await expect(success).not.toContainText(BACKUP_FILE.buffer.toString());
});
}
@@ -0,0 +1,49 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
const SHORTCODE = "buzz";
const STATUS_TEXT = "testing custom status";
async function openProfilePopover(page: import("@playwright/test").Page) {
await page.getByTestId("open-settings").click();
await expect(page.getByTestId("profile-popover")).toBeVisible();
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGUlEQVR4nGMwuBPxnxLMMGrAqAGjBgwXAwBwOGMf1PPhVwAAAABJRU5ErkJggg==",
"base64",
);
await page.route("https://example.com/e2e/**", (route) =>
route.fulfill({ contentType: "image/png", body: PNG }),
);
});
test("profile popover renders a custom emoji status as an image", async ({
page,
}) => {
await page.goto("/");
await openProfilePopover(page);
await page.getByTestId("profile-popover-set-status").click();
await expect(page.getByTestId("set-status-dialog")).toBeVisible();
await page.getByLabel("Choose status emoji").click();
const picker = page.locator("em-emoji-picker");
await picker.locator("input[type='search']").fill(SHORTCODE);
await picker
.getByRole("button", { name: `:${SHORTCODE}:` })
.first()
.click();
await page.getByTestId("set-status-input").fill(STATUS_TEXT);
await page.getByTestId("set-status-save").click();
await openProfilePopover(page);
const statusButton = page.getByTestId("profile-popover-set-status");
await expect(statusButton).toContainText(STATUS_TEXT);
await expect(statusButton.locator(`img[alt=":${SHORTCODE}:"]`)).toBeVisible();
await expect(statusButton).not.toContainText(`:${SHORTCODE}:`);
});
@@ -0,0 +1,65 @@
/**
* Compact E2E tests for NsecRevealRow in ProfileSettingsCard.
* Covers: reveal fetches + renders masked value, error state, collapse clears state.
*/
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
/** Expand the identity details section if it is not already open. */
async function expandIdentity(page: import("@playwright/test").Page) {
const identity = page.getByTestId("profile-identity-card");
const isOpen = await identity.evaluate(
(el) => el instanceof HTMLDetailsElement && el.open,
);
if (!isOpen) {
await page.getByTestId("profile-identity-toggle").click();
}
await expect(page.getByTestId("profile-identity-details")).toBeVisible();
}
test("reveal shows masked nsec value and hides it again on collapse", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await openSettings(page, "profile");
await expandIdentity(page);
const revealToggle = page.getByTestId("profile-private-key-toggle");
await expect(revealToggle).toBeVisible();
await expect(revealToggle).toHaveText("Reveal");
// Reveal — the masked nsec display should appear.
await revealToggle.click();
await expect(revealToggle).toHaveText("Hide");
const nsecDisplay = page.locator(
'[data-testid="profile-private-key-row"] [data-testid="nsec-value"]',
);
await expect(nsecDisplay).toBeVisible();
// The mock bridge returns "nsec1mock…"; the display starts masked (blurred).
await expect(nsecDisplay).toHaveCSS("filter", /blur/);
// Hide — the nsec display should disappear (state cleared).
await revealToggle.click();
await expect(revealToggle).toHaveText("Reveal");
await expect(nsecDisplay).not.toBeVisible();
});
test("reveal shows error when get_nsec fails", async ({ page }) => {
await installMockBridge(page, { nsecError: "Keychain locked" });
await page.goto("/");
await openSettings(page, "profile");
await expandIdentity(page);
const revealToggle = page.getByTestId("profile-private-key-toggle");
await revealToggle.click();
// Error text should appear inside the private-key row.
const keyRow = page.getByTestId("profile-private-key-row");
await expect(keyRow.locator(".text-destructive")).toBeVisible();
await expect(keyRow.locator(".text-destructive")).toContainText(
"Keychain locked",
);
});

Some files were not shown because too many files have changed in this diff Show More