Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default defineConfig({
"**/identity-archive.spec.ts",
"**/identity-archive-hide.spec.ts",
"**/relay-connectivity-screenshots.spec.ts",
"**/observer-seed-screenshots.spec.ts",
],
use: {
...devices["Desktop Chrome"],
Expand Down
25 changes: 25 additions & 0 deletions desktop/src/features/agents/observerRelayStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,31 @@ export function useManagedAgentObserverBridge(
}, [hasActiveAgent]);
}

// E2E-only seam. Injects already-decrypted observer events for an agent
// straight into the store, bypassing the relay subscription and the decrypt
// command. The events flow through the exact same `appendAgentEvent` →
// `processTranscriptEvent` pipeline as production frames, so the rendered
// transcript is faithful to live decrypted output (avatars, grouped prompts,
// tool/shell summaries, view_image thumbnails) without a running agent. The
// agent is registered as known + the connection is flipped to "open" so the
// panel renders the populated state rather than an empty/error placeholder.
//
// Guarded behind an explicit caller (the `__BUZZ_E2E_SEED_OBSERVER_FRAMES__`
// bridge hook) — never reachable from production code paths.
export function seedAgentObserverEvents(
agentPubkey: string,
events: ObserverEvent[],
) {
const key = normalizePubkey(agentPubkey);
knownAgentPubkeys.add(key);
if (connectionState !== "open") {
setConnectionState("open", null);
}
for (const event of events) {
appendAgentEvent(agentPubkey, event);
}
}

export function resetAgentObserverStore() {
generation += 1;
const unsubscribe = unsubscribeRelay;
Expand Down
12 changes: 12 additions & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { relayClient } from "@/shared/api/relayClient";
import type { ConnectionState } from "@/shared/api/relayClientShared";
import type { RelayEvent } from "@/shared/api/types";
import { syncAgentTurnsFromEvents } from "@/features/agents/activeAgentTurnsStore";
import { seedAgentObserverEvents } from "@/features/agents/observerRelayStore";
import type { ObserverEvent } from "@/features/agents/ui/agentSessionTypes";
import {
CUSTOM_EMOJI_SET_D_TAG,
KIND_EMOJI_SET,
Expand Down Expand Up @@ -611,6 +613,10 @@ declare global {
channelId: string;
turnId: string;
}) => void;
__BUZZ_E2E_SEED_OBSERVER_FRAMES__?: (input: {
agentPubkey: string;
events: ObserverEvent[];
}) => void;
__BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: {
clientId: string;
contexts: Record<string, number>;
Expand Down Expand Up @@ -5947,6 +5953,12 @@ export function maybeInstallE2eTauriMocks() {
},
]);
};
// Seeds populated observer transcript frames for an agent so the Activity
// panel renders live-style states (prompts, assistant messages, tool/shell
// summaries, view_image thumbnails) without a running agent or a relay.
window.__BUZZ_E2E_SEED_OBSERVER_FRAMES__ = ({ agentPubkey, events }) => {
seedAgentObserverEvents(agentPubkey, events);
};
const meshNodeStatus = (
state: "off" | "running",
mode: "serve" | "client" | null,
Expand Down
169 changes: 169 additions & 0 deletions desktop/tests/e2e/observer-seed-screenshots.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { expect, test } from "@playwright/test";

import { KIND_TYPING_INDICATOR } from "../../src/shared/constants/kinds";
import { installMockBridge } from "../helpers/bridge";
import {
OBSERVER_SEED_AGENT_PUBKEY,
observerSeedFrames,
} from "../helpers/observerSeedFixture";

// Channel the seeded agent is a member of (via the managedAgents seed below).
// The agent must be a member of the navigated channel so it classifies as a
// channel-session agent — that's what renders the composer activity trigger.
const SEED_CHANNEL_NAME = "general";

// Poll until the mock relay has a live typing-indicator subscription for the
// channel. Without this, the typing event is emitted before the channel
// subscribes and is silently dropped, so the composer trigger never paints.
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
kind?: number,
) {
await expect
.poll(async () =>
page.evaluate(
({ currentChannelName, kind }) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
kind?: number;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: currentChannelName,
kind,
}) ?? false,
{ currentChannelName: channelName, kind },
),
)
.toBe(true);
}

