Skip to content
Merged
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 @@ -174,6 +174,7 @@ export default defineConfig({
"**/stream.spec.ts",
"**/integration.spec.ts",
"**/dm-double-notification.spec.ts",
"**/evidence-reactions-relay.spec.ts",
"**/profile.spec.ts",
"**/sidebar.spec.ts",
"**/sidebar-relay-card.spec.ts",
Expand Down
101 changes: 74 additions & 27 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9528,18 +9528,30 @@ async function handleSendManagedAgentChannelMessage(
}

/**
* Mock the `delete_message` Tauri command. Removes the event from the
* in-memory mock store and records the kind:5 structural event used by Inbox
* refreshes. Do not emit it live: mock target IDs may fail the production
* 64-hex deletion filter, letting a live merge restore the flattened row.
* Handle the `delete_message` Tauri command. Relay mode publishes the
* kind:5 structural event; mock mode removes the event from the in-memory
* store and records the same event for Inbox refreshes.
*/
function handleDeleteMessage(
async function handleDeleteMessage(
args: {
channelId: string;
eventId: string;
},
config: E2eConfig | undefined,
): void {
): Promise<void> {
const identity = getIdentity(config);
if (identity) {
await submitSignedEvent(config, {
kind: KIND_DELETION,
content: "",
tags: [
["h", args.channelId],
["e", args.eventId],
],
});
return;
}

const history = mockMessages.get(args.channelId);
if (history) {
const index = history.findIndex((ev) => ev.id === args.eventId);
Expand Down Expand Up @@ -9629,32 +9641,37 @@ function findMockEventChannel(eventId: string): string | undefined {
}

/**
* Mock the `add_reaction` Tauri command. Mirrors the real Rust command: a
* kind:7 whose content is the emoji, plus — for a custom emoji — the NIP-30
* `["emoji", shortcode, url]` tag (shortcode normalized to match the relay).
* Recorded into the target's channel store, projected into home-feed activity,
* and emitted live so timeline reactions and durable inbox projections observe
* the same event. Unicode reactions carry no emoji tag, like the real command.
* Handle the `add_reaction` Tauri command. Relay mode publishes the kind:7
* event; mock mode records it into the target's channel store, projects it
* into home-feed activity, and emits it live. Unicode reactions carry no
* emoji tag; custom emoji uses the NIP-30 tag.
*/
async function handleAddReaction(
args: { eventId: string; emoji: string; emojiUrl?: string | null },
config: E2eConfig | undefined,
): Promise<void> {
const channelId = findMockEventChannel(args.eventId);
if (!channelId) {
throw new Error(`mock add_reaction: unknown target event ${args.eventId}`);
}

const identity = getIdentity(config);
const emoji = args.emoji.trim();
// Real add_reaction events carry only the target `e` tag. Channel live
// subscriptions already know which channel matched and restore that context
// before merging the event into the timeline cache.
const tags: string[][] = [["e", args.eventId]];
if (args.emojiUrl) {
const shortcode = emoji.replace(/^:+/, "").replace(/:+$/, "").toLowerCase();
tags.push(["emoji", shortcode, args.emojiUrl]);
}

if (identity) {
await submitSignedEvent(config, {
kind: KIND_REACTION,
content: emoji,
tags,
});
return;
}

const channelId = findMockEventChannel(args.eventId);
if (!channelId) {
throw new Error(`mock add_reaction: unknown target event ${args.eventId}`);
}

const event = createMockEvent(
KIND_REACTION,
emoji,
Expand Down Expand Up @@ -9682,22 +9699,42 @@ async function handleAddReaction(
}

/**
* Mock the `remove_reaction` Tauri command. Finds the active member's own
* kind:7 for this target+emoji, removes it from the store, and emits a kind:5
* deletion so the timeline drops the reaction (the real command deletes via a
* kind:5 too).
* Handle the `remove_reaction` Tauri command. Relay mode finds the caller's
* own matching kind:7 and publishes its kind:5 deletion; mock mode removes
* the reaction from the store and emits the same deletion.
*/
async function handleRemoveReaction(
args: { eventId: string; emoji: string },
config: E2eConfig | undefined,
): Promise<void> {
const identity = getIdentity(config);
const emoji = args.emoji.trim();
if (identity) {
const reactions = await relayQuery(config, [
{
kinds: [KIND_REACTION],
authors: [identity.pubkey],
"#e": [args.eventId],
},
]);
const reaction = reactions.find((event) => event.content.trim() === emoji);
if (!reaction) {
throw new Error("could not find your reaction event for this emoji");
}
await submitSignedEvent(config, {
kind: KIND_DELETION,
content: "",
tags: [["e", reaction.id]],
});
return;
}

const channelId = findMockEventChannel(args.eventId);
if (!channelId) {
return;
}

const myPubkey = getMockMemberPubkey(config).toLowerCase();
const emoji = args.emoji.trim();
const store = getMockMessageStore(channelId);
const reaction = store.find(
(event) =>
Expand Down Expand Up @@ -13392,7 +13429,7 @@ export function maybeInstallE2eTauriMocks() {
activeConfig,
);
case "delete_message":
handleDeleteMessage(
await handleDeleteMessage(
payload as Parameters<typeof handleDeleteMessage>[0],
activeConfig,
);
Expand Down Expand Up @@ -13704,8 +13741,18 @@ export function maybeInstallE2eTauriMocks() {
// The spec only verifies UI state, not the submitted request shape;
// returning null mirrors the Rust submit_event success path.
return null;
case "set_canvas":
case "set_canvas": {
const canvasArgs = payload as { channelId: string; content: string };
if (getIdentity(activeConfig)) {
const result = await submitSignedEvent(activeConfig, {
kind: 40100,
content: canvasArgs.content,
tags: [["h", canvasArgs.channelId]],
});
return { ok: result.accepted, event_id: result.event_id };
}
return { ok: true, event_id: mockEventId() };
}
case "get_canvas": {
const canvasReadError = activeConfig?.mock?.canvasReadError;
if (canvasReadError) {
Expand Down
125 changes: 125 additions & 0 deletions desktop/tests/e2e/evidence-reactions-relay.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { expect, test } from "@playwright/test";
import { hexToBytes } from "@noble/hashes/utils.js";
import { finalizeEvent } from "nostr-tools/pure";

import { installBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { assertRelaySeeded } from "../helpers/seed";

const RELAY_HTTP_URL =
process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000";
const GENERAL_CHANNEL_ID = "9f28288a-d724-587a-9709-92dc7f967110";
const EVIDENCE_TAG = [["crew-evidence", "test-run"]];

async function publishEvidence() {
const event = finalizeEvent(
{
kind: 9,
content: `Evidence requiring review ${Date.now()}`,
tags: [["h", GENERAL_CHANNEL_ID], ...EVIDENCE_TAG],
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()}`,
);
}
return event;
}

async function queryReactions(eventId: string) {
const response = await fetch(`${RELAY_HTTP_URL}/query`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Pubkey": TEST_IDENTITIES.tyler.pubkey,
},
body: JSON.stringify([
{
kinds: [7],
"#e": [eventId],
},
]),
});
if (!response.ok) {
throw new Error(
`POST /query failed (${response.status}): ${await response.text()}`,
);
}
return (await response.json()) as Array<{
content: string;
kind: number;
pubkey: string;
tags: string[][];
}>;
}

async function expectReaction(eventId: string, content: string) {
await expect
.poll(
async () =>
(await queryReactions(eventId)).find(
(event) =>
event.pubkey === TEST_IDENTITIES.tyler.pubkey &&
event.content === content &&
event.kind === 7 &&
event.tags.some(
(tag) => tag.length === 2 && tag[0] === "e" && tag[1] === eventId,
),
),
{ timeout: 15_000 },
)
.toBeTruthy();
}

test.beforeAll(async () => {
await assertRelaySeeded();
});

test("owner Accept and Reject publish real relay reactions", async ({
page,
}) => {
await installBridge(page, {
mode: "relay",
user: "tyler",
relayHttpUrl: RELAY_HTTP_URL,
relayWsUrl: RELAY_HTTP_URL.replace(/^http/, "ws"),
mock: {
searchProfiles: [
{
pubkey: TEST_IDENTITIES.alice.pubkey,
displayName: "Evidence Agent",
ownerPubkey: TEST_IDENTITIES.tyler.pubkey,
isAgent: true,
},
],
},
});
const evidence = await publishEvidence();

await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const card = page
.getByTestId("evidence-card-test-run")
.filter({ hasText: evidence.content });
await expect(card).toBeVisible();

await card.getByTestId("evidence-accept").click();
await expectReaction(evidence.id, "✅");
await expect(card.getByTestId("evidence-reaction-accepted")).toBeVisible();

await card.getByTestId("evidence-reject").click();
await expectReaction(evidence.id, "❌");
await expect(page.getByTestId("message-composer").last()).toBeVisible();
await expect(card.getByTestId("evidence-reaction-rejected")).toBeVisible();
});
20 changes: 20 additions & 0 deletions docs/crew/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -806,3 +806,23 @@ client never learns such an event exists, so a reply can arrive for a root that
never renders. Channel live subscriptions therefore start a bounded grace window
before now (`CHANNEL_LIVE_BACKLOG_GRACE_SECONDS`), matching the existing huddle
TTS startup replay; the window store dedups by event id, so the overlap is free.

## D-042 — Relay-mode E2E mutations must reach the relay

- **Status:** Accepted
- **Date:** 2026-08-11
- **Evidence:** [`verification/0011-e2e-bridge-relay-mutation-audit.md`](verification/0011-e2e-bridge-relay-mutation-audit.md)

Mutating E2E-bridge commands that have a real Nostr event behind them must
publish through `submitSignedEvent` in relay mode, branching on
`getIdentity(config)`, and must skip mock-store bookkeeping. The relay event
must mirror the Rust command's kind, content, and tags exactly.

A mock-only mutation in relay mode is a coverage trap: the spec can still pass
while never reaching the relay. Any command that cannot yet be made
relay-aware must be listed in Verification 0011 with the reason; issue #144
tracks the remaining commands and the Rust-side confirmation of local-only
classifications.

This decision changes no reaction semantics, event kinds, or evidence tag
schema. D-036 remains authoritative for evidence tags and reaction behavior.
8 changes: 4 additions & 4 deletions docs/crew/STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ In scope:
--evidence <kind>` tag surface;
- desktop evidence cards with owner Accept/Reject reactions on ordinary kind-9
messages;
- relay-backed reaction, deletion, and canvas publication in the desktop E2E
bridge, with a relay-mode evidence-card Accept/Reject spec;
- ordered Project-thread handoff state from mentions, active-turn telemetry,
and signed agent replies;
- ordered multi-agent Project task routing through normal composer mentions;
Expand Down Expand Up @@ -147,10 +149,8 @@ Out of scope for this slice:
state.
- Phase 09 live probe results are recorded in
[`verification/0010-evidence-on-thread-log-probes.md`](verification/0010-evidence-on-thread-log-probes.md).
Probe 1 is explicitly split between the mock desktop command-payload check
and the separate real-relay reaction read-back; the single-process
click-to-relay chain remains unverified; issue #133 tracks the relay-backed
`add_reaction` bridge follow-up.
- Verification 0011 records the closed headless click-to-real-relay reaction
path and the remaining relay-mutation audit.
- The desktop unit suite passes with 5045 tests passing, one skipped, and zero
failures.
- `buzz-acp` uses the process cwd for ordinary sessions and one validated,
Expand Down
13 changes: 5 additions & 8 deletions docs/crew/verification/0010-evidence-on-thread-log-probes.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,14 +176,11 @@ After capture`.
## Limits

- One machine, Linux x86_64, single relay, single-worker Playwright.
- Probe 1 was executed as two halves because the headless harness cannot make a
desktop click publish to the relay (mock `add_reaction`). The desktop half
proves the kind-7 emission target and the card's accepted/rejected states; the
relay half proves the kind-7 actually lands and is agent-readable. A
single-process "click in the app → event on the relay" chain remains
unverified and would need either a relay-backed `add_reaction` in the bridge
or a real Tauri run (which needs computer-use); issue #133 tracks adding the
relay-backed bridge path to close this gap.
- The split-probe limitation is now closed for the reaction path. The relay-
backed `add_reaction` bridge and
`desktop/tests/e2e/evidence-reactions-relay.spec.ts` exercise the single
headless click → real relay kind-7 chain. See Verification 0011 for the
commands and remaining agent-profile limitation.
- `just ci` was not run in this session; only the evidence specs and the CLI
probes above were executed.
- The relay used is the shared local dev relay on `:3000`, isolated per probe by
Expand Down
Loading
Loading