From 29b7fbde17b8b03ca511948aef5488fe49259432 Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Tue, 27 Oct 2026 19:25:31 -0700 Subject: [PATCH 01/10] feat(communities): add relays saved by the previous buzz app once read the previous app's webkit localstorage community list read-only through a native command, and add its secure relays to each viewer's memberships once through the known-communities write. existing memberships and queued removals are kept; a per-viewer marker keeps later removals from being undone. Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kalvin Chau --- src-tauri/build.rs | 1 + src-tauri/capabilities/default.json | 1 + src-tauri/src/legacy_communities.rs | 186 ++++++++++++++++++ src-tauri/src/lib.rs | 3 + .../communities/legacy-communities.test.ts | 139 +++++++++++++ .../communities/legacy-communities.ts | 59 ++++++ 6 files changed, 389 insertions(+) create mode 100644 src-tauri/src/legacy_communities.rs create mode 100644 src/features/communities/legacy-communities.test.ts create mode 100644 src/features/communities/legacy-communities.ts diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 05737b10b8..759d11e2c7 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -114,6 +114,7 @@ fn main() { "get_os_idle_seconds", "workspace_pick_folder", "repositories_folder_validate", + "legacy_communities", "plugin_import_folder", "plugin_update_check", "plugin_update_preview", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 018db8def2..35b56db814 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -76,6 +76,7 @@ "allow-get-os-idle-seconds", "allow-workspace-pick-folder", "allow-repositories-folder-validate", + "allow-legacy-communities", "allow-plugin-import-folder", "allow-plugin-import-git", "allow-plugin-import-install", diff --git a/src-tauri/src/legacy_communities.rs b/src-tauri/src/legacy_communities.rs new file mode 100644 index 0000000000..f190cb3101 --- /dev/null +++ b/src-tauri/src/legacy_communities.rs @@ -0,0 +1,186 @@ +//! The relays installed old Buzz saved, read from its WebKit localStorage so the +//! app can add them once. Old Buzz kept its community list only there, outside +//! its data folder. Never written. +use rusqlite::{Connection, OpenFlags, OptionalExtension}; +use std::path::{Path, PathBuf}; + +const COMMUNITIES_KEY: &str = "buzz-communities"; + +#[cfg(target_os = "macos")] +fn root() -> Option { + let identifier = buzz_agent_controller::LegacySource::Installed.app_directory(); + Some( + dirs::home_dir()? + .join("Library/WebKit") + .join(identifier) + .join("WebsiteData"), + ) +} +#[cfg(not(target_os = "macos"))] +fn root() -> Option { + None +} + +fn databases(dir: &Path, found: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + // `file_type` does not follow symlinks, so a link cannot loop the walk. + match entry.file_type() { + Ok(kind) if kind.is_dir() => databases(&path, found), + Ok(kind) if kind.is_file() && entry.file_name() == "localstorage.sqlite3" => { + found.push(path) + } + _ => {} + } + } +} + +/// WebKit stores values as UTF-16LE; UTF-8 is the fallback old Buzz's own reader used. +fn decode(bytes: &[u8]) -> Option { + let (pairs, rest) = bytes.as_chunks::<2>(); + if rest.is_empty() && pairs.iter().any(|pair| pair[1] == 0) { + let units: Vec = pairs.iter().map(|pair| u16::from_le_bytes(*pair)).collect(); + if let Ok(value) = String::from_utf16(&units) { + return Some(value.trim_end_matches('\0').to_owned()); + } + } + String::from_utf8(bytes.to_vec()).ok() +} + +/// The `relayUrl` of each saved community; a list that is not one reads as empty. +fn read(path: &Path) -> rusqlite::Result> { + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + let value: Option> = conn + .query_row( + "SELECT value FROM ItemTable WHERE key = ?1", + [COMMUNITIES_KEY], + |row| row.get(0), + ) + .optional()?; + let list = value + .and_then(|bytes| decode(&bytes)) + .and_then(|text| serde_json::from_str::>(&text).ok()) + .unwrap_or_default(); + Ok(list + .iter() + .filter_map(|entry| entry.get("relayUrl")?.as_str().map(str::to_owned)) + .collect()) +} + +/// Every saved relay once, in saved order. Missing storage reads as none; a +/// database that cannot be read fails the whole read so the caller retries. +fn read_all(root: &Path) -> Result, String> { + let mut found = Vec::new(); + databases(root, &mut found); + let mut relays = Vec::new(); + for path in found { + let saved = read(&path).map_err(|error| { + eprintln!( + "buzz-desktop: legacy communities: {}: {error}", + path.display() + ); + "Could not read old Buzz's communities".to_string() + })?; + for relay in saved { + if !relays.contains(&relay) { + relays.push(relay); + } + } + } + Ok(relays) +} + +#[tauri::command] +pub(crate) async fn legacy_communities() -> Result, String> { + tauri::async_runtime::spawn_blocking(|| root().map_or(Ok(vec![]), |root| read_all(&root))) + .await + .map_err(|error| error.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + + fn utf16(value: &str) -> Vec { + value.encode_utf16().flat_map(u16::to_le_bytes).collect() + } + fn store(dir: &Path, value: Option>) { + std::fs::create_dir_all(dir).unwrap(); + let conn = Connection::open(dir.join("localstorage.sqlite3")).unwrap(); + conn.execute( + "CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB NOT NULL ON CONFLICT FAIL)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO ItemTable VALUES ('buzz-active-community-id', ?1)", + [utf16("a")], + ) + .unwrap(); + if let Some(value) = value { + conn.execute( + "INSERT INTO ItemTable VALUES (?1, ?2)", + (COMMUNITIES_KEY, value), + ) + .unwrap(); + } + } + + #[test] + fn reads_each_saved_relay_once_across_origins() { + let dir = tempfile::tempdir().unwrap(); + let list = r#"[{"id":"a","name":"Hive","relayUrl":"wss://hive.example.com","pubkey":"p","addedAt":"t"}, + {"id":"b","name":"Dev","relayUrl":"ws://localhost:3000","addedAt":"t"}, + {"id":"c","name":"Broken"}, + {"id":"d","name":"Hive again","relayUrl":"wss://hive.example.com","addedAt":"t"}]"#; + store( + &dir.path().join("Default/x/x/LocalStorage"), + Some(utf16(list)), + ); + let other = + r#"[{"relayUrl":"wss://other.example.com"},{"relayUrl":"wss://hive.example.com"}]"#; + store( + &dir.path().join("Default/y/y/LocalStorage"), + Some(other.as_bytes().to_vec()), + ); + let mut relays = read_all(dir.path()).unwrap(); + relays.sort(); + assert_eq!( + relays, + [ + "ws://localhost:3000", + "wss://hive.example.com", + "wss://other.example.com" + ] + ); + } + + #[test] + fn missing_or_unusable_state_reads_as_none() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + read_all(&dir.path().join("absent")).unwrap(), + Vec::::new() + ); + store(&dir.path().join("a"), None); + store(&dir.path().join("b"), Some(utf16("not json"))); + store( + &dir.path().join("c"), + Some(utf16(r#"{"relayUrl":"wss://x.example.com"}"#)), + ); + assert_eq!(read_all(dir.path()).unwrap(), Vec::::new()); + } + + #[test] + fn an_unreadable_database_fails_the_read() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("localstorage.sqlite3"), b"not a database").unwrap(); + assert!(read_all(dir.path()).is_err()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 64aeea4f1f..93ab6f81c5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -50,6 +50,7 @@ use nsec_backup_choice::{nsec_backup_choice, nsec_backup_set_choice}; mod image_clipboard; mod jev; mod keep_awake; +mod legacy_communities; #[cfg(any(not(target_os = "macos"), test))] mod microphone; @@ -59,6 +60,7 @@ mod repositories_folder; mod window_controls; mod window_state; mod workspace_folder; +use legacy_communities::legacy_communities; use os_idle::get_os_idle_seconds; use repositories_folder::repositories_folder_validate; mod relay; @@ -550,6 +552,7 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen get_os_idle_seconds, workspace_pick_folder, repositories_folder_validate, + legacy_communities, plugin_import_folder, plugin_update_check, plugin_update_preview, diff --git a/src/features/communities/legacy-communities.test.ts b/src/features/communities/legacy-communities.test.ts new file mode 100644 index 0000000000..37b8003da1 --- /dev/null +++ b/src/features/communities/legacy-communities.test.ts @@ -0,0 +1,139 @@ +import { Context } from "@deepseek-ai/cordis"; +import { afterEach, expect, it, vi } from "vitest"; +import { createCommunities } from "./service"; +import { importLegacyCommunities } from "./legacy-communities"; +import { flush } from "../relay/testing"; + +const viewer = "a".repeat(64); +const record = `buzz-client.v1:${viewer}`; +const marker = `buzz-legacy-communities.v1:${viewer}`; +const roots: Context[] = []; +// Old Buzz's list as its reader returns it: saved order, one URL per relay. +const legacy = [ + "wss://hive.example.com", + "wss://hive.example.com/", + "ws://localhost:3000", + "wss://removed.example.com", + "wss://kept.example.com", + "not a url", + "wss://unreachable.example.com", +]; + +function setup(saved?: unknown, failSave = false) { + const storage = new Map(); + if (saved !== undefined) storage.set(record, JSON.stringify(saved)); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + if (failSave) throw new Error("Full disk"); + storage.set(key, value); + }, + removeItem: (key: string) => storage.delete(key), + }); + const connect = vi.fn((id: string) => + Promise.reject(new Error(`${id} is unreachable`)), + ); + const start = () => { + const ctx = new Context(); + roots.push(ctx); + return createCommunities( + ctx, + false, + undefined, + "", + undefined, + Promise.resolve(viewer), + connect, + ); + }; + return { storage, connect, start }; +} +const saved = (storage: Map) => + JSON.parse(storage.get(record) ?? "null"); +afterEach(async () => { + for (const root of roots.splice(0)) await root.fiber.dispose(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +it("adds old Buzz's relays once beside existing settings, keeping queued removals", async () => { + const { storage, connect, start } = setup({ + profile: { name: "Ada", picture: "", about: "" }, + memberships: [{ id: "https://kept.example.com", name: "Mine" }], + selected: "https://kept.example.com", + sync: { + known: [], + outbox: [{ url: "wss://removed.example.com", removed: true }], + }, + }); + const client = start(); + const read = vi.fn(async () => legacy); + importLegacyCommunities(client, read); + await flush(); + expect(read).toHaveBeenCalledTimes(1); + expect(client.snapshot()).toMatchObject({ + profile: { name: "Ada" }, + selected: "https://kept.example.com", + memberships: [ + { id: "https://kept.example.com", name: "Mine" }, + { id: "https://hive.example.com", name: "hive.example.com" }, + { + id: "https://unreachable.example.com", + name: "unreachable.example.com", + }, + ], + }); + expect(saved(storage).sync.outbox).toEqual([ + { url: "wss://removed.example.com", removed: true }, + { url: "wss://hive.example.com", removed: false }, + { url: "wss://unreachable.example.com", removed: false }, + ]); + expect(storage.get(marker)).toBe("1"); + // Only the selected community connects; the import contacts no relay. + expect(connect.mock.calls.map(([id]) => id)).toEqual([ + "https://kept.example.com", + ]); + + // An unreachable relay fails only its own session. + const unreachable = client.open("https://unreachable.example.com"); + await flush(); + expect(unreachable?.snapshot().status).not.toBe("ready"); + expect(client.snapshot().memberships).toHaveLength(3); + + // Restart after leaving a migrated community: the marker keeps it gone. + await client.leave("https://hive.example.com"); + const restarted = start(); + importLegacyCommunities(restarted, read); + await flush(); + expect(read).toHaveBeenCalledTimes(1); + expect(restarted.snapshot().memberships.map((m) => m.id)).toEqual([ + "https://kept.example.com", + "https://unreachable.example.com", + ]); +}); + +it("marks a viewer with no old Buzz state without changing its record", async () => { + const { storage, start } = setup(); + const client = start(); + importLegacyCommunities(client, async () => []); + await flush(); + expect(client.snapshot()).toMatchObject({ status: "ready", memberships: [] }); + expect(storage.has(record)).toBe(false); + expect(storage.get(marker)).toBe("1"); +}); + +it("retries on the next launch when old Buzz's list could not be read or saved", async () => { + const unreadable = setup(); + importLegacyCommunities(unreadable.start(), async () => { + throw new Error("Could not read old Buzz's communities"); + }); + await flush(); + expect(unreadable.storage.has(marker)).toBe(false); + + const full = setup(undefined, true); + const client = full.start(); + importLegacyCommunities(client, async () => legacy); + await flush(); + expect(client.snapshot().memberships).toEqual([]); + expect(full.storage.has(marker)).toBe(false); +}); diff --git a/src/features/communities/legacy-communities.ts b/src/features/communities/legacy-communities.ts new file mode 100644 index 0000000000..6def36c2bb --- /dev/null +++ b/src/features/communities/legacy-communities.ts @@ -0,0 +1,59 @@ +import { invoke } from "@tauri-apps/api/core"; +import { communityDestination, relayAddress } from "./destination"; +import { enqueue } from "./known-communities"; +import type { Communities } from "./service"; + +const importedKey = (viewer: string) => `buzz-legacy-communities.v1:${viewer}`; + +/** Adds the communities installed old Buzz saved to each viewer's list once, + * through the same write a known-community list uses: saved without a + * session, so no relay is contacted and one that is unreachable fails only + * when opened, as any saved community does. Existing memberships stay as they + * are; a relay already saved, not a secure origin, or whose removal is queued + * is skipped, and each added one is queued for upload as a join would be. A + * per-viewer marker, written once the list is saved, keeps a later removal + * from being undone; a failed read or save leaves it unset so the next launch + * tries again. */ +export function importLegacyCommunities( + communities: Pick, + read = () => invoke("legacy_communities"), +) { + const attempted = new Set(); + async function run() { + const { status, viewer } = communities.snapshot(); + if (status !== "ready" || !viewer || attempted.has(viewer)) return; + attempted.add(viewer); + try { + if (localStorage.getItem(importedKey(viewer)) !== null) return; + const relays = await read(); + const current = communities.snapshot(); + if (current.status !== "ready" || current.viewer !== viewer) return; + const removed = new Set( + current.sync.outbox.filter((op) => op.removed).map((op) => op.url), + ); + const saved = new Set(current.memberships.map((m) => m.id)); + let sync = current.sync; + const add: string[] = []; + for (const relay of relays) { + let id: string; + try { + id = communityDestination(relay).id; + } catch { + continue; + } + const address = relayAddress(id); + if (saved.has(id) || removed.has(address)) continue; + saved.add(id); + add.push(id); + sync = enqueue(sync, address, false); + } + if (add.length) await communities.applySync(sync, add); + localStorage.setItem(importedKey(viewer), "1"); + } catch (error) { + console.warn("Couldn't add old Buzz's communities", error); + } + } + const unsubscribe = communities.subscribe(() => void run()); + void run(); + return unsubscribe; +} From 2b82e3a6ddefdeb9e50a36171f250fbe8349996c Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Tue, 27 Oct 2026 19:50:58 -0700 Subject: [PATCH 02/10] fix(communities): scope old buzz import to its identity and lifecycle return each saved community's recorded pubkey from the native reader and import only the ones saved for the current viewer. remember memberships held while the import runs so one left during the read stays gone after its removal is acknowledged. the disposer abandons a read in flight. start the import from the native app composition. Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kalvin Chau --- src-tauri/src/legacy_communities.rs | 67 ++++++++---- src/app/services.test.ts | 32 ++++++ src/app/services.ts | 3 + .../communities/legacy-communities.test.ts | 100 +++++++++++++++--- .../communities/legacy-communities.ts | 63 +++++++---- 5 files changed, 213 insertions(+), 52 deletions(-) diff --git a/src-tauri/src/legacy_communities.rs b/src-tauri/src/legacy_communities.rs index f190cb3101..0aeefb5516 100644 --- a/src-tauri/src/legacy_communities.rs +++ b/src-tauri/src/legacy_communities.rs @@ -2,10 +2,21 @@ //! app can add them once. Old Buzz kept its community list only there, outside //! its data folder. Never written. use rusqlite::{Connection, OpenFlags, OptionalExtension}; +use serde::Serialize; use std::path::{Path, PathBuf}; const COMMUNITIES_KEY: &str = "buzz-communities"; +/// One saved community: its relay and the identity it was saved for, when +/// old Buzz recorded one. +#[derive(Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Saved { + relay_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pubkey: Option, +} + #[cfg(target_os = "macos")] fn root() -> Option { let identifier = buzz_agent_controller::LegacySource::Installed.app_directory(); @@ -50,8 +61,8 @@ fn decode(bytes: &[u8]) -> Option { String::from_utf8(bytes.to_vec()).ok() } -/// The `relayUrl` of each saved community; a list that is not one reads as empty. -fn read(path: &Path) -> rusqlite::Result> { +/// Each saved community with a `relayUrl`; a list that is not one reads as empty. +fn read(path: &Path) -> rusqlite::Result> { let conn = Connection::open_with_flags( path, OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, @@ -69,16 +80,24 @@ fn read(path: &Path) -> rusqlite::Result> { .unwrap_or_default(); Ok(list .iter() - .filter_map(|entry| entry.get("relayUrl")?.as_str().map(str::to_owned)) + .filter_map(|entry| { + Some(Saved { + relay_url: entry.get("relayUrl")?.as_str()?.to_owned(), + pubkey: entry + .get("pubkey") + .and_then(|pubkey| pubkey.as_str()) + .map(str::to_owned), + }) + }) .collect()) } -/// Every saved relay once, in saved order. Missing storage reads as none; a -/// database that cannot be read fails the whole read so the caller retries. -fn read_all(root: &Path) -> Result, String> { +/// Every saved community once, in saved order. Missing storage reads as none; +/// a database that cannot be read fails the whole read so the caller retries. +fn read_all(root: &Path) -> Result, String> { let mut found = Vec::new(); databases(root, &mut found); - let mut relays = Vec::new(); + let mut communities = Vec::new(); for path in found { let saved = read(&path).map_err(|error| { eprintln!( @@ -87,17 +106,17 @@ fn read_all(root: &Path) -> Result, String> { ); "Could not read old Buzz's communities".to_string() })?; - for relay in saved { - if !relays.contains(&relay) { - relays.push(relay); + for community in saved { + if !communities.contains(&community) { + communities.push(community); } } } - Ok(relays) + Ok(communities) } #[tauri::command] -pub(crate) async fn legacy_communities() -> Result, String> { +pub(crate) async fn legacy_communities() -> Result, String> { tauri::async_runtime::spawn_blocking(|| root().map_or(Ok(vec![]), |root| read_all(&root))) .await .map_err(|error| error.to_string())? @@ -133,7 +152,7 @@ mod tests { } #[test] - fn reads_each_saved_relay_once_across_origins() { + fn reads_each_saved_community_once_with_its_identity() { let dir = tempfile::tempdir().unwrap(); let list = r#"[{"id":"a","name":"Hive","relayUrl":"wss://hive.example.com","pubkey":"p","addedAt":"t"}, {"id":"b","name":"Dev","relayUrl":"ws://localhost:3000","addedAt":"t"}, @@ -149,14 +168,20 @@ mod tests { &dir.path().join("Default/y/y/LocalStorage"), Some(other.as_bytes().to_vec()), ); - let mut relays = read_all(dir.path()).unwrap(); - relays.sort(); + let saved = |relay_url: &str, pubkey: Option<&str>| Saved { + relay_url: relay_url.into(), + pubkey: pubkey.map(Into::into), + }; + // Directory order is not fixed, so compare as a set. + let mut found = read_all(dir.path()).unwrap(); + found.sort_by(|a, b| (&a.relay_url, &a.pubkey).cmp(&(&b.relay_url, &b.pubkey))); assert_eq!( - relays, + found, [ - "ws://localhost:3000", - "wss://hive.example.com", - "wss://other.example.com" + saved("ws://localhost:3000", None), + saved("wss://hive.example.com", None), + saved("wss://hive.example.com", Some("p")), + saved("wss://other.example.com", None), ] ); } @@ -166,7 +191,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); assert_eq!( read_all(&dir.path().join("absent")).unwrap(), - Vec::::new() + Vec::::new() ); store(&dir.path().join("a"), None); store(&dir.path().join("b"), Some(utf16("not json"))); @@ -174,7 +199,7 @@ mod tests { &dir.path().join("c"), Some(utf16(r#"{"relayUrl":"wss://x.example.com"}"#)), ); - assert_eq!(read_all(dir.path()).unwrap(), Vec::::new()); + assert_eq!(read_all(dir.path()).unwrap(), Vec::::new()); } #[test] diff --git a/src/app/services.test.ts b/src/app/services.test.ts index 41c68159da..37f7850442 100644 --- a/src/app/services.test.ts +++ b/src/app/services.test.ts @@ -540,6 +540,38 @@ it("seeds a packaged native community after onboarding and preserves the user's expect(fetch).not.toHaveBeenCalled(); }); +it("adds old Buzz's communities for the restored native identity once at startup", async () => { + await services.dispose(); + vi.stubEnv("VITE_BUZZ_LIVE", "0"); + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(fetch).mockClear(); + const legacy = vi.fn(async () => [ + { relayUrl: "wss://old.example", pubkey: viewer }, + { relayUrl: "wss://unattributed.example" }, + ]); + vi.mocked(invoke).mockImplementation(async (command) => { + if (command === "identity_restore") return viewer; + if (command === "deep_link_take") return []; + if (command === "deep_link_watch") return null; + if (command === "legacy_communities") return legacy(); + throw new Error("Fixture native capability unavailable"); + }); + services = createServices(); + await vi.advanceTimersByTimeAsync(0); + expect(services.communities.snapshot()).toMatchObject({ + selected: null, + memberships: [{ id: "https://old.example", name: "old.example" }], + }); + expect(values.get(`buzz-legacy-communities.v1:${viewer}`)).toBe("1"); + // Restart: the marker keeps old Buzz's store from being read again. + await services.dispose(); + services = createServices(); + await vi.advanceTimersByTimeAsync(0); + expect(services.communities.snapshot().memberships).toHaveLength(1); + expect(legacy).toHaveBeenCalledTimes(1); + expect(fetch).not.toHaveBeenCalled(); +}); + it("keeps pinned macOS desktop development on the broker identity", async () => { await services.dispose(); vi.mocked(isTauri).mockReturnValue(true); diff --git a/src/app/services.ts b/src/app/services.ts index ea2d9ad7ba..4d7624b0be 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -29,6 +29,7 @@ import { ConversationService } from "../features/conversation/service"; import { createAppearance } from "../shared/theme/service"; import { createCommunities } from "../features/communities/service"; import { createInviteIntent } from "../features/communities/invite-intent"; +import { importLegacyCommunities } from "../features/communities/legacy-communities"; import { connectNativeTransport } from "../features/relay/native"; import { createUpdates } from "../features/updates/updates"; import { PanelsService } from "../features/panels/service"; @@ -84,6 +85,8 @@ export function createServices() { pending: communities.pendingSync, apply: communities.applySync, }); + // Native only: old Buzz's communities live in its WebKit storage on disk. + if (identity) ctx.effect(() => importLegacyCommunities(communities)); const relay = communities.relay; const adminAccess = createAdminAccess(relay, communities, nativeRelayStaff()); ctx.provide("adminAccess", adminAccess); diff --git a/src/features/communities/legacy-communities.test.ts b/src/features/communities/legacy-communities.test.ts index 37b8003da1..be224c5cb5 100644 --- a/src/features/communities/legacy-communities.test.ts +++ b/src/features/communities/legacy-communities.test.ts @@ -1,23 +1,39 @@ import { Context } from "@deepseek-ai/cordis"; -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, assert, expect, it, vi } from "vitest"; import { createCommunities } from "./service"; -import { importLegacyCommunities } from "./legacy-communities"; +import { + importLegacyCommunities, + type LegacyCommunity, +} from "./legacy-communities"; +import { acknowledge } from "./known-communities"; import { flush } from "../relay/testing"; const viewer = "a".repeat(64); +const other = "b".repeat(64); const record = `buzz-client.v1:${viewer}`; const marker = `buzz-legacy-communities.v1:${viewer}`; const roots: Context[] = []; -// Old Buzz's list as its reader returns it: saved order, one URL per relay. -const legacy = [ - "wss://hive.example.com", - "wss://hive.example.com/", - "ws://localhost:3000", - "wss://removed.example.com", - "wss://kept.example.com", - "not a url", - "wss://unreachable.example.com", +// Old Buzz's list as its reader returns it, in saved order: the viewer's +// communities, one saved before old Buzz recorded an identity, and one saved +// for another identity. +const legacy: LegacyCommunity[] = [ + { relayUrl: "wss://hive.example.com", pubkey: viewer }, + { relayUrl: "wss://hive.example.com/", pubkey: viewer }, + { relayUrl: "ws://localhost:3000", pubkey: viewer }, + { relayUrl: "wss://removed.example.com", pubkey: viewer }, + { relayUrl: "wss://kept.example.com", pubkey: viewer }, + { relayUrl: "not a url", pubkey: viewer }, + { relayUrl: "wss://unreachable.example.com", pubkey: viewer }, + { relayUrl: "wss://unattributed.example.com" }, + { relayUrl: "wss://theirs.example.com", pubkey: other }, ]; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} function setup(saved?: unknown, failSave = false) { const storage = new Map(); @@ -89,7 +105,7 @@ it("adds old Buzz's relays once beside existing settings, keeping queued removal { url: "wss://unreachable.example.com", removed: false }, ]); expect(storage.get(marker)).toBe("1"); - // Only the selected community connects; the import contacts no relay. + // Only the selected community connects; the import opens no session. expect(connect.mock.calls.map(([id]) => id)).toEqual([ "https://kept.example.com", ]); @@ -137,3 +153,63 @@ it("retries on the next launch when old Buzz's list could not be read or saved", expect(client.snapshot().memberships).toEqual([]); expect(full.storage.has(marker)).toBe(false); }); + +it("imports nothing for an identity old Buzz did not save them for", async () => { + const { storage, start } = setup(); + const client = start(); + importLegacyCommunities(client, async () => [ + { relayUrl: "wss://theirs.example.com", pubkey: other }, + { relayUrl: "wss://unattributed.example.com" }, + ]); + await flush(); + expect(client.snapshot().memberships).toEqual([]); + expect(storage.has(record)).toBe(false); + expect(storage.get(marker)).toBe("1"); +}); + +it("keeps a community left and acknowledged while the read was pending gone", async () => { + const { storage, start } = setup({ + profile: { name: "", picture: "", about: "" }, + memberships: [{ id: "https://hive.example.com", name: "Hive" }], + selected: null, + sync: { known: ["wss://hive.example.com"], outbox: [] }, + }); + const client = start(); + const read = deferred(); + importLegacyCommunities(client, () => read.promise); + await flush(); + await client.leave("https://hive.example.com"); + const [op] = client.snapshot().sync.outbox; + assert.exists(op); + await client.applySync(acknowledge(client.snapshot().sync, op)); + expect(client.snapshot().sync.outbox).toEqual([]); + read.resolve(legacy); + await flush(); + expect(client.snapshot().memberships.map((m) => m.id)).toEqual([ + "https://removed.example.com", + "https://kept.example.com", + "https://unreachable.example.com", + ]); + expect(saved(storage).sync.outbox).not.toContainEqual({ + url: "wss://hive.example.com", + removed: false, + }); +}); + +it("abandons a pending read once disposed, leaving a replacement's settings", async () => { + const { storage, start } = setup(); + const read = deferred(); + const dispose = importLegacyCommunities(start(), () => read.promise); + await flush(); + dispose(); + const replacement = start(); + await flush(); + replacement.saveProfile({ name: "Ada", picture: "", about: "" }); + read.resolve(legacy); + await flush(); + expect(saved(storage)).toMatchObject({ + profile: { name: "Ada" }, + memberships: [], + }); + expect(storage.has(marker)).toBe(false); +}); diff --git a/src/features/communities/legacy-communities.ts b/src/features/communities/legacy-communities.ts index 6def36c2bb..3e2c58f1ee 100644 --- a/src/features/communities/legacy-communities.ts +++ b/src/features/communities/legacy-communities.ts @@ -3,47 +3,69 @@ import { communityDestination, relayAddress } from "./destination"; import { enqueue } from "./known-communities"; import type { Communities } from "./service"; +/** A community installed old Buzz saved, and the identity it saved it for. */ +export type LegacyCommunity = { relayUrl: string; pubkey?: string }; + const importedKey = (viewer: string) => `buzz-legacy-communities.v1:${viewer}`; -/** Adds the communities installed old Buzz saved to each viewer's list once, +/** Adds the communities installed old Buzz saved to its identity's list once, * through the same write a known-community list uses: saved without a - * session, so no relay is contacted and one that is unreachable fails only - * when opened, as any saved community does. Existing memberships stay as they - * are; a relay already saved, not a secure origin, or whose removal is queued - * is skipped, and each added one is queued for upload as a join would be. A - * per-viewer marker, written once the list is saved, keeps a later removal - * from being undone; a failed read or save leaves it unset so the next launch - * tries again. */ + * session or join, so a relay that is unreachable fails only when opened, as + * any saved community does. Only the communities old Buzz recorded for this + * viewer's identity are taken; one saved for another identity, or that + * records none, is not. Existing memberships stay as they are; a relay + * already saved, saved at any point while this ran, not a secure origin, or + * whose removal is queued is skipped, and each added one is queued for upload + * as a join would be. A per-viewer marker, written once the list is saved, + * keeps a later removal from being undone; a failed read or save leaves it + * unset so the next launch tries again. The returned disposer also abandons a + * read still in flight. */ export function importLegacyCommunities( communities: Pick, - read = () => invoke("legacy_communities"), + read = () => invoke("legacy_communities"), ) { + let disposed = false; const attempted = new Set(); + // Every membership each viewer held since this started, so one left while + // the read was pending stays gone once its removal is acknowledged. + const seen = new Map>(); + function observe() { + const { viewer, memberships } = communities.snapshot(); + if (!viewer) return new Set(); + const ids = seen.get(viewer) ?? new Set(); + seen.set(viewer, ids); + for (const { id } of memberships) ids.add(id); + return ids; + } async function run() { + observe(); const { status, viewer } = communities.snapshot(); - if (status !== "ready" || !viewer || attempted.has(viewer)) return; + if (disposed || status !== "ready" || !viewer || attempted.has(viewer)) + return; attempted.add(viewer); try { if (localStorage.getItem(importedKey(viewer)) !== null) return; - const relays = await read(); + const saved = await read(); const current = communities.snapshot(); - if (current.status !== "ready" || current.viewer !== viewer) return; + if (disposed || current.status !== "ready" || current.viewer !== viewer) + return; + const held = observe(); const removed = new Set( current.sync.outbox.filter((op) => op.removed).map((op) => op.url), ); - const saved = new Set(current.memberships.map((m) => m.id)); - let sync = current.sync; const add: string[] = []; - for (const relay of relays) { + let sync = current.sync; + for (const { relayUrl, pubkey } of saved) { + if (pubkey !== viewer) continue; let id: string; try { - id = communityDestination(relay).id; + id = communityDestination(relayUrl).id; } catch { continue; } const address = relayAddress(id); - if (saved.has(id) || removed.has(address)) continue; - saved.add(id); + if (held.has(id) || removed.has(address)) continue; + held.add(id); add.push(id); sync = enqueue(sync, address, false); } @@ -55,5 +77,8 @@ export function importLegacyCommunities( } const unsubscribe = communities.subscribe(() => void run()); void run(); - return unsubscribe; + return () => { + disposed = true; + unsubscribe(); + }; } From 8333a2f9236b40327d478c478288d901636d420a Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Tue, 27 Oct 2026 20:04:12 -0700 Subject: [PATCH 03/10] fix(communities): keep removals queued during old buzz import record every removal each viewer queues while the import runs, alongside the memberships it holds, so a relay whose queued removal is acknowledged during the native read is not restored. Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kalvin Chau --- .../communities/legacy-communities.test.ts | 25 +++++++++++++++ .../communities/legacy-communities.ts | 31 ++++++++++--------- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/features/communities/legacy-communities.test.ts b/src/features/communities/legacy-communities.test.ts index be224c5cb5..2505de9419 100644 --- a/src/features/communities/legacy-communities.test.ts +++ b/src/features/communities/legacy-communities.test.ts @@ -196,6 +196,31 @@ it("keeps a community left and acknowledged while the read was pending gone", as }); }); +it("keeps a queued removal acknowledged while the read was pending gone", async () => { + const { storage, start } = setup({ + profile: { name: "", picture: "", about: "" }, + memberships: [], + selected: null, + sync: { + known: ["wss://removed.example.com"], + outbox: [{ url: "wss://removed.example.com", removed: true }], + }, + }); + const client = start(); + const read = deferred(); + importLegacyCommunities(client, () => read.promise); + await flush(); + const [op] = client.snapshot().sync.outbox; + assert.exists(op); + await client.applySync(acknowledge(client.snapshot().sync, op)); + expect(client.snapshot().sync.outbox).toEqual([]); + read.resolve([{ relayUrl: "wss://removed.example.com", pubkey: viewer }]); + await flush(); + expect(client.snapshot().memberships).toEqual([]); + expect(saved(storage).sync.outbox).toEqual([]); + expect(storage.get(marker)).toBe("1"); +}); + it("abandons a pending read once disposed, leaving a replacement's settings", async () => { const { storage, start } = setup(); const read = deferred(); diff --git a/src/features/communities/legacy-communities.ts b/src/features/communities/legacy-communities.ts index 3e2c58f1ee..2677726387 100644 --- a/src/features/communities/legacy-communities.ts +++ b/src/features/communities/legacy-communities.ts @@ -14,8 +14,8 @@ const importedKey = (viewer: string) => `buzz-legacy-communities.v1:${viewer}`; * any saved community does. Only the communities old Buzz recorded for this * viewer's identity are taken; one saved for another identity, or that * records none, is not. Existing memberships stay as they are; a relay - * already saved, saved at any point while this ran, not a secure origin, or - * whose removal is queued is skipped, and each added one is queued for upload + * already saved, not a secure origin, or saved or queued for removal at any + * point while this ran is skipped, and each added one is queued for upload * as a join would be. A per-viewer marker, written once the list is saved, * keeps a later removal from being undone; a failed read or save leaves it * unset so the next launch tries again. The returned disposer also abandons a @@ -26,16 +26,20 @@ export function importLegacyCommunities( ) { let disposed = false; const attempted = new Set(); - // Every membership each viewer held since this started, so one left while - // the read was pending stays gone once its removal is acknowledged. - const seen = new Map>(); + // Every membership each viewer held and removal it queued since this + // started, so one left before or while the read was pending stays gone once + // its removal is acknowledged. + const seen = new Map; removed: Set }>(); function observe() { - const { viewer, memberships } = communities.snapshot(); - if (!viewer) return new Set(); - const ids = seen.get(viewer) ?? new Set(); - seen.set(viewer, ids); - for (const { id } of memberships) ids.add(id); - return ids; + const { viewer, memberships, sync } = communities.snapshot(); + const history = { held: new Set(), removed: new Set() }; + if (!viewer) return history; + const known = seen.get(viewer) ?? history; + seen.set(viewer, known); + for (const { id } of memberships) known.held.add(id); + for (const { url, removed } of sync.outbox) + if (removed) known.removed.add(url); + return known; } async function run() { observe(); @@ -49,10 +53,7 @@ export function importLegacyCommunities( const current = communities.snapshot(); if (disposed || current.status !== "ready" || current.viewer !== viewer) return; - const held = observe(); - const removed = new Set( - current.sync.outbox.filter((op) => op.removed).map((op) => op.url), - ); + const { held, removed } = observe(); const add: string[] = []; let sync = current.sync; for (const { relayUrl, pubkey } of saved) { From 672e0ec4dfd82fe27ce5605db758aa918f225e44 Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Tue, 27 Oct 2026 20:20:49 -0700 Subject: [PATCH 04/10] fix(communities): keep observed removals across a retried old buzz import until the import finishes, store the removal addresses it has seen under the per-viewer marker and seed them on the next launch, so a relay whose removal was acknowledged after a failed read or save is not restored when the import retries. Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kalvin Chau --- .../communities/legacy-communities.test.ts | 38 ++++++++++++++ .../communities/legacy-communities.ts | 51 +++++++++++++++---- 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/src/features/communities/legacy-communities.test.ts b/src/features/communities/legacy-communities.test.ts index 2505de9419..fbc674209f 100644 --- a/src/features/communities/legacy-communities.test.ts +++ b/src/features/communities/legacy-communities.test.ts @@ -221,6 +221,44 @@ it("keeps a queued removal acknowledged while the read was pending gone", async expect(storage.get(marker)).toBe("1"); }); +it("keeps a removal acknowledged after a failed import gone on the next launch", async () => { + const { storage, start } = setup({ + profile: { name: "", picture: "", about: "" }, + memberships: [], + selected: null, + sync: { + known: ["wss://removed.example.com"], + outbox: [{ url: "wss://removed.example.com", removed: true }], + }, + }); + const client = start(); + importLegacyCommunities(client, async () => { + throw new Error("Could not read old Buzz's communities"); + }); + await flush(); + const [op] = client.snapshot().sync.outbox; + assert.exists(op); + await client.applySync(acknowledge(client.snapshot().sync, op)); + expect(saved(storage).sync.outbox).toEqual([]); + expect(JSON.parse(storage.get(marker) ?? "null")).toEqual([ + "wss://removed.example.com", + ]); + + const restarted = start(); + importLegacyCommunities(restarted, async () => [ + { relayUrl: "wss://removed.example.com", pubkey: viewer }, + { relayUrl: "wss://hive.example.com", pubkey: viewer }, + ]); + await flush(); + expect(restarted.snapshot().memberships.map((m) => m.id)).toEqual([ + "https://hive.example.com", + ]); + expect(saved(storage).sync.outbox).toEqual([ + { url: "wss://hive.example.com", removed: false }, + ]); + expect(storage.get(marker)).toBe("1"); +}); + it("abandons a pending read once disposed, leaving a replacement's settings", async () => { const { storage, start } = setup(); const read = deferred(); diff --git a/src/features/communities/legacy-communities.ts b/src/features/communities/legacy-communities.ts index 2677726387..5c9502ee06 100644 --- a/src/features/communities/legacy-communities.ts +++ b/src/features/communities/legacy-communities.ts @@ -7,6 +7,21 @@ import type { Communities } from "./service"; export type LegacyCommunity = { relayUrl: string; pubkey?: string }; const importedKey = (viewer: string) => `buzz-legacy-communities.v1:${viewer}`; +const imported = "1"; + +/** The removal addresses a pending import recorded for this viewer. */ +function pendingRemovals(viewer: string): string[] { + try { + const value: unknown = JSON.parse( + localStorage.getItem(importedKey(viewer)) ?? "[]", + ); + return Array.isArray(value) + ? value.filter((url): url is string => typeof url === "string") + : []; + } catch { + return []; + } +} /** Adds the communities installed old Buzz saved to its identity's list once, * through the same write a known-community list uses: saved without a @@ -17,9 +32,10 @@ const importedKey = (viewer: string) => `buzz-legacy-communities.v1:${viewer}`; * already saved, not a secure origin, or saved or queued for removal at any * point while this ran is skipped, and each added one is queued for upload * as a join would be. A per-viewer marker, written once the list is saved, - * keeps a later removal from being undone; a failed read or save leaves it - * unset so the next launch tries again. The returned disposer also abandons a - * read still in flight. */ + * keeps a later removal from being undone; until then it holds the removals + * seen, so a failed read or save tries again on the next launch without + * restoring them. The returned disposer also abandons a read still in + * flight. */ export function importLegacyCommunities( communities: Pick, read = () => invoke("legacy_communities"), @@ -27,18 +43,33 @@ export function importLegacyCommunities( let disposed = false; const attempted = new Set(); // Every membership each viewer held and removal it queued since this - // started, so one left before or while the read was pending stays gone once - // its removal is acknowledged. + // started, or before a launch whose import did not finish, so one left + // before or while the read was pending stays gone once its removal is + // acknowledged. const seen = new Map; removed: Set }>(); function observe() { const { viewer, memberships, sync } = communities.snapshot(); - const history = { held: new Set(), removed: new Set() }; - if (!viewer) return history; - const known = seen.get(viewer) ?? history; + if (!viewer) return { held: new Set(), removed: new Set() }; + const known = seen.get(viewer) ?? { + held: new Set(), + removed: new Set(pendingRemovals(viewer)), + }; seen.set(viewer, known); + const before = known.removed.size; for (const { id } of memberships) known.held.add(id); for (const { url, removed } of sync.outbox) if (removed) known.removed.add(url); + if (known.removed.size > before) { + try { + if (localStorage.getItem(importedKey(viewer)) !== imported) + localStorage.setItem( + importedKey(viewer), + JSON.stringify([...known.removed]), + ); + } catch (error) { + console.warn("Couldn't save old Buzz's import progress", error); + } + } return known; } async function run() { @@ -48,7 +79,7 @@ export function importLegacyCommunities( return; attempted.add(viewer); try { - if (localStorage.getItem(importedKey(viewer)) !== null) return; + if (localStorage.getItem(importedKey(viewer)) === imported) return; const saved = await read(); const current = communities.snapshot(); if (disposed || current.status !== "ready" || current.viewer !== viewer) @@ -71,7 +102,7 @@ export function importLegacyCommunities( sync = enqueue(sync, address, false); } if (add.length) await communities.applySync(sync, add); - localStorage.setItem(importedKey(viewer), "1"); + localStorage.setItem(importedKey(viewer), imported); } catch (error) { console.warn("Couldn't add old Buzz's communities", error); } From 10fc18523a7dea589fdb5876f4be89d203a46f09 Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Tue, 27 Oct 2026 20:31:15 -0700 Subject: [PATCH 05/10] fix(communities): retry unsaved old buzz import progress mark the observed removal history unsaved when it grows and retry the write on each observation until it succeeds, so a removal whose first progress write failed is still kept once storage recovers. Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kalvin Chau --- .../communities/legacy-communities.test.ts | 43 +++++++++++++++++++ .../communities/legacy-communities.ts | 19 ++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/features/communities/legacy-communities.test.ts b/src/features/communities/legacy-communities.test.ts index fbc674209f..fa8dd314fb 100644 --- a/src/features/communities/legacy-communities.test.ts +++ b/src/features/communities/legacy-communities.test.ts @@ -259,6 +259,49 @@ it("keeps a removal acknowledged after a failed import gone on the next launch", expect(storage.get(marker)).toBe("1"); }); +it("saves a removal whose first progress write failed once storage recovers", async () => { + const { storage, start } = setup({ + profile: { name: "", picture: "", about: "" }, + memberships: [], + selected: null, + sync: { + known: ["wss://removed.example.com"], + outbox: [{ url: "wss://removed.example.com", removed: true }], + }, + }); + const setItem = localStorage.setItem; + let failing = true; + vi.spyOn(localStorage, "setItem").mockImplementation((key, value) => { + if (failing && key === marker) throw new Error("Full disk"); + setItem(key, value); + }); + const client = start(); + const read = deferred(); + const dispose = importLegacyCommunities(client, () => read.promise); + await flush(); + expect(storage.has(marker)).toBe(false); + failing = false; + const [op] = client.snapshot().sync.outbox; + assert.exists(op); + await client.applySync(acknowledge(client.snapshot().sync, op)); + expect(saved(storage).sync.outbox).toEqual([]); + expect(JSON.parse(storage.get(marker) ?? "null")).toEqual([ + "wss://removed.example.com", + ]); + dispose(); + + const restarted = start(); + importLegacyCommunities(restarted, async () => [ + { relayUrl: "wss://removed.example.com", pubkey: viewer }, + { relayUrl: "wss://hive.example.com", pubkey: viewer }, + ]); + await flush(); + expect(restarted.snapshot().memberships.map((m) => m.id)).toEqual([ + "https://hive.example.com", + ]); + expect(storage.get(marker)).toBe("1"); +}); + it("abandons a pending read once disposed, leaving a replacement's settings", async () => { const { storage, start } = setup(); const read = deferred(); diff --git a/src/features/communities/legacy-communities.ts b/src/features/communities/legacy-communities.ts index 5c9502ee06..be36fdf3a5 100644 --- a/src/features/communities/legacy-communities.ts +++ b/src/features/communities/legacy-communities.ts @@ -45,27 +45,38 @@ export function importLegacyCommunities( // Every membership each viewer held and removal it queued since this // started, or before a launch whose import did not finish, so one left // before or while the read was pending stays gone once its removal is - // acknowledged. - const seen = new Map; removed: Set }>(); + // acknowledged. Removals not yet written are retried on each observation. + const seen = new Map< + string, + { held: Set; removed: Set; saved: boolean } + >(); function observe() { const { viewer, memberships, sync } = communities.snapshot(); - if (!viewer) return { held: new Set(), removed: new Set() }; + if (!viewer) + return { + held: new Set(), + removed: new Set(), + saved: true, + }; const known = seen.get(viewer) ?? { held: new Set(), removed: new Set(pendingRemovals(viewer)), + saved: true, }; seen.set(viewer, known); const before = known.removed.size; for (const { id } of memberships) known.held.add(id); for (const { url, removed } of sync.outbox) if (removed) known.removed.add(url); - if (known.removed.size > before) { + if (known.removed.size > before) known.saved = false; + if (!known.saved) { try { if (localStorage.getItem(importedKey(viewer)) !== imported) localStorage.setItem( importedKey(viewer), JSON.stringify([...known.removed]), ); + known.saved = true; } catch (error) { console.warn("Couldn't save old Buzz's import progress", error); } From fa1f3e4033964ef22d1c27e1091ec76edbf885d5 Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Wed, 28 Oct 2026 09:30:01 -0700 Subject: [PATCH 06/10] fix(communities): propagate legacy storage traversal errors fail the legacy read on directory, entry, and file type errors while allowing only a missing root to read as empty. preserve symlink skipping and cover access recovery and importer retry after a rejected read. Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: Kalvin Chau --- src-tauri/src/legacy_communities.rs | 82 ++++++++++++++++--- .../communities/legacy-communities.test.ts | 16 +++- 2 files changed, 82 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/legacy_communities.rs b/src-tauri/src/legacy_communities.rs index 0aeefb5516..36144dfdce 100644 --- a/src-tauri/src/legacy_communities.rs +++ b/src-tauri/src/legacy_communities.rs @@ -32,21 +32,19 @@ fn root() -> Option { None } -fn databases(dir: &Path, found: &mut Vec) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { +fn databases(entries: std::fs::ReadDir, found: &mut Vec) -> std::io::Result<()> { + for entry in entries { + let entry = entry?; let path = entry.path(); // `file_type` does not follow symlinks, so a link cannot loop the walk. - match entry.file_type() { - Ok(kind) if kind.is_dir() => databases(&path, found), - Ok(kind) if kind.is_file() && entry.file_name() == "localstorage.sqlite3" => { - found.push(path) - } - _ => {} + let kind = entry.file_type()?; + if kind.is_dir() { + databases(std::fs::read_dir(&path)?, found)?; + } else if kind.is_file() && entry.file_name() == "localstorage.sqlite3" { + found.push(path); } } + Ok(()) } /// WebKit stores values as UTF-16LE; UTF-8 is the fallback old Buzz's own reader used. @@ -93,10 +91,22 @@ fn read(path: &Path) -> rusqlite::Result> { } /// Every saved community once, in saved order. Missing storage reads as none; -/// a database that cannot be read fails the whole read so the caller retries. +/// a traversal or database error fails the whole read so the caller retries. fn read_all(root: &Path) -> Result, String> { let mut found = Vec::new(); - databases(root, &mut found); + let traversal = match std::fs::read_dir(root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), + entries => entries, + }; + traversal + .and_then(|entries| databases(entries, &mut found)) + .map_err(|error| { + eprintln!( + "buzz-desktop: legacy communities: {}: {error}", + root.display() + ); + "Could not read old Buzz's communities".to_string() + })?; let mut communities = Vec::new(); for path in found { let saved = read(&path).map_err(|error| { @@ -207,5 +217,51 @@ mod tests { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("localstorage.sqlite3"), b"not a database").unwrap(); assert!(read_all(dir.path()).is_err()); + assert!(read_all(&dir.path().join("localstorage.sqlite3")).is_err()); + } + + #[cfg(unix)] + #[test] + fn an_unreadable_directory_fails_the_read_and_retries_after_access_is_restored() { + use std::os::unix::fs::PermissionsExt; + + for nested in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let storage = dir.path().join("Default/LocalStorage"); + store( + &storage, + Some(utf16( + r#"[{"relayUrl":"wss://hive.example.com","pubkey":"p"}]"#, + )), + ); + let blocked = if nested { &storage } else { dir.path() }; + let permissions = std::fs::metadata(blocked).unwrap().permissions(); + std::fs::set_permissions(blocked, std::fs::Permissions::from_mode(0o000)).unwrap(); + let result = read_all(dir.path()); + std::fs::set_permissions(blocked, permissions).unwrap(); + assert!(result.is_err(), "unreadable directory: {blocked:?}"); + assert_eq!( + read_all(dir.path()).unwrap(), + vec![Saved { + relay_url: "wss://hive.example.com".into(), + pubkey: Some("p".into()), + }] + ); + } + } + + #[cfg(unix)] + #[test] + fn skips_symlinked_directories_and_databases() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let database = outside.path().join("localstorage.sqlite3"); + std::fs::write(&database, b"not a database").unwrap(); + symlink(outside.path(), dir.path().join("linked-directory")).unwrap(); + symlink(database, dir.path().join("localstorage.sqlite3")).unwrap(); + symlink(dir.path(), dir.path().join("loop")).unwrap(); + assert_eq!(read_all(dir.path()).unwrap(), Vec::::new()); } } diff --git a/src/features/communities/legacy-communities.test.ts b/src/features/communities/legacy-communities.test.ts index fa8dd314fb..7afc9cd5cc 100644 --- a/src/features/communities/legacy-communities.test.ts +++ b/src/features/communities/legacy-communities.test.ts @@ -140,11 +140,21 @@ it("marks a viewer with no old Buzz state without changing its record", async () it("retries on the next launch when old Buzz's list could not be read or saved", async () => { const unreadable = setup(); - importLegacyCommunities(unreadable.start(), async () => { - throw new Error("Could not read old Buzz's communities"); - }); + const read = vi + .fn<() => Promise>() + .mockRejectedValueOnce("Could not read old Buzz's communities") + .mockResolvedValue(legacy); + const dispose = importLegacyCommunities(unreadable.start(), read); await flush(); expect(unreadable.storage.has(marker)).toBe(false); + expect(unreadable.storage.has(record)).toBe(false); + dispose(); + const restarted = unreadable.start(); + importLegacyCommunities(restarted, read); + await flush(); + expect(read).toHaveBeenCalledTimes(2); + expect(restarted.snapshot().memberships).toHaveLength(4); + expect(unreadable.storage.get(marker)).toBe("1"); const full = setup(undefined, true); const client = full.start(); From 617f4bbe9507a2cc9617ab8327131e1422fd940a Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Wed, 28 Oct 2026 10:02:17 -0700 Subject: [PATCH 07/10] fix(communities): require consent before importing old buzz communities require approval for every nonempty legacy import because account state cannot prove that acknowledged removals never existed. offer the saved origins after launch and remember a declined import without adding or queueing communities. recheck current membership and sync state after consent, preserving edits made while the prompt is open. cover acknowledged removals, uncertain account sync, dialog disposal, native app wiring, and browser startup focus with reload persistence. Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: Kalvin Chau --- docs/communities.md | 20 +++ src/app/App.tsx | 5 + src/app/services.test.ts | 12 +- src/app/services.ts | 3 - .../LegacyCommunityImport.test.tsx | 110 ++++++++++++ .../communities/LegacyCommunityImport.tsx | 69 ++++++++ .../communities/legacy-communities.test.ts | 165 ++++++++++++++++-- .../communities/legacy-communities.ts | 32 +++- tests/browser/legacy-communities.spec.mjs | 129 ++++++++++++++ 9 files changed, 510 insertions(+), 35 deletions(-) create mode 100644 src/features/communities/LegacyCommunityImport.test.tsx create mode 100644 src/features/communities/LegacyCommunityImport.tsx create mode 100644 tests/browser/legacy-communities.spec.mjs diff --git a/docs/communities.md b/docs/communities.md index 896c96c9a1..6bdec43da8 100644 --- a/docs/communities.md +++ b/docs/communities.md @@ -115,6 +115,26 @@ empty state there. Try drafting in A, switching to B, then returning to A. Selected channels, drafts and reading offsets are partitioned by the canonical community origin and viewer; channel IDs alone are not sufficient keys. +## Import from the previous desktop app + +After native startup, Buzz offers the previous desktop app's saved communities +for the signed-in identity. The dialog lists eligible secure origins and warns +that importing may restore communities previously removed. Nothing is added or +queued for account sync until **Import communities** is chosen. **Skip import**, +Close, or Escape declines the import and keeps that decision across launches. + +Every nonempty import requires consent. An empty local record or account list +cannot establish an untouched account: acknowledged removals leave no history, +and pending or unsuccessful sync cannot establish it either. Existing memberships +and settings stay unchanged. Memberships and removals observed while the read or +consent is pending are checked again before adding anything. + +The native reader opens the previous app's macOS WebKit storage read-only and +skips symlinks. An absent root reads as empty; traversal or database errors leave +the import retryable on the next launch. A read or save that does not complete +does not record approval; disposing a pending prompt does not record a decline. +Other platforms have no supported legacy storage source. + ## Known communities The device record also carries which saved destinations the account service diff --git a/src/app/App.tsx b/src/app/App.tsx index f1f5e90484..bd76e7cb08 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -46,10 +46,12 @@ import { PanelLaunchers } from "./shell/PanelLaunchers"; import { PanelCard } from "../features/panels/PanelCard"; import { communityDestination } from "../features/communities/destination"; import { CommunityLanding } from "../features/communities/CommunityLanding"; +import { LegacyCommunityImport } from "../features/communities/LegacyCommunityImport"; import { launchVisible, setLaunchReady, subscribeLaunch } from "./launch"; export function App({ services }: { services: AppServices }) { const identity = services.identity; + const launching = useSyncExternalStore(subscribeLaunch, launchVisible); useEffect(() => { if (!identity) return; const update = () => { @@ -69,6 +71,9 @@ export function App({ services }: { services: AppServices }) { {services.identity ? ( + {!launching && ( + + )} ) : ( diff --git a/src/app/services.test.ts b/src/app/services.test.ts index 37f7850442..19244efb64 100644 --- a/src/app/services.test.ts +++ b/src/app/services.test.ts @@ -540,7 +540,7 @@ it("seeds a packaged native community after onboarding and preserves the user's expect(fetch).not.toHaveBeenCalled(); }); -it("adds old Buzz's communities for the restored native identity once at startup", async () => { +it("does not import old Buzz's communities at native startup without consent", async () => { await services.dispose(); vi.stubEnv("VITE_BUZZ_LIVE", "0"); vi.mocked(isTauri).mockReturnValue(true); @@ -560,15 +560,15 @@ it("adds old Buzz's communities for the restored native identity once at startup await vi.advanceTimersByTimeAsync(0); expect(services.communities.snapshot()).toMatchObject({ selected: null, - memberships: [{ id: "https://old.example", name: "old.example" }], + memberships: [], }); - expect(values.get(`buzz-legacy-communities.v1:${viewer}`)).toBe("1"); - // Restart: the marker keeps old Buzz's store from being read again. + expect(values.has(`buzz-legacy-communities.v1:${viewer}`)).toBe(false); + // Restart does not infer consent from the still-empty device record. await services.dispose(); services = createServices(); await vi.advanceTimersByTimeAsync(0); - expect(services.communities.snapshot().memberships).toHaveLength(1); - expect(legacy).toHaveBeenCalledTimes(1); + expect(services.communities.snapshot().memberships).toHaveLength(0); + expect(legacy).not.toHaveBeenCalled(); expect(fetch).not.toHaveBeenCalled(); }); diff --git a/src/app/services.ts b/src/app/services.ts index 4d7624b0be..ea2d9ad7ba 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -29,7 +29,6 @@ import { ConversationService } from "../features/conversation/service"; import { createAppearance } from "../shared/theme/service"; import { createCommunities } from "../features/communities/service"; import { createInviteIntent } from "../features/communities/invite-intent"; -import { importLegacyCommunities } from "../features/communities/legacy-communities"; import { connectNativeTransport } from "../features/relay/native"; import { createUpdates } from "../features/updates/updates"; import { PanelsService } from "../features/panels/service"; @@ -85,8 +84,6 @@ export function createServices() { pending: communities.pendingSync, apply: communities.applySync, }); - // Native only: old Buzz's communities live in its WebKit storage on disk. - if (identity) ctx.effect(() => importLegacyCommunities(communities)); const relay = communities.relay; const adminAccess = createAdminAccess(relay, communities, nativeRelayStaff()); ctx.provide("adminAccess", adminAccess); diff --git a/src/features/communities/LegacyCommunityImport.test.tsx b/src/features/communities/LegacyCommunityImport.test.tsx new file mode 100644 index 0000000000..83c3740abe --- /dev/null +++ b/src/features/communities/LegacyCommunityImport.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Context } from "@deepseek-ai/cordis"; +import { StrictMode } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; +import { createCommunities } from "./service"; +import { LegacyCommunityImport } from "./LegacyCommunityImport"; +import { flush } from "../relay/testing"; + +vi.mock("@tauri-apps/api/core", async (importOriginal) => ({ + ...(await importOriginal()), + invoke: vi.fn(), +})); +const viewer = "a".repeat(64); +const marker = `buzz-legacy-communities.v1:${viewer}`; +const record = `buzz-client.v1:${viewer}`; +const roots: Context[] = []; +afterEach(async () => { + cleanup(); + for (const root of roots.splice(0)) await root.fiber.dispose(); + localStorage.clear(); + vi.mocked(invoke).mockReset(); +}); + +function fixture() { + vi.mocked(invoke).mockResolvedValue([ + { relayUrl: "wss://old.example", pubkey: viewer }, + ]); + const root = new Context(); + roots.push(root); + const communities = createCommunities( + root, + false, + undefined, + "", + undefined, + Promise.resolve(viewer), + ); + const mount = () => + render( + + + , + ); + return { communities, mount, user: userEvent.setup() }; +} + +it("imports only after the owner approves the listed communities", async () => { + const { communities, mount, user } = fixture(); + const view = mount(); + await screen.findByRole("alertdialog", { + name: "Import communities from old Buzz?", + }); + expect( + screen.getByRole("list", { name: "Communities to import" }), + ).toHaveTextContent("https://old.example"); + expect( + screen.getByText(/may restore communities you previously removed/), + ).toBeVisible(); + expect(communities.snapshot().memberships).toEqual([]); + expect(localStorage.getItem(record)).toBeNull(); + expect(localStorage.getItem(marker)).toBeNull(); + await user.click(screen.getByRole("button", { name: "Import communities" })); + await waitFor(() => expect(localStorage.getItem(marker)).toBe("1")); + expect(communities.snapshot().memberships).toEqual([ + { id: "https://old.example", name: "old.example" }, + ]); + expect(communities.snapshot().sync.outbox).toEqual([ + { url: "wss://old.example", removed: false }, + ]); + const calls = vi.mocked(invoke).mock.calls.length; + view.unmount(); + mount(); + await act(flush); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + expect(invoke).toHaveBeenCalledTimes(calls); +}); + +it.each(["Skip import", "Close", "Escape"])( + "declines without importing via %s", + async (action) => { + const { communities, mount, user } = fixture(); + const view = mount(); + await screen.findByRole("alertdialog"); + if (action === "Escape") await user.keyboard("{Escape}"); + else await user.click(screen.getByRole("button", { name: action })); + await waitFor(() => expect(localStorage.getItem(marker)).toBe("1")); + expect(communities.snapshot().memberships).toEqual([]); + expect(localStorage.getItem(record)).toBeNull(); + view.unmount(); + mount(); + await act(flush); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + }, +); + +it("does not persist a decline when a pending consent dialog unmounts", async () => { + const { communities, mount } = fixture(); + const view = mount(); + await screen.findByRole("alertdialog"); + view.unmount(); + await act(flush); + expect(communities.snapshot().memberships).toEqual([]); + expect(localStorage.getItem(marker)).toBeNull(); + mount(); + expect(await screen.findByRole("alertdialog")).toBeVisible(); +}); diff --git a/src/features/communities/LegacyCommunityImport.tsx b/src/features/communities/LegacyCommunityImport.tsx new file mode 100644 index 0000000000..dbc4e063c0 --- /dev/null +++ b/src/features/communities/LegacyCommunityImport.tsx @@ -0,0 +1,69 @@ +import { useEffect, useState } from "react"; +import { AlertDialog } from "../../shared/design-system/ui/AlertDialog"; +import { Button } from "../../shared/design-system/ui/Button"; +import { importLegacyCommunities } from "./legacy-communities"; +import type { Communities } from "./service"; + +/** Old storage cannot distinguish a removed community from a new one. */ +export function LegacyCommunityImport({ + communities, +}: { + communities: Communities; +}) { + const [request, setRequest] = useState<{ + origins: string[]; + resolve(approved: boolean): void; + }>(); + useEffect(() => { + let answer: ((approved: boolean) => void) | undefined; + const dispose = importLegacyCommunities( + communities, + (origins) => + new Promise((resolve) => { + answer = resolve; + setRequest({ origins, resolve }); + }), + ); + return () => { + dispose(); + answer?.(false); + }; + }, [communities]); + if (!request) return null; + const respond = (approved: boolean) => { + setRequest(undefined); + request.resolve(approved); + }; + return ( + respond(false)} + actions={ + <> + + + + } + > +
    + {request.origins.map((origin) => ( +
  • {origin}
  • + ))} +
+
+ ); +} diff --git a/src/features/communities/legacy-communities.test.ts b/src/features/communities/legacy-communities.test.ts index 7afc9cd5cc..aa9dd3fa21 100644 --- a/src/features/communities/legacy-communities.test.ts +++ b/src/features/communities/legacy-communities.test.ts @@ -5,7 +5,10 @@ import { importLegacyCommunities, type LegacyCommunity, } from "./legacy-communities"; -import { acknowledge } from "./known-communities"; +import { acknowledge, enqueue } from "./known-communities"; +import { createOAuthSession } from "../../bundled/builderlab/oauth/session"; +import { startKnownCommunitiesSync } from "../../bundled/builderlab/known-communities/sync"; +import type { ListResult } from "../../bundled/builderlab/known-communities/client"; import { flush } from "../relay/testing"; const viewer = "a".repeat(64); @@ -13,6 +16,7 @@ const other = "b".repeat(64); const record = `buzz-client.v1:${viewer}`; const marker = `buzz-legacy-communities.v1:${viewer}`; const roots: Context[] = []; +const approve = async () => true; // Old Buzz's list as its reader returns it, in saved order: the viewer's // communities, one saved before old Buzz recorded an identity, and one saved // for another identity. @@ -29,10 +33,12 @@ const legacy: LegacyCommunity[] = [ ]; function deferred() { let resolve!: (value: T) => void; - const promise = new Promise((done) => { + let reject!: (reason: unknown) => void; + const promise = new Promise((done, fail) => { resolve = done; + reject = fail; }); - return { promise, resolve }; + return { promise, resolve, reject }; } function setup(saved?: unknown, failSave = false) { @@ -72,6 +78,127 @@ afterEach(async () => { vi.restoreAllMocks(); }); +it("requires consent after a removal was acknowledged before migration", async () => { + const { storage, start } = setup({ + sync: { + known: ["wss://hive.example.com"], + outbox: [{ url: "wss://hive.example.com", removed: true }], + }, + }); + const client = start(); + await flush(); + const [op] = client.snapshot().sync.outbox; + assert.exists(op); + await client.applySync(acknowledge(client.snapshot().sync, op)); + expect(client.snapshot().sync).toEqual({ known: [], outbox: [] }); + const before = storage.get(record); + const answer = deferred(); + const consent = vi.fn(() => answer.promise); + const read = vi.fn(async () => [ + { relayUrl: "wss://hive.example.com", pubkey: viewer }, + ]); + const dispose = importLegacyCommunities(client, consent, read); + await flush(); + expect(consent).toHaveBeenCalledWith(["https://hive.example.com"]); + expect(storage.get(record)).toBe(before); + expect(storage.has(marker)).toBe(false); + answer.resolve(false); + await flush(); + expect(storage.get(record)).toBe(before); + expect(storage.get(marker)).toBe("1"); + dispose(); + importLegacyCommunities(start(), consent, read); + await flush(); + expect(read).toHaveBeenCalledTimes(1); +}); + +it.each(["pending", "failed", "empty"])( + "does not treat %s account sync as proof that an empty device is untouched", + async (syncState) => { + const { storage, start } = setup(); + const client = start(); + await flush(); + const list = deferred(); + const remote = { + list: vi.fn(() => list.promise), + add: vi.fn(async () => ({ kind: "accepted" as const })), + remove: vi.fn(async () => ({ kind: "accepted" as const })), + }; + const session = createOAuthSession(async () => ({ + value: "fixture", + account: { + subject: "account", + email: "fixture@example.com", + canUseRemoteAgents: false, + }, + })); + const stop = startKnownCommunitiesSync({ + client: remote, + session, + knownCommunities: { + snapshot: client.snapshot, + subscribe: client.subscribe, + pending: client.pendingSync, + apply: client.applySync, + }, + }); + const answer = deferred(); + let dispose = () => {}; + try { + await session.signIn(); + expect(remote.list).toHaveBeenCalledTimes(1); + if (syncState === "failed") list.reject(new Error("Account offline")); + if (syncState === "empty") + list.resolve({ kind: "listed", communities: [] }); + await flush(); + const consent = vi.fn(() => answer.promise); + dispose = importLegacyCommunities(client, consent, async () => legacy); + await flush(); + expect(consent).toHaveBeenCalledTimes(1); + expect(client.snapshot().memberships).toEqual([]); + expect(client.snapshot().sync.outbox).toEqual([]); + expect(remote.add).not.toHaveBeenCalled(); + expect(storage.has(marker)).toBe(false); + answer.resolve(false); + await flush(); + expect(storage.get(marker)).toBe("1"); + expect(remote.add).not.toHaveBeenCalled(); + } finally { + dispose(); + stop(); + session.dispose(); + list.resolve({ kind: "listed", communities: [] }); + answer.resolve(false); + } + }, +); + +it("uses current membership and sync state after consent, preserving concurrent edits", async () => { + const { storage, start } = setup(); + const client = start(); + const answer = deferred(); + importLegacyCommunities( + client, + () => answer.promise, + async () => [{ relayUrl: "wss://hive.example.com", pubkey: viewer }], + ); + await flush(); + await client.applySync( + enqueue(client.snapshot().sync, "wss://manual.example.com", false), + ["https://manual.example.com"], + ); + answer.resolve(true); + await flush(); + expect(client.snapshot().memberships.map((entry) => entry.id)).toEqual([ + "https://manual.example.com", + "https://hive.example.com", + ]); + expect(saved(storage).sync.outbox).toEqual([ + { url: "wss://manual.example.com", removed: false }, + { url: "wss://hive.example.com", removed: false }, + ]); +}); + it("adds old Buzz's relays once beside existing settings, keeping queued removals", async () => { const { storage, connect, start } = setup({ profile: { name: "Ada", picture: "", about: "" }, @@ -84,7 +211,7 @@ it("adds old Buzz's relays once beside existing settings, keeping queued removal }); const client = start(); const read = vi.fn(async () => legacy); - importLegacyCommunities(client, read); + importLegacyCommunities(client, approve, read); await flush(); expect(read).toHaveBeenCalledTimes(1); expect(client.snapshot()).toMatchObject({ @@ -119,7 +246,7 @@ it("adds old Buzz's relays once beside existing settings, keeping queued removal // Restart after leaving a migrated community: the marker keeps it gone. await client.leave("https://hive.example.com"); const restarted = start(); - importLegacyCommunities(restarted, read); + importLegacyCommunities(restarted, approve, read); await flush(); expect(read).toHaveBeenCalledTimes(1); expect(restarted.snapshot().memberships.map((m) => m.id)).toEqual([ @@ -131,11 +258,13 @@ it("adds old Buzz's relays once beside existing settings, keeping queued removal it("marks a viewer with no old Buzz state without changing its record", async () => { const { storage, start } = setup(); const client = start(); - importLegacyCommunities(client, async () => []); + const consent = vi.fn(approve); + importLegacyCommunities(client, consent, async () => []); await flush(); expect(client.snapshot()).toMatchObject({ status: "ready", memberships: [] }); expect(storage.has(record)).toBe(false); expect(storage.get(marker)).toBe("1"); + expect(consent).not.toHaveBeenCalled(); }); it("retries on the next launch when old Buzz's list could not be read or saved", async () => { @@ -144,13 +273,13 @@ it("retries on the next launch when old Buzz's list could not be read or saved", .fn<() => Promise>() .mockRejectedValueOnce("Could not read old Buzz's communities") .mockResolvedValue(legacy); - const dispose = importLegacyCommunities(unreadable.start(), read); + const dispose = importLegacyCommunities(unreadable.start(), approve, read); await flush(); expect(unreadable.storage.has(marker)).toBe(false); expect(unreadable.storage.has(record)).toBe(false); dispose(); const restarted = unreadable.start(); - importLegacyCommunities(restarted, read); + importLegacyCommunities(restarted, approve, read); await flush(); expect(read).toHaveBeenCalledTimes(2); expect(restarted.snapshot().memberships).toHaveLength(4); @@ -158,7 +287,7 @@ it("retries on the next launch when old Buzz's list could not be read or saved", const full = setup(undefined, true); const client = full.start(); - importLegacyCommunities(client, async () => legacy); + importLegacyCommunities(client, approve, async () => legacy); await flush(); expect(client.snapshot().memberships).toEqual([]); expect(full.storage.has(marker)).toBe(false); @@ -167,7 +296,8 @@ it("retries on the next launch when old Buzz's list could not be read or saved", it("imports nothing for an identity old Buzz did not save them for", async () => { const { storage, start } = setup(); const client = start(); - importLegacyCommunities(client, async () => [ + const consent = vi.fn(approve); + importLegacyCommunities(client, consent, async () => [ { relayUrl: "wss://theirs.example.com", pubkey: other }, { relayUrl: "wss://unattributed.example.com" }, ]); @@ -175,6 +305,7 @@ it("imports nothing for an identity old Buzz did not save them for", async () => expect(client.snapshot().memberships).toEqual([]); expect(storage.has(record)).toBe(false); expect(storage.get(marker)).toBe("1"); + expect(consent).not.toHaveBeenCalled(); }); it("keeps a community left and acknowledged while the read was pending gone", async () => { @@ -186,7 +317,7 @@ it("keeps a community left and acknowledged while the read was pending gone", as }); const client = start(); const read = deferred(); - importLegacyCommunities(client, () => read.promise); + importLegacyCommunities(client, approve, () => read.promise); await flush(); await client.leave("https://hive.example.com"); const [op] = client.snapshot().sync.outbox; @@ -218,7 +349,7 @@ it("keeps a queued removal acknowledged while the read was pending gone", async }); const client = start(); const read = deferred(); - importLegacyCommunities(client, () => read.promise); + importLegacyCommunities(client, approve, () => read.promise); await flush(); const [op] = client.snapshot().sync.outbox; assert.exists(op); @@ -242,7 +373,7 @@ it("keeps a removal acknowledged after a failed import gone on the next launch", }, }); const client = start(); - importLegacyCommunities(client, async () => { + importLegacyCommunities(client, approve, async () => { throw new Error("Could not read old Buzz's communities"); }); await flush(); @@ -255,7 +386,7 @@ it("keeps a removal acknowledged after a failed import gone on the next launch", ]); const restarted = start(); - importLegacyCommunities(restarted, async () => [ + importLegacyCommunities(restarted, approve, async () => [ { relayUrl: "wss://removed.example.com", pubkey: viewer }, { relayUrl: "wss://hive.example.com", pubkey: viewer }, ]); @@ -287,7 +418,7 @@ it("saves a removal whose first progress write failed once storage recovers", as }); const client = start(); const read = deferred(); - const dispose = importLegacyCommunities(client, () => read.promise); + const dispose = importLegacyCommunities(client, approve, () => read.promise); await flush(); expect(storage.has(marker)).toBe(false); failing = false; @@ -301,7 +432,7 @@ it("saves a removal whose first progress write failed once storage recovers", as dispose(); const restarted = start(); - importLegacyCommunities(restarted, async () => [ + importLegacyCommunities(restarted, approve, async () => [ { relayUrl: "wss://removed.example.com", pubkey: viewer }, { relayUrl: "wss://hive.example.com", pubkey: viewer }, ]); @@ -315,7 +446,7 @@ it("saves a removal whose first progress write failed once storage recovers", as it("abandons a pending read once disposed, leaving a replacement's settings", async () => { const { storage, start } = setup(); const read = deferred(); - const dispose = importLegacyCommunities(start(), () => read.promise); + const dispose = importLegacyCommunities(start(), approve, () => read.promise); await flush(); dispose(); const replacement = start(); diff --git a/src/features/communities/legacy-communities.ts b/src/features/communities/legacy-communities.ts index be36fdf3a5..fb0f81af93 100644 --- a/src/features/communities/legacy-communities.ts +++ b/src/features/communities/legacy-communities.ts @@ -23,7 +23,7 @@ function pendingRemovals(viewer: string): string[] { } } -/** Adds the communities installed old Buzz saved to its identity's list once, +/** Offers the communities installed old Buzz saved for explicit import once, * through the same write a known-community list uses: saved without a * session or join, so a relay that is unreachable fails only when opened, as * any saved community does. Only the communities old Buzz recorded for this @@ -31,13 +31,17 @@ function pendingRemovals(viewer: string): string[] { * records none, is not. Existing memberships stay as they are; a relay * already saved, not a secure origin, or saved or queued for removal at any * point while this ran is skipped, and each added one is queued for upload - * as a join would be. A per-viewer marker, written once the list is saved, + * as a join would be. Account state cannot prove there were no acknowledged + * removals, so every nonempty import requires consent, even with an empty + * device record or pending sync. A per-viewer marker, written after declining + * or once the list is saved, * keeps a later removal from being undone; until then it holds the removals * seen, so a failed read or save tries again on the next launch without * restoring them. The returned disposer also abandons a read still in * flight. */ export function importLegacyCommunities( communities: Pick, + confirm: (origins: string[]) => Promise, read = () => invoke("legacy_communities"), ) { let disposed = false; @@ -92,12 +96,11 @@ export function importLegacyCommunities( try { if (localStorage.getItem(importedKey(viewer)) === imported) return; const saved = await read(); - const current = communities.snapshot(); - if (disposed || current.status !== "ready" || current.viewer !== viewer) + const ready = communities.snapshot(); + if (disposed || ready.status !== "ready" || ready.viewer !== viewer) return; const { held, removed } = observe(); - const add: string[] = []; - let sync = current.sync; + const candidates = new Set(); for (const { relayUrl, pubkey } of saved) { if (pubkey !== viewer) continue; let id: string; @@ -108,10 +111,21 @@ export function importLegacyCommunities( } const address = relayAddress(id); if (held.has(id) || removed.has(address)) continue; - held.add(id); - add.push(id); - sync = enqueue(sync, address, false); + candidates.add(id); } + const approved = candidates.size ? await confirm([...candidates]) : true; + const current = communities.snapshot(); + if (disposed || current.status !== "ready" || current.viewer !== viewer) + return; + const latest = observe(); + const add = approved + ? [...candidates].filter( + (id) => + !latest.held.has(id) && !latest.removed.has(relayAddress(id)), + ) + : []; + let sync = current.sync; + for (const id of add) sync = enqueue(sync, relayAddress(id), false); if (add.length) await communities.applySync(sync, add); localStorage.setItem(importedKey(viewer), imported); } catch (error) { diff --git a/tests/browser/legacy-communities.spec.mjs b/tests/browser/legacy-communities.spec.mjs new file mode 100644 index 0000000000..6f60cea5c5 --- /dev/null +++ b/tests/browser/legacy-communities.spec.mjs @@ -0,0 +1,129 @@ +import { test, expect } from "./playwright-test.mjs"; +import { createServer } from "./vite-server.mjs"; +import { fileURLToPath } from "node:url"; +import { watchPageErrors } from "./page-errors.mjs"; + +// Browser boundary: the real launch overlay must release inertness and focus +// before consent, and a declined import must stay declined across page reload. +// Eligibility and sync failure permutations live in the colocated Vitest tests. +test("native legacy import waits for consent after launch and remembers a decline", async ({ + page, +}) => { + const server = await createServer({ + root: fileURLToPath(new URL("../../", import.meta.url)), + configFile: false, + envFile: false, + define: { "import.meta.env.VITE_BUZZ_LIVE": '"0"' }, + server: { host: "127.0.0.1", port: 0 }, + }); + const errors = watchPageErrors(page); + await page.addInitScript(() => { + const viewer = "a".repeat(64); + window.isTauri = true; + window.legacyReads = 0; + Object.defineProperty(navigator, "platform", { value: "MacIntel" }); + let callback = 0; + window.__TAURI_INTERNALS__ = { + metadata: { + currentWindow: { label: "main" }, + currentWebview: { label: "main" }, + }, + transformCallback: () => ++callback, + unregisterCallback() {}, + invoke: async (command) => { + if (command === "identity_restore") return viewer; + if (command === "legacy_communities") { + window.legacyReads++; + return [{ relayUrl: "wss://old.example", pubkey: viewer }]; + } + if (command === "deep_link_take") return []; + if (command === "deep_link_watch" || command === "get_os_idle_seconds") + return null; + if (command === "app_agent_state_load") return {}; + if (command === "app_agent_state_set") return; + if (command === "keep_awake_set") return; + if (command === "plugin_catalog") + return { + status: "ready", + externalPluginsPaused: false, + catalog: { + profile: "fixture", + location: "isolated IPC", + plugins: [], + }, + }; + throw new Error(`Unexpected fixture native command: ${command}`); + }, + }; + window.fetch = () => { + throw new Error("No development broker"); + }; + }); + try { + await server.listen(); + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}`); + const dialog = page.getByRole("alertdialog", { + name: "Import communities from old Buzz?", + }); + await expect(dialog).toBeVisible(); + await expect(page.locator("#buzz-launch")).toHaveCount(0); + expect( + await page.evaluate(() => + localStorage.getItem(`buzz-client.v1:${"a".repeat(64)}`), + ), + ).toBeNull(); + await expect(dialog.getByRole("list")).toHaveText("https://old.example"); + await page.screenshot({ + path: test.info().outputPath("consent-light.png"), + }); + await page.setViewportSize({ width: 390, height: 620 }); + await page.emulateMedia({ colorScheme: "dark" }); + await page.evaluate(() => { + localStorage.setItem("buzz-font-scale.v1", "1.5"); + window.dispatchEvent( + new StorageEvent("storage", { key: "buzz-font-scale.v1" }), + ); + }); + await expect(page.locator("html")).toHaveAttribute( + "data-color-mode", + "dark", + ); + await dialog.getByRole("button", { name: "Skip import" }).focus(); + await page.keyboard.press("Tab"); + const approve = dialog.getByRole("button", { + name: "Import communities", + exact: true, + }); + await expect(approve).toBeFocused(); + await expect(approve).toBeInViewport({ ratio: 1 }); + await expect( + dialog.getByRole("button", { name: "Skip import" }), + ).toBeInViewport({ ratio: 1 }); + await page.screenshot({ + path: test.info().outputPath("consent-dark-narrow.png"), + }); + await dialog.getByRole("button", { name: "Skip import" }).focus(); + await page.keyboard.press("Enter"); + await expect(dialog).toHaveCount(0); + await expect( + page.getByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => + localStorage.getItem(`buzz-legacy-communities.v1:${"a".repeat(64)}`), + ), + ) + .toBe("1"); + await page.reload(); + await expect( + page.getByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + await expect(page.locator("#buzz-launch")).toHaveCount(0); + await expect(dialog).toHaveCount(0); + expect(await page.evaluate(() => window.legacyReads)).toBe(0); + expect(errors.unexplained()).toEqual([]); + } finally { + await server.close(); + } +}); From 29a2eac4c379b61524cf69bcf6ca122b60e059f6 Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Wed, 28 Oct 2026 10:07:24 -0700 Subject: [PATCH 08/10] test(communities): verify import consent at enlarged interface size set the storage event source so appearance applies the requested scale and assert the computed root size before checking keyboard reachability. Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: Kalvin Chau --- tests/browser/legacy-communities.spec.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/browser/legacy-communities.spec.mjs b/tests/browser/legacy-communities.spec.mjs index 6f60cea5c5..859cfd36ab 100644 --- a/tests/browser/legacy-communities.spec.mjs +++ b/tests/browser/legacy-communities.spec.mjs @@ -81,13 +81,17 @@ test("native legacy import waits for consent after launch and remembers a declin await page.evaluate(() => { localStorage.setItem("buzz-font-scale.v1", "1.5"); window.dispatchEvent( - new StorageEvent("storage", { key: "buzz-font-scale.v1" }), + new StorageEvent("storage", { + key: "buzz-font-scale.v1", + storageArea: localStorage, + }), ); }); await expect(page.locator("html")).toHaveAttribute( "data-color-mode", "dark", ); + await expect(page.locator("html")).toHaveCSS("font-size", "24px"); await dialog.getByRole("button", { name: "Skip import" }).focus(); await page.keyboard.press("Tab"); const approve = dialog.getByRole("button", { From 3ddb185f7847fc5b91b6daceb970c7103b21abcf Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Wed, 28 Oct 2026 10:28:17 -0700 Subject: [PATCH 09/10] test(communities): trim one-time import coverage remove the whole-app browser journey and duplicate dismissal cases. retain native traversal, consent, removal and retry regressions in the existing unit and component layers without expanding browser fixtures. Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: Kalvin Chau --- .../LegacyCommunityImport.test.tsx | 30 ++-- tests/browser/legacy-communities.spec.mjs | 133 ------------------ 2 files changed, 13 insertions(+), 150 deletions(-) delete mode 100644 tests/browser/legacy-communities.spec.mjs diff --git a/src/features/communities/LegacyCommunityImport.test.tsx b/src/features/communities/LegacyCommunityImport.test.tsx index 83c3740abe..1e1ce291c3 100644 --- a/src/features/communities/LegacyCommunityImport.test.tsx +++ b/src/features/communities/LegacyCommunityImport.test.tsx @@ -79,23 +79,19 @@ it("imports only after the owner approves the listed communities", async () => { expect(invoke).toHaveBeenCalledTimes(calls); }); -it.each(["Skip import", "Close", "Escape"])( - "declines without importing via %s", - async (action) => { - const { communities, mount, user } = fixture(); - const view = mount(); - await screen.findByRole("alertdialog"); - if (action === "Escape") await user.keyboard("{Escape}"); - else await user.click(screen.getByRole("button", { name: action })); - await waitFor(() => expect(localStorage.getItem(marker)).toBe("1")); - expect(communities.snapshot().memberships).toEqual([]); - expect(localStorage.getItem(record)).toBeNull(); - view.unmount(); - mount(); - await act(flush); - expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); - }, -); +it("remembers a declined import across mounts", async () => { + const { communities, mount, user } = fixture(); + const view = mount(); + await screen.findByRole("alertdialog"); + await user.click(screen.getByRole("button", { name: "Skip import" })); + await waitFor(() => expect(localStorage.getItem(marker)).toBe("1")); + expect(communities.snapshot().memberships).toEqual([]); + expect(localStorage.getItem(record)).toBeNull(); + view.unmount(); + mount(); + await act(flush); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); +}); it("does not persist a decline when a pending consent dialog unmounts", async () => { const { communities, mount } = fixture(); diff --git a/tests/browser/legacy-communities.spec.mjs b/tests/browser/legacy-communities.spec.mjs deleted file mode 100644 index 859cfd36ab..0000000000 --- a/tests/browser/legacy-communities.spec.mjs +++ /dev/null @@ -1,133 +0,0 @@ -import { test, expect } from "./playwright-test.mjs"; -import { createServer } from "./vite-server.mjs"; -import { fileURLToPath } from "node:url"; -import { watchPageErrors } from "./page-errors.mjs"; - -// Browser boundary: the real launch overlay must release inertness and focus -// before consent, and a declined import must stay declined across page reload. -// Eligibility and sync failure permutations live in the colocated Vitest tests. -test("native legacy import waits for consent after launch and remembers a decline", async ({ - page, -}) => { - const server = await createServer({ - root: fileURLToPath(new URL("../../", import.meta.url)), - configFile: false, - envFile: false, - define: { "import.meta.env.VITE_BUZZ_LIVE": '"0"' }, - server: { host: "127.0.0.1", port: 0 }, - }); - const errors = watchPageErrors(page); - await page.addInitScript(() => { - const viewer = "a".repeat(64); - window.isTauri = true; - window.legacyReads = 0; - Object.defineProperty(navigator, "platform", { value: "MacIntel" }); - let callback = 0; - window.__TAURI_INTERNALS__ = { - metadata: { - currentWindow: { label: "main" }, - currentWebview: { label: "main" }, - }, - transformCallback: () => ++callback, - unregisterCallback() {}, - invoke: async (command) => { - if (command === "identity_restore") return viewer; - if (command === "legacy_communities") { - window.legacyReads++; - return [{ relayUrl: "wss://old.example", pubkey: viewer }]; - } - if (command === "deep_link_take") return []; - if (command === "deep_link_watch" || command === "get_os_idle_seconds") - return null; - if (command === "app_agent_state_load") return {}; - if (command === "app_agent_state_set") return; - if (command === "keep_awake_set") return; - if (command === "plugin_catalog") - return { - status: "ready", - externalPluginsPaused: false, - catalog: { - profile: "fixture", - location: "isolated IPC", - plugins: [], - }, - }; - throw new Error(`Unexpected fixture native command: ${command}`); - }, - }; - window.fetch = () => { - throw new Error("No development broker"); - }; - }); - try { - await server.listen(); - await page.goto(`http://127.0.0.1:${server.httpServer.address().port}`); - const dialog = page.getByRole("alertdialog", { - name: "Import communities from old Buzz?", - }); - await expect(dialog).toBeVisible(); - await expect(page.locator("#buzz-launch")).toHaveCount(0); - expect( - await page.evaluate(() => - localStorage.getItem(`buzz-client.v1:${"a".repeat(64)}`), - ), - ).toBeNull(); - await expect(dialog.getByRole("list")).toHaveText("https://old.example"); - await page.screenshot({ - path: test.info().outputPath("consent-light.png"), - }); - await page.setViewportSize({ width: 390, height: 620 }); - await page.emulateMedia({ colorScheme: "dark" }); - await page.evaluate(() => { - localStorage.setItem("buzz-font-scale.v1", "1.5"); - window.dispatchEvent( - new StorageEvent("storage", { - key: "buzz-font-scale.v1", - storageArea: localStorage, - }), - ); - }); - await expect(page.locator("html")).toHaveAttribute( - "data-color-mode", - "dark", - ); - await expect(page.locator("html")).toHaveCSS("font-size", "24px"); - await dialog.getByRole("button", { name: "Skip import" }).focus(); - await page.keyboard.press("Tab"); - const approve = dialog.getByRole("button", { - name: "Import communities", - exact: true, - }); - await expect(approve).toBeFocused(); - await expect(approve).toBeInViewport({ ratio: 1 }); - await expect( - dialog.getByRole("button", { name: "Skip import" }), - ).toBeInViewport({ ratio: 1 }); - await page.screenshot({ - path: test.info().outputPath("consent-dark-narrow.png"), - }); - await dialog.getByRole("button", { name: "Skip import" }).focus(); - await page.keyboard.press("Enter"); - await expect(dialog).toHaveCount(0); - await expect( - page.getByRole("heading", { name: "Join a community" }), - ).toBeVisible(); - await expect - .poll(() => - page.evaluate(() => - localStorage.getItem(`buzz-legacy-communities.v1:${"a".repeat(64)}`), - ), - ) - .toBe("1"); - await page.reload(); - await expect( - page.getByRole("heading", { name: "Join a community" }), - ).toBeVisible(); - await expect(page.locator("#buzz-launch")).toHaveCount(0); - await expect(dialog).toHaveCount(0); - expect(await page.evaluate(() => window.legacyReads)).toBe(0); - expect(errors.unexplained()).toEqual([]); - } finally { - await server.close(); - } -}); From afe0beb906129a608b1eddfe3a001d2afbfa7321 Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Wed, 28 Oct 2026 11:15:03 -0700 Subject: [PATCH 10/10] test(communities): keep one startup import smoke test remove native fixtures, importer permutations, dialog tests and the startup test at the author's request. retain only consent gating and the one-time completion check without changing production behavior. Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: Kalvin Chau --- src-tauri/src/legacy_communities.rs | 134 ----- src/app/services.test.ts | 32 -- .../LegacyCommunityImport.test.tsx | 106 ---- .../communities/legacy-communities.test.ts | 492 ++---------------- 4 files changed, 43 insertions(+), 721 deletions(-) delete mode 100644 src/features/communities/LegacyCommunityImport.test.tsx diff --git a/src-tauri/src/legacy_communities.rs b/src-tauri/src/legacy_communities.rs index 36144dfdce..7ccf045d15 100644 --- a/src-tauri/src/legacy_communities.rs +++ b/src-tauri/src/legacy_communities.rs @@ -131,137 +131,3 @@ pub(crate) async fn legacy_communities() -> Result, String> { .await .map_err(|error| error.to_string())? } - -#[cfg(test)] -mod tests { - use super::*; - - fn utf16(value: &str) -> Vec { - value.encode_utf16().flat_map(u16::to_le_bytes).collect() - } - fn store(dir: &Path, value: Option>) { - std::fs::create_dir_all(dir).unwrap(); - let conn = Connection::open(dir.join("localstorage.sqlite3")).unwrap(); - conn.execute( - "CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB NOT NULL ON CONFLICT FAIL)", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO ItemTable VALUES ('buzz-active-community-id', ?1)", - [utf16("a")], - ) - .unwrap(); - if let Some(value) = value { - conn.execute( - "INSERT INTO ItemTable VALUES (?1, ?2)", - (COMMUNITIES_KEY, value), - ) - .unwrap(); - } - } - - #[test] - fn reads_each_saved_community_once_with_its_identity() { - let dir = tempfile::tempdir().unwrap(); - let list = r#"[{"id":"a","name":"Hive","relayUrl":"wss://hive.example.com","pubkey":"p","addedAt":"t"}, - {"id":"b","name":"Dev","relayUrl":"ws://localhost:3000","addedAt":"t"}, - {"id":"c","name":"Broken"}, - {"id":"d","name":"Hive again","relayUrl":"wss://hive.example.com","addedAt":"t"}]"#; - store( - &dir.path().join("Default/x/x/LocalStorage"), - Some(utf16(list)), - ); - let other = - r#"[{"relayUrl":"wss://other.example.com"},{"relayUrl":"wss://hive.example.com"}]"#; - store( - &dir.path().join("Default/y/y/LocalStorage"), - Some(other.as_bytes().to_vec()), - ); - let saved = |relay_url: &str, pubkey: Option<&str>| Saved { - relay_url: relay_url.into(), - pubkey: pubkey.map(Into::into), - }; - // Directory order is not fixed, so compare as a set. - let mut found = read_all(dir.path()).unwrap(); - found.sort_by(|a, b| (&a.relay_url, &a.pubkey).cmp(&(&b.relay_url, &b.pubkey))); - assert_eq!( - found, - [ - saved("ws://localhost:3000", None), - saved("wss://hive.example.com", None), - saved("wss://hive.example.com", Some("p")), - saved("wss://other.example.com", None), - ] - ); - } - - #[test] - fn missing_or_unusable_state_reads_as_none() { - let dir = tempfile::tempdir().unwrap(); - assert_eq!( - read_all(&dir.path().join("absent")).unwrap(), - Vec::::new() - ); - store(&dir.path().join("a"), None); - store(&dir.path().join("b"), Some(utf16("not json"))); - store( - &dir.path().join("c"), - Some(utf16(r#"{"relayUrl":"wss://x.example.com"}"#)), - ); - assert_eq!(read_all(dir.path()).unwrap(), Vec::::new()); - } - - #[test] - fn an_unreadable_database_fails_the_read() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("localstorage.sqlite3"), b"not a database").unwrap(); - assert!(read_all(dir.path()).is_err()); - assert!(read_all(&dir.path().join("localstorage.sqlite3")).is_err()); - } - - #[cfg(unix)] - #[test] - fn an_unreadable_directory_fails_the_read_and_retries_after_access_is_restored() { - use std::os::unix::fs::PermissionsExt; - - for nested in [false, true] { - let dir = tempfile::tempdir().unwrap(); - let storage = dir.path().join("Default/LocalStorage"); - store( - &storage, - Some(utf16( - r#"[{"relayUrl":"wss://hive.example.com","pubkey":"p"}]"#, - )), - ); - let blocked = if nested { &storage } else { dir.path() }; - let permissions = std::fs::metadata(blocked).unwrap().permissions(); - std::fs::set_permissions(blocked, std::fs::Permissions::from_mode(0o000)).unwrap(); - let result = read_all(dir.path()); - std::fs::set_permissions(blocked, permissions).unwrap(); - assert!(result.is_err(), "unreadable directory: {blocked:?}"); - assert_eq!( - read_all(dir.path()).unwrap(), - vec![Saved { - relay_url: "wss://hive.example.com".into(), - pubkey: Some("p".into()), - }] - ); - } - } - - #[cfg(unix)] - #[test] - fn skips_symlinked_directories_and_databases() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let outside = tempfile::tempdir().unwrap(); - let database = outside.path().join("localstorage.sqlite3"); - std::fs::write(&database, b"not a database").unwrap(); - symlink(outside.path(), dir.path().join("linked-directory")).unwrap(); - symlink(database, dir.path().join("localstorage.sqlite3")).unwrap(); - symlink(dir.path(), dir.path().join("loop")).unwrap(); - assert_eq!(read_all(dir.path()).unwrap(), Vec::::new()); - } -} diff --git a/src/app/services.test.ts b/src/app/services.test.ts index 19244efb64..41c68159da 100644 --- a/src/app/services.test.ts +++ b/src/app/services.test.ts @@ -540,38 +540,6 @@ it("seeds a packaged native community after onboarding and preserves the user's expect(fetch).not.toHaveBeenCalled(); }); -it("does not import old Buzz's communities at native startup without consent", async () => { - await services.dispose(); - vi.stubEnv("VITE_BUZZ_LIVE", "0"); - vi.mocked(isTauri).mockReturnValue(true); - vi.mocked(fetch).mockClear(); - const legacy = vi.fn(async () => [ - { relayUrl: "wss://old.example", pubkey: viewer }, - { relayUrl: "wss://unattributed.example" }, - ]); - vi.mocked(invoke).mockImplementation(async (command) => { - if (command === "identity_restore") return viewer; - if (command === "deep_link_take") return []; - if (command === "deep_link_watch") return null; - if (command === "legacy_communities") return legacy(); - throw new Error("Fixture native capability unavailable"); - }); - services = createServices(); - await vi.advanceTimersByTimeAsync(0); - expect(services.communities.snapshot()).toMatchObject({ - selected: null, - memberships: [], - }); - expect(values.has(`buzz-legacy-communities.v1:${viewer}`)).toBe(false); - // Restart does not infer consent from the still-empty device record. - await services.dispose(); - services = createServices(); - await vi.advanceTimersByTimeAsync(0); - expect(services.communities.snapshot().memberships).toHaveLength(0); - expect(legacy).not.toHaveBeenCalled(); - expect(fetch).not.toHaveBeenCalled(); -}); - it("keeps pinned macOS desktop development on the broker identity", async () => { await services.dispose(); vi.mocked(isTauri).mockReturnValue(true); diff --git a/src/features/communities/LegacyCommunityImport.test.tsx b/src/features/communities/LegacyCommunityImport.test.tsx deleted file mode 100644 index 1e1ce291c3..0000000000 --- a/src/features/communities/LegacyCommunityImport.test.tsx +++ /dev/null @@ -1,106 +0,0 @@ -// @vitest-environment jsdom -import "@testing-library/jest-dom/vitest"; -import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { Context } from "@deepseek-ai/cordis"; -import { StrictMode } from "react"; -import { afterEach, expect, it, vi } from "vitest"; -import { invoke } from "@tauri-apps/api/core"; -import { createCommunities } from "./service"; -import { LegacyCommunityImport } from "./LegacyCommunityImport"; -import { flush } from "../relay/testing"; - -vi.mock("@tauri-apps/api/core", async (importOriginal) => ({ - ...(await importOriginal()), - invoke: vi.fn(), -})); -const viewer = "a".repeat(64); -const marker = `buzz-legacy-communities.v1:${viewer}`; -const record = `buzz-client.v1:${viewer}`; -const roots: Context[] = []; -afterEach(async () => { - cleanup(); - for (const root of roots.splice(0)) await root.fiber.dispose(); - localStorage.clear(); - vi.mocked(invoke).mockReset(); -}); - -function fixture() { - vi.mocked(invoke).mockResolvedValue([ - { relayUrl: "wss://old.example", pubkey: viewer }, - ]); - const root = new Context(); - roots.push(root); - const communities = createCommunities( - root, - false, - undefined, - "", - undefined, - Promise.resolve(viewer), - ); - const mount = () => - render( - - - , - ); - return { communities, mount, user: userEvent.setup() }; -} - -it("imports only after the owner approves the listed communities", async () => { - const { communities, mount, user } = fixture(); - const view = mount(); - await screen.findByRole("alertdialog", { - name: "Import communities from old Buzz?", - }); - expect( - screen.getByRole("list", { name: "Communities to import" }), - ).toHaveTextContent("https://old.example"); - expect( - screen.getByText(/may restore communities you previously removed/), - ).toBeVisible(); - expect(communities.snapshot().memberships).toEqual([]); - expect(localStorage.getItem(record)).toBeNull(); - expect(localStorage.getItem(marker)).toBeNull(); - await user.click(screen.getByRole("button", { name: "Import communities" })); - await waitFor(() => expect(localStorage.getItem(marker)).toBe("1")); - expect(communities.snapshot().memberships).toEqual([ - { id: "https://old.example", name: "old.example" }, - ]); - expect(communities.snapshot().sync.outbox).toEqual([ - { url: "wss://old.example", removed: false }, - ]); - const calls = vi.mocked(invoke).mock.calls.length; - view.unmount(); - mount(); - await act(flush); - expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); - expect(invoke).toHaveBeenCalledTimes(calls); -}); - -it("remembers a declined import across mounts", async () => { - const { communities, mount, user } = fixture(); - const view = mount(); - await screen.findByRole("alertdialog"); - await user.click(screen.getByRole("button", { name: "Skip import" })); - await waitFor(() => expect(localStorage.getItem(marker)).toBe("1")); - expect(communities.snapshot().memberships).toEqual([]); - expect(localStorage.getItem(record)).toBeNull(); - view.unmount(); - mount(); - await act(flush); - expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); -}); - -it("does not persist a decline when a pending consent dialog unmounts", async () => { - const { communities, mount } = fixture(); - const view = mount(); - await screen.findByRole("alertdialog"); - view.unmount(); - await act(flush); - expect(communities.snapshot().memberships).toEqual([]); - expect(localStorage.getItem(marker)).toBeNull(); - mount(); - expect(await screen.findByRole("alertdialog")).toBeVisible(); -}); diff --git a/src/features/communities/legacy-communities.test.ts b/src/features/communities/legacy-communities.test.ts index aa9dd3fa21..183a270d9f 100644 --- a/src/features/communities/legacy-communities.test.ts +++ b/src/features/communities/legacy-communities.test.ts @@ -1,462 +1,56 @@ import { Context } from "@deepseek-ai/cordis"; -import { afterEach, assert, expect, it, vi } from "vitest"; +import { expect, it, vi } from "vitest"; import { createCommunities } from "./service"; -import { - importLegacyCommunities, - type LegacyCommunity, -} from "./legacy-communities"; -import { acknowledge, enqueue } from "./known-communities"; -import { createOAuthSession } from "../../bundled/builderlab/oauth/session"; -import { startKnownCommunitiesSync } from "../../bundled/builderlab/known-communities/sync"; -import type { ListResult } from "../../bundled/builderlab/known-communities/client"; +import { importLegacyCommunities } from "./legacy-communities"; import { flush } from "../relay/testing"; -const viewer = "a".repeat(64); -const other = "b".repeat(64); -const record = `buzz-client.v1:${viewer}`; -const marker = `buzz-legacy-communities.v1:${viewer}`; -const roots: Context[] = []; -const approve = async () => true; -// Old Buzz's list as its reader returns it, in saved order: the viewer's -// communities, one saved before old Buzz recorded an identity, and one saved -// for another identity. -const legacy: LegacyCommunity[] = [ - { relayUrl: "wss://hive.example.com", pubkey: viewer }, - { relayUrl: "wss://hive.example.com/", pubkey: viewer }, - { relayUrl: "ws://localhost:3000", pubkey: viewer }, - { relayUrl: "wss://removed.example.com", pubkey: viewer }, - { relayUrl: "wss://kept.example.com", pubkey: viewer }, - { relayUrl: "not a url", pubkey: viewer }, - { relayUrl: "wss://unreachable.example.com", pubkey: viewer }, - { relayUrl: "wss://unattributed.example.com" }, - { relayUrl: "wss://theirs.example.com", pubkey: other }, -]; -function deferred() { - let resolve!: (value: T) => void; - let reject!: (reason: unknown) => void; - const promise = new Promise((done, fail) => { - resolve = done; - reject = fail; - }); - return { promise, resolve, reject }; -} - -function setup(saved?: unknown, failSave = false) { +it("imports once and only after consent", async () => { + const viewer = "a".repeat(64); const storage = new Map(); - if (saved !== undefined) storage.set(record, JSON.stringify(saved)); vi.stubGlobal("localStorage", { getItem: (key: string) => storage.get(key) ?? null, - setItem: (key: string, value: string) => { - if (failSave) throw new Error("Full disk"); - storage.set(key, value); - }, - removeItem: (key: string) => storage.delete(key), - }); - const connect = vi.fn((id: string) => - Promise.reject(new Error(`${id} is unreachable`)), + setItem: (key: string, value: string) => storage.set(key, value), + }); + const root = new Context(); + const communities = createCommunities( + root, + false, + undefined, + "", + undefined, + Promise.resolve(viewer), ); - const start = () => { - const ctx = new Context(); - roots.push(ctx); - return createCommunities( - ctx, - false, - undefined, - "", - undefined, - Promise.resolve(viewer), - connect, - ); - }; - return { storage, connect, start }; -} -const saved = (storage: Map) => - JSON.parse(storage.get(record) ?? "null"); -afterEach(async () => { - for (const root of roots.splice(0)) await root.fiber.dispose(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - -it("requires consent after a removal was acknowledged before migration", async () => { - const { storage, start } = setup({ - sync: { - known: ["wss://hive.example.com"], - outbox: [{ url: "wss://hive.example.com", removed: true }], - }, + let answer!: (approved: boolean) => void; + const pending = new Promise((resolve) => { + answer = resolve; }); - const client = start(); - await flush(); - const [op] = client.snapshot().sync.outbox; - assert.exists(op); - await client.applySync(acknowledge(client.snapshot().sync, op)); - expect(client.snapshot().sync).toEqual({ known: [], outbox: [] }); - const before = storage.get(record); - const answer = deferred(); - const consent = vi.fn(() => answer.promise); + const confirm = vi.fn(() => pending); const read = vi.fn(async () => [ - { relayUrl: "wss://hive.example.com", pubkey: viewer }, + { relayUrl: "wss://old.example", pubkey: viewer }, ]); - const dispose = importLegacyCommunities(client, consent, read); - await flush(); - expect(consent).toHaveBeenCalledWith(["https://hive.example.com"]); - expect(storage.get(record)).toBe(before); - expect(storage.has(marker)).toBe(false); - answer.resolve(false); - await flush(); - expect(storage.get(record)).toBe(before); - expect(storage.get(marker)).toBe("1"); - dispose(); - importLegacyCommunities(start(), consent, read); - await flush(); - expect(read).toHaveBeenCalledTimes(1); -}); - -it.each(["pending", "failed", "empty"])( - "does not treat %s account sync as proof that an empty device is untouched", - async (syncState) => { - const { storage, start } = setup(); - const client = start(); + const dispose = importLegacyCommunities(communities, confirm, read); + try { await flush(); - const list = deferred(); - const remote = { - list: vi.fn(() => list.promise), - add: vi.fn(async () => ({ kind: "accepted" as const })), - remove: vi.fn(async () => ({ kind: "accepted" as const })), - }; - const session = createOAuthSession(async () => ({ - value: "fixture", - account: { - subject: "account", - email: "fixture@example.com", - canUseRemoteAgents: false, - }, - })); - const stop = startKnownCommunitiesSync({ - client: remote, - session, - knownCommunities: { - snapshot: client.snapshot, - subscribe: client.subscribe, - pending: client.pendingSync, - apply: client.applySync, - }, - }); - const answer = deferred(); - let dispose = () => {}; - try { - await session.signIn(); - expect(remote.list).toHaveBeenCalledTimes(1); - if (syncState === "failed") list.reject(new Error("Account offline")); - if (syncState === "empty") - list.resolve({ kind: "listed", communities: [] }); - await flush(); - const consent = vi.fn(() => answer.promise); - dispose = importLegacyCommunities(client, consent, async () => legacy); - await flush(); - expect(consent).toHaveBeenCalledTimes(1); - expect(client.snapshot().memberships).toEqual([]); - expect(client.snapshot().sync.outbox).toEqual([]); - expect(remote.add).not.toHaveBeenCalled(); - expect(storage.has(marker)).toBe(false); - answer.resolve(false); - await flush(); - expect(storage.get(marker)).toBe("1"); - expect(remote.add).not.toHaveBeenCalled(); - } finally { - dispose(); - stop(); - session.dispose(); - list.resolve({ kind: "listed", communities: [] }); - answer.resolve(false); - } - }, -); - -it("uses current membership and sync state after consent, preserving concurrent edits", async () => { - const { storage, start } = setup(); - const client = start(); - const answer = deferred(); - importLegacyCommunities( - client, - () => answer.promise, - async () => [{ relayUrl: "wss://hive.example.com", pubkey: viewer }], - ); - await flush(); - await client.applySync( - enqueue(client.snapshot().sync, "wss://manual.example.com", false), - ["https://manual.example.com"], - ); - answer.resolve(true); - await flush(); - expect(client.snapshot().memberships.map((entry) => entry.id)).toEqual([ - "https://manual.example.com", - "https://hive.example.com", - ]); - expect(saved(storage).sync.outbox).toEqual([ - { url: "wss://manual.example.com", removed: false }, - { url: "wss://hive.example.com", removed: false }, - ]); -}); - -it("adds old Buzz's relays once beside existing settings, keeping queued removals", async () => { - const { storage, connect, start } = setup({ - profile: { name: "Ada", picture: "", about: "" }, - memberships: [{ id: "https://kept.example.com", name: "Mine" }], - selected: "https://kept.example.com", - sync: { - known: [], - outbox: [{ url: "wss://removed.example.com", removed: true }], - }, - }); - const client = start(); - const read = vi.fn(async () => legacy); - importLegacyCommunities(client, approve, read); - await flush(); - expect(read).toHaveBeenCalledTimes(1); - expect(client.snapshot()).toMatchObject({ - profile: { name: "Ada" }, - selected: "https://kept.example.com", - memberships: [ - { id: "https://kept.example.com", name: "Mine" }, - { id: "https://hive.example.com", name: "hive.example.com" }, - { - id: "https://unreachable.example.com", - name: "unreachable.example.com", - }, - ], - }); - expect(saved(storage).sync.outbox).toEqual([ - { url: "wss://removed.example.com", removed: true }, - { url: "wss://hive.example.com", removed: false }, - { url: "wss://unreachable.example.com", removed: false }, - ]); - expect(storage.get(marker)).toBe("1"); - // Only the selected community connects; the import opens no session. - expect(connect.mock.calls.map(([id]) => id)).toEqual([ - "https://kept.example.com", - ]); - - // An unreachable relay fails only its own session. - const unreachable = client.open("https://unreachable.example.com"); - await flush(); - expect(unreachable?.snapshot().status).not.toBe("ready"); - expect(client.snapshot().memberships).toHaveLength(3); - - // Restart after leaving a migrated community: the marker keeps it gone. - await client.leave("https://hive.example.com"); - const restarted = start(); - importLegacyCommunities(restarted, approve, read); - await flush(); - expect(read).toHaveBeenCalledTimes(1); - expect(restarted.snapshot().memberships.map((m) => m.id)).toEqual([ - "https://kept.example.com", - "https://unreachable.example.com", - ]); -}); - -it("marks a viewer with no old Buzz state without changing its record", async () => { - const { storage, start } = setup(); - const client = start(); - const consent = vi.fn(approve); - importLegacyCommunities(client, consent, async () => []); - await flush(); - expect(client.snapshot()).toMatchObject({ status: "ready", memberships: [] }); - expect(storage.has(record)).toBe(false); - expect(storage.get(marker)).toBe("1"); - expect(consent).not.toHaveBeenCalled(); -}); - -it("retries on the next launch when old Buzz's list could not be read or saved", async () => { - const unreadable = setup(); - const read = vi - .fn<() => Promise>() - .mockRejectedValueOnce("Could not read old Buzz's communities") - .mockResolvedValue(legacy); - const dispose = importLegacyCommunities(unreadable.start(), approve, read); - await flush(); - expect(unreadable.storage.has(marker)).toBe(false); - expect(unreadable.storage.has(record)).toBe(false); - dispose(); - const restarted = unreadable.start(); - importLegacyCommunities(restarted, approve, read); - await flush(); - expect(read).toHaveBeenCalledTimes(2); - expect(restarted.snapshot().memberships).toHaveLength(4); - expect(unreadable.storage.get(marker)).toBe("1"); - - const full = setup(undefined, true); - const client = full.start(); - importLegacyCommunities(client, approve, async () => legacy); - await flush(); - expect(client.snapshot().memberships).toEqual([]); - expect(full.storage.has(marker)).toBe(false); -}); - -it("imports nothing for an identity old Buzz did not save them for", async () => { - const { storage, start } = setup(); - const client = start(); - const consent = vi.fn(approve); - importLegacyCommunities(client, consent, async () => [ - { relayUrl: "wss://theirs.example.com", pubkey: other }, - { relayUrl: "wss://unattributed.example.com" }, - ]); - await flush(); - expect(client.snapshot().memberships).toEqual([]); - expect(storage.has(record)).toBe(false); - expect(storage.get(marker)).toBe("1"); - expect(consent).not.toHaveBeenCalled(); -}); - -it("keeps a community left and acknowledged while the read was pending gone", async () => { - const { storage, start } = setup({ - profile: { name: "", picture: "", about: "" }, - memberships: [{ id: "https://hive.example.com", name: "Hive" }], - selected: null, - sync: { known: ["wss://hive.example.com"], outbox: [] }, - }); - const client = start(); - const read = deferred(); - importLegacyCommunities(client, approve, () => read.promise); - await flush(); - await client.leave("https://hive.example.com"); - const [op] = client.snapshot().sync.outbox; - assert.exists(op); - await client.applySync(acknowledge(client.snapshot().sync, op)); - expect(client.snapshot().sync.outbox).toEqual([]); - read.resolve(legacy); - await flush(); - expect(client.snapshot().memberships.map((m) => m.id)).toEqual([ - "https://removed.example.com", - "https://kept.example.com", - "https://unreachable.example.com", - ]); - expect(saved(storage).sync.outbox).not.toContainEqual({ - url: "wss://hive.example.com", - removed: false, - }); -}); - -it("keeps a queued removal acknowledged while the read was pending gone", async () => { - const { storage, start } = setup({ - profile: { name: "", picture: "", about: "" }, - memberships: [], - selected: null, - sync: { - known: ["wss://removed.example.com"], - outbox: [{ url: "wss://removed.example.com", removed: true }], - }, - }); - const client = start(); - const read = deferred(); - importLegacyCommunities(client, approve, () => read.promise); - await flush(); - const [op] = client.snapshot().sync.outbox; - assert.exists(op); - await client.applySync(acknowledge(client.snapshot().sync, op)); - expect(client.snapshot().sync.outbox).toEqual([]); - read.resolve([{ relayUrl: "wss://removed.example.com", pubkey: viewer }]); - await flush(); - expect(client.snapshot().memberships).toEqual([]); - expect(saved(storage).sync.outbox).toEqual([]); - expect(storage.get(marker)).toBe("1"); -}); - -it("keeps a removal acknowledged after a failed import gone on the next launch", async () => { - const { storage, start } = setup({ - profile: { name: "", picture: "", about: "" }, - memberships: [], - selected: null, - sync: { - known: ["wss://removed.example.com"], - outbox: [{ url: "wss://removed.example.com", removed: true }], - }, - }); - const client = start(); - importLegacyCommunities(client, approve, async () => { - throw new Error("Could not read old Buzz's communities"); - }); - await flush(); - const [op] = client.snapshot().sync.outbox; - assert.exists(op); - await client.applySync(acknowledge(client.snapshot().sync, op)); - expect(saved(storage).sync.outbox).toEqual([]); - expect(JSON.parse(storage.get(marker) ?? "null")).toEqual([ - "wss://removed.example.com", - ]); - - const restarted = start(); - importLegacyCommunities(restarted, approve, async () => [ - { relayUrl: "wss://removed.example.com", pubkey: viewer }, - { relayUrl: "wss://hive.example.com", pubkey: viewer }, - ]); - await flush(); - expect(restarted.snapshot().memberships.map((m) => m.id)).toEqual([ - "https://hive.example.com", - ]); - expect(saved(storage).sync.outbox).toEqual([ - { url: "wss://hive.example.com", removed: false }, - ]); - expect(storage.get(marker)).toBe("1"); -}); - -it("saves a removal whose first progress write failed once storage recovers", async () => { - const { storage, start } = setup({ - profile: { name: "", picture: "", about: "" }, - memberships: [], - selected: null, - sync: { - known: ["wss://removed.example.com"], - outbox: [{ url: "wss://removed.example.com", removed: true }], - }, - }); - const setItem = localStorage.setItem; - let failing = true; - vi.spyOn(localStorage, "setItem").mockImplementation((key, value) => { - if (failing && key === marker) throw new Error("Full disk"); - setItem(key, value); - }); - const client = start(); - const read = deferred(); - const dispose = importLegacyCommunities(client, approve, () => read.promise); - await flush(); - expect(storage.has(marker)).toBe(false); - failing = false; - const [op] = client.snapshot().sync.outbox; - assert.exists(op); - await client.applySync(acknowledge(client.snapshot().sync, op)); - expect(saved(storage).sync.outbox).toEqual([]); - expect(JSON.parse(storage.get(marker) ?? "null")).toEqual([ - "wss://removed.example.com", - ]); - dispose(); - - const restarted = start(); - importLegacyCommunities(restarted, approve, async () => [ - { relayUrl: "wss://removed.example.com", pubkey: viewer }, - { relayUrl: "wss://hive.example.com", pubkey: viewer }, - ]); - await flush(); - expect(restarted.snapshot().memberships.map((m) => m.id)).toEqual([ - "https://hive.example.com", - ]); - expect(storage.get(marker)).toBe("1"); -}); - -it("abandons a pending read once disposed, leaving a replacement's settings", async () => { - const { storage, start } = setup(); - const read = deferred(); - const dispose = importLegacyCommunities(start(), approve, () => read.promise); - await flush(); - dispose(); - const replacement = start(); - await flush(); - replacement.saveProfile({ name: "Ada", picture: "", about: "" }); - read.resolve(legacy); - await flush(); - expect(saved(storage)).toMatchObject({ - profile: { name: "Ada" }, - memberships: [], - }); - expect(storage.has(marker)).toBe(false); + expect(confirm).toHaveBeenCalledWith(["https://old.example"]); + expect(communities.snapshot().memberships).toEqual([]); + expect(storage.size).toBe(0); + answer(true); + await flush(); + expect(communities.snapshot().memberships).toEqual([ + { id: "https://old.example", name: "old.example" }, + ]); + expect(communities.snapshot().sync.outbox).toEqual([ + { url: "wss://old.example", removed: false }, + ]); + expect(storage.get(`buzz-legacy-communities.v1:${viewer}`)).toBe("1"); + dispose(); + importLegacyCommunities(communities, confirm, read)(); + await flush(); + expect(read).toHaveBeenCalledTimes(1); + } finally { + dispose(); + answer(false); + await root.fiber.dispose(); + vi.unstubAllGlobals(); + } });