const SHOTS = "test-results/observer-seed";

// Themes the populated panel is captured against. Values map to the real
// THEME_STORAGE_KEY entries read by ThemeProvider (light = catppuccin-latte,
// dark = houston).
const THEMES = [
{ label: "light", value: "catppuccin-latte" },
{ label: "dark", value: "houston" },
] as const;

function asWindow(page: import("@playwright/test").Page) {
return page;
}

async function waitForBridge(page: import("@playwright/test").Page) {
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_SEED_OBSERVER_FRAMES__?: unknown;
}
).__BUZZ_E2E_SEED_OBSERVER_FRAMES__ === "function",
null,
{ timeout: 10_000 },
);
}

// Drives the app from the channel list into the agent-session thread panel for
// the seeded agent, then injects the populated observer transcript. The agent
// is surfaced in the composer activity bar via a mock typing indicator, which
// is what renders the `bot-activity-composer-*` controls.
async function openSeededAgentSession(
page: import("@playwright/test").Page,
themeValue: string,
) {
// Set the theme before the app boots so the first paint is already themed.
await page.addInitScript((value) => {
window.localStorage.setItem("buzz-theme", value);
}, themeValue);

await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForBridge(page);

// Open #general and surface the agent as "typing" so the composer activity
// bar exposes the trigger + per-agent item. Wait for the typing-indicator
// subscription to go live first so the emitted event isn't dropped.
await page.getByTestId(`channel-${SEED_CHANNEL_NAME}`).click();
await waitForMockLiveSubscription(
page,
SEED_CHANNEL_NAME,
KIND_TYPING_INDICATOR,
);
await page.evaluate((pubkey) => {
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_TYPING__?: (input: {
channelName: string;
pubkey: string;
}) => void;
}
).__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
channelName: "general",
pubkey,
});
}, OBSERVER_SEED_AGENT_PUBKEY);

await expect(page.getByTestId("bot-activity-composer-trigger")).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("bot-activity-composer-trigger").click();
await page
.getByTestId(`bot-activity-composer-item-${OBSERVER_SEED_AGENT_PUBKEY}`)
.click({ force: true });

const panel = page.getByTestId("agent-session-thread-panel");
await expect(panel).toBeVisible({ timeout: 10_000 });

// Inject the already-decrypted observer transcript through the production
// appendAgentEvent -> processTranscriptEvent pipeline.
await page.evaluate(
({ agentPubkey, events }) => {
(
window as Window & {
__BUZZ_E2E_SEED_OBSERVER_FRAMES__?: (input: {
agentPubkey: string;
events: unknown[];
}) => void;
}
).__BUZZ_E2E_SEED_OBSERVER_FRAMES__?.({ agentPubkey, events });
},
{ agentPubkey: OBSERVER_SEED_AGENT_PUBKEY, events: observerSeedFrames },
);

return panel;
}

test.describe("observer-seed populated panel screenshots", () => {
test.use({ viewport: { width: 1280, height: 800 } });

for (const theme of THEMES) {
test(`populated transcript — ${theme.label}`, async ({ page }) => {
await installMockBridge(asWindow(page), {
managedAgents: [
{
pubkey: OBSERVER_SEED_AGENT_PUBKEY,
name: "Fizz",
status: "running",
channelNames: ["general"],
},
],
});

const panel = await openSeededAgentSession(page, theme.value);

// The seeded transcript renders a user prompt, an assistant message, and
// tool/shell summaries — assert one stable marker before capturing so the
// shot isn't taken mid-render. The compact tool row renders the friendly
// label ("Read file"), not the raw tool name.
await expect(panel).toContainText("Read file", { timeout: 10_000 });

await panel.screenshot({
path: `${SHOTS}/populated-${theme.label}.png`,
});
});
}
});
Loading