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
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:
@@ -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
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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` });
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 A–Z", 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 A–Z)", 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: "A–Z" }),
|
||||
).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" } });
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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` });
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 `` 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();
|
||||
});
|
||||
});
|
||||
@@ -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: "What’s 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: "What’s an identity key?" }),
|
||||
).toHaveCSS("color", "rgb(23, 23, 23)");
|
||||
});
|
||||
@@ -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",
|
||||
``,
|
||||
`||||`,
|
||||
].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`,
|
||||
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",
|
||||
``,
|
||||
``,
|
||||
].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",
|
||||
``,
|
||||
``,
|
||||
].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) => ``),
|
||||
].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",
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
].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");
|
||||
});
|
||||
@@ -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
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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",
|
||||
"Couldn’t 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
@@ -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
@@ -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");
|
||||
});
|
||||
@@ -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 before→after 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`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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` });
|
||||
});
|
||||
@@ -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` });
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user