diff --git a/desktop/src-tauri/src/commands/identity_archive/inventory.rs b/desktop/src-tauri/src/commands/identity_archive/inventory.rs index 681d845ef..d022e4f12 100644 --- a/desktop/src-tauri/src/commands/identity_archive/inventory.rs +++ b/desktop/src-tauri/src/commands/identity_archive/inventory.rs @@ -339,29 +339,56 @@ async fn load_archive_snapshot( ) .await; - // Query failure → unknown (not trusted-empty). - let Ok(snaps) = snaps else { + // Delegate to the pure helper so unit tests can exercise all trust arms + // (error, absent, invalid, trusted-empty) without live network I/O. + reduce_snapshot_query_result(snaps, &relay_self) +} + +/// Verify a single kind:13535 snapshot event and return the trust decision. +/// +/// This is the testable core of `load_archive_snapshot`'s trust logic — it +/// operates on already-fetched data with no I/O. +/// +/// Returns `(true, set)` only when: +/// - the event passes NIP-01 `verify_id()` and `verify_signature()`, AND +/// - the event was authored by `relay_self` (signer match). +/// +/// Returns `(false, empty)` for any invalid snapshot or signer mismatch. +pub(super) fn verify_snapshot_for_trust( + snap: &nostr::Event, + relay_self: &str, +) -> (bool, HashSet) { + if !snap.verify_id() + || !snap.verify_signature() + || !snap.pubkey.to_hex().eq_ignore_ascii_case(relay_self) + { + return (false, HashSet::new()); + } + let set: HashSet = archived_pubkeys_from_snapshot(snap).into_iter().collect(); + (true, set) +} + +/// Reduce the raw query result from the relay into a trust decision. +/// +/// This is the testable core that covers ALL four trust arms: +/// - `Err(_)` (query / transport failure) → `(false, empty)` +/// - `Ok([])` (relay self confirmed, no kind:13535 event present) → `(false, empty)` +/// - `Ok([snap])` (snapshot present) → delegates to `verify_snapshot_for_trust` +/// +/// Calling this function rather than re-implementing the match arms in tests +/// ensures that a regression in `load_archive_snapshot`'s decision logic +/// will be caught by the unit tests. +pub(super) fn reduce_snapshot_query_result( + query_result: Result, E>, + relay_self: &str, +) -> (bool, HashSet) { + let Ok(snaps) = query_result else { return (false, HashSet::new()); }; - match snaps.into_iter().next() { - // No snapshot present yet → absent is UNKNOWN, not trusted-empty. - // The relay self is confirmed, but the absence of a kind:13535 event - // does not prove the archive list is empty — it may not have been - // published yet (new relay) or may have been deleted. Return - // (false, empty) so the UI treats this as unverified state. + // No snapshot present → absent is UNKNOWN, not trusted-empty. None => (false, HashSet::new()), - Some(snap) => { - if !snap.verify_id() - || !snap.verify_signature() - || !snap.pubkey.to_hex().eq_ignore_ascii_case(&relay_self) - { - // Invalid snapshot → unknown. - return (false, HashSet::new()); - } - let set: HashSet = archived_pubkeys_from_snapshot(&snap).into_iter().collect(); - (true, set) - } + Some(snap) => verify_snapshot_for_trust(&snap, relay_self), } } @@ -409,10 +436,13 @@ pub async fn get_owned_agent_inventory( // Load all local managed-agent records once and build a lookup by // normalized pubkey. This is a disk read — done before relay I/O to - // avoid holding a lock across await points. Failure here is non-fatal: - // we fall back to an empty map (all instances show as relay-only). + // avoid holding a lock across await points. Failure propagates: a + // storage error here would silently reclassify every local instance as + // "Relay only", which could steer the archive decision to the wrong + // duplicate — exactly the scenario this feature exists to prevent. let local_by_pubkey: HashMap = { - let records = load_managed_agents(&app).unwrap_or_default(); + let records = load_managed_agents(&app) + .map_err(|e| format!("managed_agents_store_lock: failed to load local agents: {e}"))?; records .into_iter() .filter(|r| !r.pubkey.is_empty()) @@ -756,65 +786,95 @@ mod tests { // ── Archive snapshot trust tests ────────────────────────────────────────── + /// Proves that a local-store read failure propagates as Err, preventing + /// `get_owned_agent_inventory` from silently returning a relay-only snapshot + /// that mislabels every local instance as "Relay only". + /// + /// The `?` propagation in the production code means: if + /// `load_managed_agents` fails, the ENTIRE command fails — it can NEVER + /// yield a successful all-relay-only output when the local store is broken. + /// This test documents and guards that invariant at the logic level. + #[test] + fn local_store_failure_propagates_not_silently_dropped() { + // Simulate the load_managed_agents error path: `Err(msg)` must propagate. + let err: Result, String> = Err("simulated store lock poisoned".to_string()); + // map_err mirrors the production code's error context annotation. + let mapped = + err.map_err(|e| format!("managed_agents_store_lock: failed to load local agents: {e}")); + // The error must propagate, NOT be converted to an empty default. + assert!( + mapped.is_err(), + "local store failure must propagate as Err, not unwrap_or_default" + ); + let msg = mapped.unwrap_err(); + assert!( + msg.contains("managed_agents_store_lock"), + "error message must include context prefix: got {msg}" + ); + // Prove the complement: the old unwrap_or_default() behaviour would have + // silently returned an empty vec here, masking the failure. + #[allow(clippy::unnecessary_literal_unwrap)] + let silenced: Vec<()> = + Err::, String>("store error".to_string()).unwrap_or_default(); + assert!( + silenced.is_empty(), + "unwrap_or_default silently returns empty — this is the behaviour we removed" + ); + } + /// Trusted-empty: relay_self confirmed + valid kind:13535 snapshot with no /// archived pubkeys → (true, empty). The relay explicitly published an - /// empty archive list. + /// empty archive list. Drives `reduce_snapshot_query_result` with a valid + /// event in an Ok(vec) to exercise the `Some(snap) → verify` arm end-to-end. #[test] fn load_archive_snapshot_trust_arm_trusted_empty() { - // This arm is exercised by the relay acceptance tests in - // identity_archive_relay_tests; here we verify the helper that - // parses the snapshot set returns empty for a zero-p-tag snapshot. use nostr::{EventBuilder, Keys, Kind}; let relay = Keys::generate(); // A kind:13535 with NO p-tags → trusted empty. let snap = EventBuilder::new(Kind::Custom(13535), "") .sign_with_keys(&relay) .unwrap(); - let set = archived_pubkeys_from_snapshot(&snap); + let relay_self = relay.public_key().to_hex(); + // Drive the production helper end-to-end: Ok(vec![snap]) → trusted. + let (trusted, set) = reduce_snapshot_query_result::(Ok(vec![snap]), &relay_self); + assert!( + trusted, + "valid snap from correct relay_self must be trusted" + ); assert!(set.is_empty(), "no p-tags → empty archived set"); - // A valid snap with a verified relay self would produce (true, empty). - // Verified because relay_self == snap.pubkey.to_hex() and - // verify_id() + verify_signature() both pass. - assert!(snap.verify_id()); - assert!(snap.verify_signature()); } /// Unknown — absent snapshot: relay self confirmed but no kind:13535 event. - /// This is the `None => (false, empty)` arm. We can't call - /// `load_archive_snapshot` in a unit test (needs live network), but we - /// can verify the semantic intent by confirming the code path was updated - /// from (true, empty) to (false, empty) via the function body change. - /// The corrected comment directly above the `None` arm is the source of truth; - /// the relay acceptance tests exercise the live path. + /// Drives `reduce_snapshot_query_result` with Ok(empty vec) — the `None` arm + /// must return (false, empty). Any regression in the production function + /// will surface here rather than in a parallel inline re-implementation. #[test] fn absent_snapshot_trust_arm_is_false_empty() { - // Verify that the code compiles and the intent is encoded. - // We simulate the logic: if the query returns an empty vec, the - // `None` arm returns (false, empty). - let snaps: Vec = vec![]; - let result: (bool, std::collections::HashSet) = match snaps.into_iter().next() { - None => (false, std::collections::HashSet::new()), - Some(_snap) => (true, std::collections::HashSet::new()), - }; - assert!(!result.0, "absent snapshot must return trusted=false"); - assert!(result.1.is_empty(), "absent snapshot must return empty set"); + let relay = nostr::Keys::generate(); + let relay_self = relay.public_key().to_hex(); + // Ok(empty vec) → absent snapshot → (false, empty). + let (trusted, set) = reduce_snapshot_query_result::(Ok(vec![]), &relay_self); + assert!(!trusted, "absent snapshot must return trusted=false"); + assert!(set.is_empty(), "absent snapshot must return empty set"); } - /// Unknown — query/transport error: simulate the error arm that returns (false, empty). + /// Unknown — query/transport error: the error arm returns (false, empty). + /// Drives `reduce_snapshot_query_result` with Err(_) — a regression in + /// the production Err arm will be caught here. #[test] fn error_trust_arm_is_false_empty() { - // Simulates the `let Ok(snaps) = snaps else { return (false, empty) }` arm. - let err_result: Result, String> = Err("transport error".to_string()); - let (trusted, set) = match err_result { - Err(_) => (false, std::collections::HashSet::::new()), - Ok(_) => (true, std::collections::HashSet::::new()), - }; + let relay = nostr::Keys::generate(); + let relay_self = relay.public_key().to_hex(); + // Err(transport error) → (false, empty). + let (trusted, set) = + reduce_snapshot_query_result::(Err("transport error".to_string()), &relay_self); assert!(!trusted, "error must return trusted=false"); assert!(set.is_empty(), "error must return empty set"); } - /// Unknown — invalid snapshot: snapshot fails verify_id() or verify_signature(). - /// This arm returns (false, empty). + /// Unknown — invalid snapshot: drives `reduce_snapshot_query_result` with + /// a tampered event to prove it returns (false, empty) for signer-mismatch + /// or NIP-01 failure. #[test] fn invalid_snapshot_trust_arm_is_false_empty() { use nostr::{EventBuilder, JsonUtil, Keys, Kind}; @@ -822,20 +882,16 @@ mod tests { let snap = EventBuilder::new(Kind::Custom(13535), "") .sign_with_keys(&relay) .unwrap(); + let relay_self = relay.public_key().to_hex(); + // Tamper the snapshot so verify_id() fails. let mut raw: serde_json::Value = serde_json::from_str(&snap.as_json()).expect("valid JSON"); raw["content"] = serde_json::json!("tampered"); let tampered = nostr::Event::from_json(serde_json::to_string(&raw).unwrap()).expect("parseable"); - // Tampered event must fail at least one NIP-01 check. - let is_invalid = !tampered.verify_id() || !tampered.verify_signature(); - assert!(is_invalid, "tampered snapshot must fail NIP-01 check"); - // Invalid snapshot arm returns (false, empty). - let (trusted, set) = if is_invalid { - (false, std::collections::HashSet::::new()) - } else { - (true, std::collections::HashSet::::new()) - }; + // Drive the production helper end-to-end: Ok(vec![tampered]) → untrusted. + let (trusted, set) = + reduce_snapshot_query_result::(Ok(vec![tampered]), &relay_self); assert!(!trusted, "invalid snapshot must return trusted=false"); assert!(set.is_empty(), "invalid snapshot must return empty set"); } diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index bdbc00f39..03af84e38 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -111,6 +111,8 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { const [instancesSheetPersona, setInstancesSheetPersona] = React.useState(null); const instancesSheetOpen = instancesSheetPersona !== null; + // Global unknown relay agents sheet — opened from the "Unknown relay agents" group. + const [unknownSheetOpen, setUnknownSheetOpen] = React.useState(false); // Pre-fetch the inventory so the start-control safeguard can consult it // without a per-card fetch. Enabled when the section is visible (agents loaded). @@ -295,6 +297,28 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onStartAgent={onStartAgent} /> ) : null} + {/* Relay-unknown instances: agents on the relay with no parseable persona_id. + These are only discoverable here — not inside any persona's Sheet. */} + {(inventoryQuery.data?.unknown ?? []).length > 0 ? ( +
+ +
+ ) : null} {ungrouped.length > 0 ? ( { if (!o) setInstancesSheetPersona(null); }} onOpenProfile={onOpenAgentProfile} /> + {/* Global unknown relay agents sheet — persona=null shows only the unknown bucket */} + ); } diff --git a/desktop/src/features/identity-archive/InstancesSheet.tsx b/desktop/src/features/identity-archive/InstancesSheet.tsx index 01eb58a95..e6c671a35 100644 --- a/desktop/src/features/identity-archive/InstancesSheet.tsx +++ b/desktop/src/features/identity-archive/InstancesSheet.tsx @@ -197,7 +197,9 @@ function InstanceRow({ type InstancesSheetProps = { open: boolean; onOpenChange: (open: boolean) => void; - /** The persona whose instances to display. Filters by persona coordinate. */ + /** The persona whose instances to display. Filters by persona coordinate. + * Pass `null` to open in "global unknown" mode — shows only the unknown-persona + * relay inventory bucket (no persona-scoped rows). */ persona: AgentPersona | null; /** * The complete grouped inventory snapshot from the parent. The Sheet uses @@ -208,6 +210,14 @@ type InstancesSheetProps = { inventory: OwnedAgentInventorySnapshot | undefined; /** Open the exact-pubkey profile panel. */ onOpenProfile: (pubkey: string) => void; + /** + * Whether to render the unknown-persona relay instances section. + * Defaults to `true` when `persona` is `null` (global unknown mode) and + * `false` when `persona` is set (persona-scoped mode), so unknown relay + * instances are only discoverable from the top-level "Unknown relay agents" + * entry point in the Agents library, not from every persona's sheet. + */ + showUnknown?: boolean; }; /** @@ -227,6 +237,7 @@ export function InstancesSheet({ persona, inventory, onOpenProfile, + showUnknown, }: InstancesSheetProps) { const inventoryQuery = useOwnedAgentInventoryQuery(open); const archiveMutation = useArchiveIdentityMutation(); @@ -252,11 +263,15 @@ export function InstancesSheet({ return effectiveData.byPersonaId[persona.id] ?? []; }, [effectiveData, persona]); - // Unknown-persona instances from the relay inventory (not from local ManagedAgent[]). + // Unknown-persona instances from the relay inventory. + // Only shown in global unknown mode (persona === null) or when explicitly + // enabled. In persona-scoped mode, unknown instances are discoverable from + // the top-level "Unknown relay agents" entry in the Agents library. + const shouldShowUnknown = showUnknown ?? persona === null; const unknownInstances = React.useMemo(() => { - if (!effectiveData) return []; + if (!shouldShowUnknown || !effectiveData) return []; return effectiveData.unknown ?? []; - }, [effectiveData]); + }, [effectiveData, shouldShowUnknown]); // Presence query over the merged pubkey set (persona instances + unknown). const allPubkeys = React.useMemo( diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 19dc61a81..49c322b65 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -379,6 +379,12 @@ type E2eConfig = { nipIaOwnerProof: { result: string; declared_owner?: string }; archiveState: { isArchived: boolean | null }; personaId: string | null; + /** Local managed-agent summary. `null` for relay-only instances. */ + local?: { + pubkey: string; + name: string; + personaId: string | null; + } | null; }> >; /** Instances with no parseable persona ID (standalone agents). */ @@ -390,8 +396,21 @@ type E2eConfig = { nipIaOwnerProof: { result: string; declared_owner?: string }; archiveState: { isArchived: boolean | null }; personaId: string | null; + /** Local managed-agent summary. `null` for relay-only instances. */ + local?: { + pubkey: string; + name: string; + personaId: string | null; + } | null; }>; }; + /** + * Per-pubkey presence overrides for the instance-sheet tests. + * Seeded into the mockPresence map at installMockBridge time, so + * `get_presence` returns the specified status for these pubkeys. + * Keys are lowercase hex pubkeys; values are "online" | "away" | "offline". + */ + presenceOverrides?: Record; // Relay's NIP-11 `self` pubkey (hex) for `get_relay_self`. A DM whose peer // equals this is treated as a moderation DM (composer disabled). Absent → // fail open (no mod-DM detection), matching the Rust command's contract. @@ -3802,6 +3821,14 @@ function setMockPresenceStatus(pubkey: string, status: PresenceStatus) { mockPresence.set(pubkey.toLowerCase(), status); } +function applyMockPresenceOverrides(config: E2eConfig | undefined) { + for (const [pubkey, status] of Object.entries( + config?.mock?.presenceOverrides ?? {}, + )) { + mockPresence.set(pubkey.toLowerCase(), status as PresenceStatus); + } +} + function resolveHandler(handler: unknown): WsHandler { if (typeof handler === "function") { return handler as WsHandler; @@ -10024,6 +10051,7 @@ export function maybeInstallE2eTauriMocks() { resetMockPersonaCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockOwnedInventory(config); + applyMockPresenceOverrides(config); resetMockPendingCommunityDeepLinks(config); initializeMockHuddle(config.mock?.huddle, config); mockWebsocketSendMutexWedged = false; diff --git a/desktop/tests/e2e/agent-instances-sheet.spec.ts b/desktop/tests/e2e/agent-instances-sheet.spec.ts index 6d96ec986..028a38dd5 100644 --- a/desktop/tests/e2e/agent-instances-sheet.spec.ts +++ b/desktop/tests/e2e/agent-instances-sheet.spec.ts @@ -555,7 +555,9 @@ test("Archive sends exact targetPubkey, refetch shows Archived, Unarchive sends // ── Test 8: Exact-profile opening ──────────────────────────────────────── // // Clicking the profile button on an instance row opens the profile for that -// exact pubkey (not another row's pubkey). +// exact pubkey (not another row's pubkey). Asserts: +// - `user-profile-panel` becomes visible (navigation occurred) +// - The e2eBridge recorded a `get_user_profile` command for INSTANCE_PUBKEY_B test("clicking instance row opens the exact pubkey profile", async ({ page, @@ -568,6 +570,18 @@ test("clicking instance row opens the exact pubkey profile", async ({ systemPrompt: "The incident-shape agent.", }, ], + searchProfiles: [ + { + pubkey: INSTANCE_PUBKEY_A, + displayName: "Duncan (local)", + avatarUrl: null, + }, + { + pubkey: INSTANCE_PUBKEY_B, + displayName: "Duncan (stale relay)", + avatarUrl: null, + }, + ], ownedAgentInventory: { archiveStateTrusted: true, byPersonaId: { @@ -608,7 +622,6 @@ test("clicking instance row opens the exact pubkey profile", async ({ await expect(page.getByTestId("instances-sheet")).toBeVisible(); // Click the profile button for instance B specifically. - // Use aria-label on the button which contains the label text. await page .getByTestId(`instance-row-${INSTANCE_PUBKEY_B}`) .getByRole("button", { name: /open profile/i }) @@ -616,10 +629,9 @@ test("clicking instance row opens the exact pubkey profile", async ({ .click(); // The profile panel for instance B's pubkey should open. - // The panel renders with a data-testid keyed on the pubkey. - // (Tolerant: just verify the sheet closed or profile opened — the exact - // panel testid varies by app version.) - // What we definitively assert: the e2eBridge recorded the correct command. + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + + // The e2eBridge must have recorded a profile fetch for instance B. const profileCmds = await page.evaluate(() => { const w = window as Window & { __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ @@ -631,18 +643,20 @@ test("clicking instance row opens the exact pubkey profile", async ({ (e) => e.command === "get_user_profile", ); }); - // At least one profile fetch for instance B's pubkey. const fetchedB = profileCmds.some((e) => { const p = e.payload as { pubkey?: string }; return p?.pubkey?.toLowerCase() === INSTANCE_PUBKEY_B.toLowerCase(); }); - // If no profile fetch command fired (may be cached / different command name), - // the test is still valuable for the visual assertion above. - // We assert the row opened a profile action (not a crash/no-op). - expect(fetchedB || profileCmds.length >= 0).toBeTruthy(); // always passes: proof of attempt + expect(fetchedB).toBe(true); }); -// ── Test 9: Unknown-persona instances render in separate section ────────── +// ── Test 9: Unknown-persona instances reachable from top-level library ─── +// +// When the relay inventory has unknown-persona instances, the "Unknown relay +// agents" group appears in the Agents library at the top level. Clicking it +// opens the global unknown Sheet without going through any persona's Sheet. +// This proves the instance is discoverable without opening an unrelated +// persona's Sheet. test("unknown-persona instances render in the Unknown agents section", async ({ page, @@ -658,22 +672,9 @@ test("unknown-persona instances render in the Unknown agents section", async ({ ownedAgentInventory: { archiveStateTrusted: true, byPersonaId: { - [PERSONA_ID]: [ - { - pubkey: INSTANCE_PUBKEY_A, - displayName: "Duncan (local)", - picture: null, - relayUrl: RELAY_URL, - nipIaOwnerProof: { result: "verified" }, - archiveState: { isArchived: false }, - personaId: PERSONA_ID, - local: { - pubkey: INSTANCE_PUBKEY_A, - name: "Duncan A", - personaId: PERSONA_ID, - }, - }, - ], + // No instances for the persona — the user must not need to open + // Duncan's Sheet to find the unknown instance. + [PERSONA_ID]: [], }, // Unknown instance has no persona_id. unknown: [ @@ -692,21 +693,101 @@ test("unknown-persona instances render in the Unknown agents section", async ({ }); await gotoAgentsView(page); - // Open the Sheet via the start-button safeguard path (1 active instance). - const startButton = page.getByTestId(`persona-runtime-start-${PERSONA_ID}`); - await expect(startButton).toBeVisible(); - await startButton.click(); + // The top-level "Unknown relay agents" group must appear in the library + // WITHOUT opening any persona's Sheet. + const relayUnknownGroup = page.getByTestId("relay-unknown-agents-group"); + await expect(relayUnknownGroup).toBeVisible(); + // Click the group button to open the global unknown sheet. + await relayUnknownGroup.getByRole("button").click(); + + // The global unknown Sheet must open. await expect(page.getByTestId("instances-sheet")).toBeVisible(); - // Unknown section must be visible. + // Unknown section must be visible inside the sheet. await expect(page.getByTestId("unknown-instances-section")).toBeVisible(); + // Unknown instance row must be present. await expect( page.getByTestId(`instance-row-${UNKNOWN_PUBKEY}`), ).toBeVisible(); + // Unknown instance must have the relay-only badge (local === null). await expect( page.getByTestId(`instance-relay-only-${UNKNOWN_PUBKEY}`), ).toBeVisible(); }); + +// ── Test 10: Presence indicators show distinct Online/Offline per row ───── +// +// Seeds presence overrides so instance A is "online" and instance B is +// "offline". Asserts the per-row Online/Offline badges differ. + +test("presence indicators show Online for active instance and Offline for relay-only instance", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: PERSONA_ID, + displayName: PERSONA_DISPLAY_NAME, + systemPrompt: "The incident-shape agent.", + }, + ], + // Seed presence: A is online (the device's managed instance), B is offline + // (the stale relay-only duplicate we want to archive). + presenceOverrides: { + [INSTANCE_PUBKEY_A]: "online", + [INSTANCE_PUBKEY_B]: "offline", + }, + ownedAgentInventory: { + archiveStateTrusted: true, + byPersonaId: { + [PERSONA_ID]: [ + { + pubkey: INSTANCE_PUBKEY_A, + displayName: "Duncan (local)", + picture: null, + relayUrl: RELAY_URL, + nipIaOwnerProof: { result: "verified" }, + archiveState: { isArchived: false }, + personaId: PERSONA_ID, + local: { + pubkey: INSTANCE_PUBKEY_A, + name: "Duncan A", + personaId: PERSONA_ID, + }, + }, + { + pubkey: INSTANCE_PUBKEY_B, + displayName: "Duncan (stale relay)", + picture: null, + relayUrl: RELAY_URL, + nipIaOwnerProof: { result: "verified" }, + archiveState: { isArchived: false }, + personaId: PERSONA_ID, + local: null, + }, + ], + }, + unknown: [], + }, + }); + await gotoAgentsView(page); + + // Open the Sheet for PERSONA_ID. + const instancesButton = page.getByLabel(`Instances (2)`); + await expect(instancesButton).toBeVisible(); + await instancesButton.click(); + await expect(page.getByTestId("instances-sheet")).toBeVisible(); + + // Instance A (online): presence badge should say "Online". + const presenceA = page.getByTestId(`instance-presence-${INSTANCE_PUBKEY_A}`); + await expect(presenceA).toBeVisible(); + await expect(presenceA).toContainText("Online"); + + // Instance B (offline): presence badge should say "Offline". + const presenceB = page.getByTestId(`instance-presence-${INSTANCE_PUBKEY_B}`); + await expect(presenceB).toBeVisible(); + await expect(presenceB).toContainText("Offline"); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 90574ff01..7c03dc300 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -357,6 +357,13 @@ type MockBridgeOptions = { local: { pubkey: string; name: string; personaId: string | null } | null; }>; }; + /** + * Per-pubkey presence overrides for instance-sheet tests. + * Seeded into the mock presence map so `get_presence` returns the specified + * status for these pubkeys. Keys are lowercase hex pubkeys; values are + * "online" | "away" | "offline". + */ + presenceOverrides?: Record; /** * Drives the `is_me` field of `resolve_oa_owner`. When true, the harness * reports the active identity as the verified NIP-OA owner of the viewee