test(desktop): harden observer archive policy regression coverage (#1994)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-17 12:10:44 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 2310b03f9d
commit fd2eaac73b
7 changed files with 610 additions and 174 deletions
@@ -1,71 +0,0 @@
/**
* Persists whether the user has made an explicit choice about the
* observer-feed archive default-on feature.
*
* The key is identity-scoped so toggling off on one identity doesn't suppress
* the default-on for another identity. The value is:
* "1" → user explicitly enabled (or accepted the default)
* "0" → user explicitly disabled
* null → no explicit choice yet (default-on seeding may still fire)
*
* Device-level localStorage — intentionally not reset on community switch
* (the archive subscription itself is identity-scoped in SQLite; this flag
* is just the UI gate that prevents re-seeding after an explicit opt-out).
*/
const KEY_PREFIX = "buzz:observer-archive-default-seeded";
function storageKey(identityPubkey: string): string {
return `${KEY_PREFIX}:${identityPubkey}`;
}
/**
* Returns `true` if the user has already made an explicit choice for this
* identity (either opted in or opted out). When `false`, the seeding path
* may fire.
*/
export function hasExplicitObserverArchiveChoice(
identityPubkey: string,
): boolean {
if (typeof window === "undefined") return true; // SSR/test: treat as set
try {
return window.localStorage.getItem(storageKey(identityPubkey)) !== null;
} catch {
return true; // storage error → treat as set, never auto-seed
}
}
/**
* Mark that the user has made an explicit choice for this identity.
* `enabled` should reflect whether the `owner_p` subscription exists after
* the action (true = seeded/enabled, false = opted out).
*/
export function setExplicitObserverArchiveChoice(
identityPubkey: string,
enabled: boolean,
): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
storageKey(identityPubkey),
enabled ? "1" : "0",
);
} catch {
// Best-effort — the seeding guard will re-fire on next startup if storage
// is unavailable, but that is safe (create_save_subscription is idempotent).
}
}
/**
* Clear the explicit choice for this identity (for testing / reset flows).
*/
export function clearExplicitObserverArchiveChoice(
identityPubkey: string,
): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(storageKey(identityPubkey));
} catch {
// ignore
}
}
@@ -26,7 +26,6 @@ import {
} from "@/features/settings/ui/SettingsOptionGroup";
import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader";
import { observerArchiveDefaultEnabled } from "@/shared/api/tauriArchive";
import { setExplicitObserverArchiveChoice } from "../observerArchivePreference";
import { setExplicitAgentMetricArchiveChoice } from "../agentMetricArchivePreference";
import {
@@ -474,7 +473,6 @@ export function LocalArchiveSettingsCard() {
} else {
await removeSaveSubscriptionKind(KIND_AGENT_OBSERVER_FRAME);
}
setExplicitObserverArchiveChoice(pubkey, checked);
toast.success(
checked
? "Observer feed archive enabled."
@@ -4,6 +4,7 @@ import test from "node:test";
import {
isReconciledFor,
reconcileObserverArchive,
startReconciliation,
} from "./useObserverArchiveSeed.ts";
import { ArchiveSyncManager } from "./archiveSyncManager.ts";
@@ -14,7 +15,7 @@ function makeDeps({
mergeShouldFail = false,
flagShouldFail = false,
} = {}) {
const calls = { merge: [], setChoice: [] };
const calls = { merge: [] };
return {
calls,
@@ -26,9 +27,6 @@ function makeDeps({
if (mergeShouldFail) throw new Error("merge failed");
calls.merge.push({ kind });
},
setExplicitChoice: (pubkey, enabled) => {
calls.setChoice.push({ pubkey, enabled });
},
};
}
@@ -39,79 +37,40 @@ function tick() {
// ── Internal policy build ────────────────────────────────────────────────────
test("test_internal_policy_marker_null_seeds_24200", async () => {
test("test_internal_policy_seeds_24200", async () => {
const deps = makeDeps({ policyOn: true });
await reconcileObserverArchive("pk1", deps);
await reconcileObserverArchive(deps);
assert.equal(deps.calls.merge.length, 1);
assert.equal(deps.calls.merge[0].kind, 24200);
assert.deepEqual(deps.calls.setChoice, [{ pubkey: "pk1", enabled: true }]);
});
test("test_internal_policy_marker_0_still_seeds_24200", async () => {
const deps = makeDeps({ policyOn: true });
await reconcileObserverArchive("pk1", deps);
assert.equal(deps.calls.merge.length, 1, "must merge even with stale marker");
assert.equal(deps.calls.merge[0].kind, 24200);
});
test("test_internal_policy_marker_1_still_seeds_24200", async () => {
const deps = makeDeps({ policyOn: true });
await reconcileObserverArchive("pk1", deps);
assert.equal(deps.calls.merge.length, 1, "must reconcile even with marker 1");
});
// ── OSS build — policy-off is a pure no-op ──────────────────────────────────
test("test_oss_marker_null_no_merge", async () => {
test("test_oss_policy_off_no_merge", async () => {
const deps = makeDeps({ policyOn: false });
await reconcileObserverArchive("pk1", deps);
await reconcileObserverArchive(deps);
assert.equal(deps.calls.merge.length, 0, "OSS must not merge");
assert.equal(deps.calls.setChoice.length, 0, "OSS must not write marker");
});
test("test_oss_marker_0_no_merge", async () => {
const deps = makeDeps({ policyOn: false });
await reconcileObserverArchive("pk1", deps);
assert.equal(deps.calls.merge.length, 0, "OSS must not merge");
assert.equal(deps.calls.setChoice.length, 0, "OSS must not write marker");
});
test("test_oss_marker_1_no_merge", async () => {
const deps = makeDeps({ policyOn: false });
await reconcileObserverArchive("pk1", deps);
assert.equal(deps.calls.merge.length, 0, "OSS must not merge");
assert.equal(deps.calls.setChoice.length, 0, "OSS must not write marker");
});
// ── Failure behavior ─────────────────────────────────────────────────────────
test("test_merge_failure_rejects_no_marker_persisted", async () => {
test("test_merge_failure_rejects", async () => {
const deps = makeDeps({ policyOn: true, mergeShouldFail: true });
await assert.rejects(() => reconcileObserverArchive("pk1", deps), {
await assert.rejects(() => reconcileObserverArchive(deps), {
message: "merge failed",
});
assert.equal(
deps.calls.setChoice.length,
0,
"must not persist marker on merge failure",
);
});
test("test_flag_check_failure_rejects", async () => {
const deps = makeDeps({ flagShouldFail: true });
await assert.rejects(() => reconcileObserverArchive("pk1", deps), {
await assert.rejects(() => reconcileObserverArchive(deps), {
message: "flag check failed",
});
assert.equal(deps.calls.merge.length, 0);
assert.equal(deps.calls.setChoice.length, 0);
});
// ── Startup ordering (real ArchiveSyncManager + real reconciler) ─────────────
@@ -133,7 +92,6 @@ test("test_archive_sync_blocked_until_reconciliation", async () => {
const reconcilerDeps = {
observerArchiveDefaultEnabled: () => flagPromise,
mergeSaveSubscriptionKinds: async () => {},
setExplicitChoice: () => {},
};
const manager = new ArchiveSyncManager({
@@ -153,7 +111,7 @@ test("test_archive_sync_blocked_until_reconciliation", async () => {
});
// Start reconciliation (pending — flag check not yet resolved).
const reconciling = reconcileObserverArchive("pk1", reconcilerDeps);
const reconciling = reconcileObserverArchive(reconcilerDeps);
// Before reconciliation resolves, manager must not have been started.
await tick();
@@ -210,7 +168,7 @@ test("test_archive_sync_blocked_on_reconciliation_rejection", async () => {
// Reconciliation rejects — gate must remain closed.
let rejected = false;
try {
await reconcileObserverArchive("pk1", reconcilerDeps);
await reconcileObserverArchive(reconcilerDeps);
} catch {
rejected = true;
}
@@ -250,7 +208,7 @@ test("test_identity_change_resets_readiness", async () => {
// Identity A reconciles successfully.
const depsA = makeDeps({ policyOn: true });
await reconcileObserverArchive("pkA", depsA);
await reconcileObserverArchive(depsA);
reconciledPubkey = "pkA";
assert.equal(
isReconciledFor(reconciledPubkey, "pkA"),
@@ -267,7 +225,7 @@ test("test_identity_change_resets_readiness", async () => {
// B reconciles successfully.
const depsB = makeDeps({ policyOn: true });
await reconcileObserverArchive("pkB", depsB);
await reconcileObserverArchive(depsB);
reconciledPubkey = "pkB";
assert.equal(
isReconciledFor(reconciledPubkey, "pkB"),
@@ -286,13 +244,13 @@ test("test_identity_change_b_failure_stays_closed", async () => {
// Identity A reconciles successfully.
const depsA = makeDeps({ policyOn: true });
await reconcileObserverArchive("pkA", depsA);
await reconcileObserverArchive(depsA);
reconciledPubkey = "pkA";
// Identity changes to B — B's reconciliation fails.
const depsB = makeDeps({ policyOn: true, mergeShouldFail: true });
try {
await reconcileObserverArchive("pkB", depsB);
await reconcileObserverArchive(depsB);
reconciledPubkey = "pkB";
} catch {
// B failed — reconciledPubkey stays "pkA" (stale).
@@ -306,11 +264,98 @@ test("test_identity_change_b_failure_stays_closed", async () => {
);
});
// ── startReconciliation lifecycle (cancellation guard) ──────────────────────
//
// These exercise the actual effect/cleanup code path extracted into
// `startReconciliation`, rather than only the pure `isReconciledFor` helper
// or manually-sequenced fakes. Mirrors what React calls on unmount / before
// re-running an effect with new deps (identity switch).
test("test_startReconciliation_calls_onReady_after_success", async () => {
const deps = makeDeps({ policyOn: true });
const readyCalls = [];
startReconciliation("pk1", deps, (pubkey) => readyCalls.push(pubkey));
await tick();
assert.deepEqual(readyCalls, ["pk1"]);
assert.equal(deps.calls.merge.length, 1);
});
test("test_startReconciliation_unmount_before_resolve_suppresses_onReady", async () => {
let resolveFlag;
const flagPromise = new Promise((resolve) => {
resolveFlag = resolve;
});
const deps = {
observerArchiveDefaultEnabled: () => flagPromise,
mergeSaveSubscriptionKinds: async () => {},
};
const readyCalls = [];
const cancel = startReconciliation("pk1", deps, (pubkey) =>
readyCalls.push(pubkey),
);
// Unmount (or re-run effect) before the flag check resolves.
cancel();
resolveFlag(true);
await tick();
assert.deepEqual(
readyCalls,
[],
"onReady must not fire for a cancelled reconciliation",
);
});
test("test_startReconciliation_identity_switch_stale_completion_suppressed", async () => {
let resolveFlagA;
const flagPromiseA = new Promise((resolve) => {
resolveFlagA = resolve;
});
const depsA = {
observerArchiveDefaultEnabled: () => flagPromiseA,
mergeSaveSubscriptionKinds: async () => {},
};
const depsB = makeDeps({ policyOn: true });
const readyCalls = [];
const onReady = (pubkey) => readyCalls.push(pubkey);
// Start reconciling for pkA (pending), then switch identity to pkB before
// A resolves — this is exactly what the hook's effect does when `pubkey`
// changes: it calls the previous effect's cleanup (cancelA) before
// starting the new effect.
const cancelA = startReconciliation("pkA", depsA, onReady);
cancelA();
startReconciliation("pkB", depsB, onReady);
// A's flag check now resolves late — its stale completion must not fire.
resolveFlagA(true);
await tick();
assert.deepEqual(
readyCalls,
["pkB"],
"only the current identity's completion should fire",
);
});
test("test_startReconciliation_failure_does_not_call_onReady", async () => {
const deps = makeDeps({ policyOn: true, mergeShouldFail: true });
const readyCalls = [];
startReconciliation("pk1", deps, (pubkey) => readyCalls.push(pubkey));
await tick();
assert.deepEqual(readyCalls, [], "onReady must not fire on failure");
});
// ── Metric seed independence ─────────────────────────────────────────────────
test("test_metric_seed_remains_independently_deferrable", async () => {
const deps = makeDeps({ policyOn: true });
await reconcileObserverArchive("pk1", deps);
await reconcileObserverArchive(deps);
assert.equal(deps.calls.merge.length, 1);
assert.equal(deps.calls.merge[0].kind, 24200, "must only touch kind 24200");
@@ -5,25 +5,22 @@ import {
mergeSaveSubscriptionKinds,
observerArchiveDefaultEnabled,
} from "@/shared/api/tauriArchive";
import { setExplicitObserverArchiveChoice } from "./observerArchivePreference";
export interface ObserverArchiveSeedDeps {
observerArchiveDefaultEnabled: () => Promise<boolean>;
mergeSaveSubscriptionKinds: (kind: number) => Promise<void>;
setExplicitChoice: (pubkey: string, enabled: boolean) => void;
}
const defaultDeps: ObserverArchiveSeedDeps = {
observerArchiveDefaultEnabled,
mergeSaveSubscriptionKinds,
setExplicitChoice: setExplicitObserverArchiveChoice,
};
/**
* Reconcile observer-feed archive state for `pubkey`.
* Reconcile observer-feed archive state for the current identity.
*
* Internal builds (policy flag ON): unconditionally ensure kind 24200 exists
* in the DB subscription, regardless of localStorage marker state.
* in the DB subscription.
*
* OSS builds (policy flag OFF): no-op. The Settings toggle is the only
* mutation path for OSS users.
@@ -32,14 +29,12 @@ const defaultDeps: ObserverArchiveSeedDeps = {
* unreconciled state.
*/
export async function reconcileObserverArchive(
pubkey: string,
deps: ObserverArchiveSeedDeps = defaultDeps,
): Promise<void> {
const policyOn = await deps.observerArchiveDefaultEnabled();
if (!policyOn) return;
await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME);
deps.setExplicitChoice(pubkey, true);
}
/**
@@ -57,6 +52,40 @@ export function isReconciledFor(
);
}
/**
* Orchestrates one reconciliation attempt for `pubkey` and reports success
* via `onReady`. Returns a `cancel()` that suppresses a still-pending
* completion called on unmount or when `pubkey` changes mid-flight.
*
* Extracted from `useObserverArchiveReconciliation`'s effect body so the
* cancellation/stale-completion logic (the part a lifecycle regression would
* actually break) is directly testable without React mount infra. The hook
* below calls this verbatim; it does not duplicate the cancellation guard.
*
* On failure: does not call `onReady`; the caller's gate stays closed and a
* fresh reconciliation attempt on next mount will retry (no success marker
* is persisted anywhere).
*/
export function startReconciliation(
pubkey: string,
deps: ObserverArchiveSeedDeps,
onReady: (pubkey: string) => void,
): () => void {
let cancelled = false;
reconcileObserverArchive(deps)
.then(() => {
if (!cancelled) onReady(pubkey);
})
.catch((err) => {
console.warn("[useObserverArchiveReconciliation] failed:", err);
});
return () => {
cancelled = true;
};
}
/**
* Runs observer archive reconciliation eagerly when `pubkey` resolves.
* Returns `true` only after successful reconciliation for the current
@@ -78,20 +107,7 @@ export function useObserverArchiveReconciliation(
React.useEffect(() => {
if (!pubkey) return;
let cancelled = false;
reconcileObserverArchive(pubkey, deps)
.then(() => {
if (!cancelled) setReconciledPubkey(pubkey);
})
.catch((err) => {
console.warn("[useObserverArchiveReconciliation] failed:", err);
});
return () => {
cancelled = true;
};
return startReconciliation(pubkey, deps, setReconciledPubkey);
}, [pubkey, deps]);
return isReconciledFor(reconciledPubkey, pubkey);
+167 -13
View File
@@ -241,6 +241,19 @@ type E2eConfig = {
// snake_case wire shape the Rust backend returns so tests can drive the
// LocalArchiveSettingsCard without a real SQLite database.
observerArchiveDefaultEnabled?: boolean;
/**
* Delay (ms) applied to `observer_archive_default_enabled` so E2E tests
* can observe the pending-reconciliation state (toggle disabled, no
* archive-manager `list_save_subscriptions` call) before the policy
* resolves. 0/undefined = instant.
*/
observerArchiveDefaultEnabledDelayMs?: number;
/**
* When set, `observer_archive_default_enabled` throws with this message
* instead of resolving drives the fail-closed `.catch()` path in
* `useObserverArchiveReconciliation` / `LocalArchiveSettingsCard`.
*/
observerArchiveDefaultEnabledError?: string;
agentMetricArchiveDefaultEnabled?: boolean;
saveSubscriptions?: Array<{
scope_type: string;
@@ -701,6 +714,10 @@ const GLOBAL_MOCK_SUBSCRIPTION = "*";
type MockSubscription = {
channelId: string;
kinds: number[] | null;
/** `#p` values from the REQ filters, if any lets specs assert an
* owner-scoped live subscription (e.g. the observer-archive `24200`
* reconciliation gate) independently of channel-scoped ones. */
ownerPubkeys: string[];
};
type MockFilter = {
@@ -829,6 +846,10 @@ declare global {
channelName: string;
kind?: number;
}) => boolean;
__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?: (input: {
ownerPubkey: string;
kind: number;
}) => boolean;
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
@@ -2544,6 +2565,28 @@ let mockWebsocketSendMutexWedged = false;
const realSockets = new Map<number, WebSocket>();
let mockManagedAgents: MockManagedAgent[] = [];
// Mutable `save_subscriptions` table mirror — TEST-ONLY.
//
// Cloned from `activeConfig.mock.saveSubscriptions` at install time, then
// mutated by `create_save_subscription` / `delete_save_subscription` /
// `merge_save_subscription_kinds` / `remove_save_subscription_kind` exactly
// as the real SQLite-backed Rust commands would (see `archive/store.rs`).
// This lets E2E specs drive the fresh-internal-repair path (start from `[]`,
// reconcile, observe a kind-24200 row appear) and OSS toggle ON/OFF, neither
// of which an immutable seed can represent.
type MockSaveSubscriptionRow = {
scope_type: string;
scope_value: string;
kinds: string; // JSON-encoded integer array, e.g. "[9,40002]"
};
let mockSaveSubscriptions: MockSaveSubscriptionRow[] = [];
function resetMockSaveSubscriptions(config: E2eConfig | undefined) {
mockSaveSubscriptions = (config?.mock?.saveSubscriptions ?? []).map((s) => ({
...s,
}));
}
// Mesh-compute mock state — TEST-ONLY.
//
// This entire module (e2eBridge.ts) is loaded only when `window.__BUZZ_E2E__`
@@ -3574,6 +3617,28 @@ function hasMockLiveSubscription(channelId: string, kind?: number) {
return false;
}
/**
* True iff a live REQ subscription is open with an `#p` filter containing
* `ownerPubkey` and a `kinds` filter containing `kind`. Used to assert the
* observer-archive reconciliation gate actually opens an owner-scoped live
* filter (not just that `list_save_subscriptions` was called) once the
* gate resolves.
*/
function hasMockOwnerKindSubscription(ownerPubkey: string, kind: number) {
for (const socket of mockSockets.values()) {
for (const subscription of socket.subscriptions.values()) {
if (
subscription.ownerPubkeys.includes(ownerPubkey) &&
(subscription.kinds?.includes(kind) ?? false)
) {
return true;
}
}
}
return false;
}
function recordMockMessage(channelId: string, event: RelayEvent) {
const history = getMockMessageStore(channelId);
history.push(event);
@@ -8147,12 +8212,16 @@ function sendToMockSocket(args: {
// Collect channel IDs from all filters in the REQ
const channelIds = new Set<string>();
const kinds = new Set<number>();
const ownerPubkeys = new Set<string>();
for (const f of filters) {
const cid = f["#h"]?.[0];
if (cid) channelIds.add(cid);
for (const kind of f.kinds ?? []) {
kinds.add(kind);
}
for (const p of f["#p"] ?? []) {
ownerPubkeys.add(p);
}
}
const onlyChannelId =
channelIds.size === 1
@@ -8161,6 +8230,7 @@ function sendToMockSocket(args: {
socket.subscriptions.set(subId, {
channelId: onlyChannelId ?? GLOBAL_MOCK_SUBSCRIPTION,
kinds: kinds.size > 0 ? [...kinds] : null,
ownerPubkeys: [...ownerPubkeys],
});
sendWsText(socket.handler, ["EOSE", subId]);
return;
@@ -8383,6 +8453,7 @@ export function maybeInstallE2eTauriMocks() {
resetMockWorkflows();
resetMockMesh();
resetMockUserStatuses();
resetMockSaveSubscriptions(config);
resetMockPendingCommunityDeepLinks(config);
mockWebsocketSendMutexWedged = false;
mockWindows("main");
@@ -8442,6 +8513,10 @@ export function maybeInstallE2eTauriMocks() {
return hasMockLiveSubscription(channel.id, kind);
};
window.__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__ = ({
ownerPubkey,
kind,
}) => hasMockOwnerKindSubscription(ownerPubkey, kind);
window.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ = (item) => {
const category = item.category === "mention" ? "mentions" : item.category;
mockFeedOverrides[category].unshift(item);
@@ -9828,9 +9903,12 @@ export function maybeInstallE2eTauriMocks() {
}
// ── Local-save archive ──────────────────────────────────────────────
// These stubs drive the LocalArchiveSettingsCard in screenshot / UI tests
// without requiring a real SQLite backend. `activeConfig.mock.saveSubscriptions`
// seeds the initial list; create/delete return success shapes so the
// component's reload path behaves correctly.
// without requiring a real SQLite backend. `mockSaveSubscriptions` is a
// mutable clone of `activeConfig.mock.saveSubscriptions` (reset on
// install); create/merge/delete/remove mutate it with the same
// union / delete-row-when-empty semantics as the real Rust commands
// (see `archive/store.rs::merge_owner_p_kinds` / `remove_owner_p_kind`)
// so specs can drive fresh-internal-repair and toggle ON/OFF flows.
case "list_save_subscriptions": {
const win = window as unknown as Record<string, unknown>;
if (!win.__BUZZ_E2E_IPC_COUNTERS__) {
@@ -9843,7 +9921,7 @@ export function maybeInstallE2eTauriMocks() {
ipcCounters.list_save_subscriptions =
(ipcCounters.list_save_subscriptions ?? 0) + 1;
const ident = activeConfig?.identity ?? DEFAULT_MOCK_IDENTITY;
return (activeConfig?.mock?.saveSubscriptions ?? []).map((s) => ({
return mockSaveSubscriptions.map((s) => ({
identity_pubkey: ident.pubkey,
relay_url: DEFAULT_RELAY_WS_URL,
scope_type: s.scope_type,
@@ -9852,24 +9930,100 @@ export function maybeInstallE2eTauriMocks() {
created_at: Math.floor(Date.now() / 1000),
}));
}
case "create_save_subscription":
// UI calls this then re-fetches via list_save_subscriptions; returning
// null (Rust Ok(())) is sufficient to let the component proceed.
case "create_save_subscription": {
const req = payload as {
scopeType: string;
scopeValue: string;
kinds: number[];
};
const kindsJson = JSON.stringify(req.kinds);
const existing = mockSaveSubscriptions.find(
(s) =>
s.scope_type === req.scopeType && s.scope_value === req.scopeValue,
);
if (existing) {
existing.kinds = kindsJson;
} else {
mockSaveSubscriptions.push({
scope_type: req.scopeType,
scope_value: req.scopeValue,
kinds: kindsJson,
});
}
return null;
case "delete_save_subscription":
// Returns true == row removed; mirrors Rust success path.
return true;
}
case "delete_save_subscription": {
const req = payload as { scopeType: string; scopeValue: string };
const before = mockSaveSubscriptions.length;
mockSaveSubscriptions = mockSaveSubscriptions.filter(
(s) =>
!(
s.scope_type === req.scopeType && s.scope_value === req.scopeValue
),
);
return mockSaveSubscriptions.length < before;
}
case "archive_events":
// Returns the ArchiveBatchResult shape the UI expects.
return { persisted: 0, dropped: 0 };
case "observer_archive_default_enabled":
case "observer_archive_default_enabled": {
const delayMs =
activeConfig?.mock?.observerArchiveDefaultEnabledDelayMs;
if (delayMs && delayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
}
const error = activeConfig?.mock?.observerArchiveDefaultEnabledError;
if (error) {
throw new Error(error);
}
return activeConfig?.mock?.observerArchiveDefaultEnabled ?? false;
}
case "agent_metric_archive_default_enabled":
return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? false;
case "merge_save_subscription_kinds":
case "merge_save_subscription_kinds": {
// Mirrors `merge_owner_p_kinds`: union `kind` into the owner_p row's
// kinds, creating the row if it doesn't exist yet.
const { kind } = payload as { kind: number };
const ident = activeConfig?.identity ?? DEFAULT_MOCK_IDENTITY;
const row = mockSaveSubscriptions.find(
(s) => s.scope_type === "owner_p" && s.scope_value === ident.pubkey,
);
if (row) {
const kinds: number[] = JSON.parse(row.kinds);
if (!kinds.includes(kind)) {
row.kinds = JSON.stringify([...kinds, kind]);
}
} else {
mockSaveSubscriptions.push({
scope_type: "owner_p",
scope_value: ident.pubkey,
kinds: JSON.stringify([kind]),
});
}
return null;
case "remove_save_subscription_kind":
}
case "remove_save_subscription_kind": {
// Mirrors `remove_owner_p_kind`: remove `kind` from the owner_p row's
// kinds, deleting the row entirely once its kinds list is empty.
const { kind } = payload as { kind: number };
const ident = activeConfig?.identity ?? DEFAULT_MOCK_IDENTITY;
const row = mockSaveSubscriptions.find(
(s) => s.scope_type === "owner_p" && s.scope_value === ident.pubkey,
);
if (row) {
const kinds: number[] = JSON.parse(row.kinds).filter(
(k: number) => k !== kind,
);
if (kinds.length === 0) {
mockSaveSubscriptions = mockSaveSubscriptions.filter(
(s) => s !== row,
);
} else {
row.kinds = JSON.stringify(kinds);
}
}
return null;
}
default:
throw new Error(`Unsupported mocked Tauri command: ${command}`);
}
@@ -56,15 +56,13 @@ test.describe("observer archive policy — Settings toggle", () => {
await expect(toggle).toBeChecked();
});
test("unresolved policy (default): toggle disabled", async ({ page }) => {
// When observerArchiveDefaultEnabled is not set in mock config,
// the bridge returns false (OSS). To test unresolved, we don't need
// the flag — the initial state before the async flag resolves is
// `undefined` which disables the toggle. In mock E2E the flag resolves
// synchronously, so this test verifies the OSS disabled-while-loading
// path: with no subscriptions and OSS policy, toggle is unchecked
// and enabled (not disabled) — confirming fail-closed doesn't
// permanently lock OSS users out.
test("OSS policy, no subscriptions: toggle enabled and unchecked", async ({
page,
}) => {
// Resolved-OSS empty-subscription state: no owner_p/24200 row exists,
// so the toggle reads unchecked, and OSS policy (false) keeps it
// enabled — confirming fail-closed doesn't permanently lock OSS users
// out once the policy flag resolves.
await installMockBridge(page, {
observerArchiveDefaultEnabled: false,
saveSubscriptions: [],
@@ -76,6 +74,88 @@ test.describe("observer archive policy — Settings toggle", () => {
await expect(toggle).toBeEnabled();
await expect(toggle).not.toBeChecked();
});
test("policy pending: toggle disabled, then enabled once resolved", async ({
page,
}) => {
await installMockBridge(page, {
observerArchiveDefaultEnabled: false,
observerArchiveDefaultEnabledDelayMs: 500,
saveSubscriptions: [],
});
const card = await openLocalArchiveSettings(page);
const toggle = card.getByTestId("local-archive-observer-toggle");
await expect(toggle).toBeVisible({ timeout: 5_000 });
// Fail-closed: disabled while the policy check is still in flight.
await expect(toggle).toBeDisabled();
await expect(toggle).toBeEnabled({ timeout: 5_000 });
await expect(toggle).not.toBeChecked();
});
test("policy check fails: toggle stays disabled and issues no mutation", async ({
page,
}) => {
await installMockBridge(page, {
observerArchiveDefaultEnabled: false,
observerArchiveDefaultEnabledError: "policy check failed",
saveSubscriptions: [],
});
const card = await openLocalArchiveSettings(page);
const toggle = card.getByTestId("local-archive-observer-toggle");
await expect(toggle).toBeVisible({ timeout: 5_000 });
// Rejection leaves `observerPolicy` at its initial `undefined` — the
// fail-closed `.catch()` in LocalArchiveSettingsCard must not flip it
// to a permissive state. Give the rejection time to settle, then
// assert the disabled state holds (not just "hasn't flipped yet").
await page.waitForTimeout(200);
await expect(toggle).toBeDisabled();
const commands = await page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
);
expect(
commands.filter(
(c) =>
c === "merge_save_subscription_kinds" ||
c === "remove_save_subscription_kind",
),
).toEqual([]);
});
test("OSS policy: toggle click ON merges kind 24200, click OFF removes the row", async ({
page,
}) => {
await installMockBridge(page, {
observerArchiveDefaultEnabled: false,
saveSubscriptions: [],
});
const card = await openLocalArchiveSettings(page);
const toggle = card.getByTestId("local-archive-observer-toggle");
await expect(toggle).toBeVisible({ timeout: 5_000 });
await expect(toggle).not.toBeChecked();
// ON: merges kind 24200 into a fresh owner_p row (the row-creation edge
// of merge_save_subscription_kinds).
await toggle.click();
await expect(toggle).toBeChecked();
// OFF: removes kind 24200. Since it's the row's only kind, the row is
// deleted entirely (remove_save_subscription_kind's row-delete-on-empty
// edge) — re-checking observerEnabled must correctly read "no row" as
// unchecked, not stale/checked.
await toggle.click();
await expect(toggle).not.toBeChecked();
// ON again: re-creates the row from empty, proving the delete above was
// a real row removal and not a lingering empty-kinds row.
await toggle.click();
await expect(toggle).toBeChecked();
});
});
test.describe("observer archive policy — reconciliation gate", () => {
@@ -120,5 +200,208 @@ test.describe("observer archive policy — reconciliation gate", () => {
return counters?.list_save_subscriptions ?? 0;
});
expect(count).toBeGreaterThan(0);
// Bonus (Thufir pass 2, F4): the reconciliation gate must also result
// in a real `#p` + kind-24200 live REQ filter, not just an IPC call.
const hasOwnerKindSubscription = await page.evaluate(
(ownerPubkey) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?: (input: {
ownerPubkey: string;
kind: number;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?.({
ownerPubkey,
kind: 24200,
}) ?? false,
"deadbeef".repeat(8),
);
expect(hasOwnerKindSubscription).toBe(true);
});
test("policy pending: no subscription list call or live filter until resolved", async ({
page,
}) => {
await installMockBridge(page, {
observerArchiveDefaultEnabled: true,
observerArchiveDefaultEnabledDelayMs: 500,
saveSubscriptions: [
{
scope_type: "owner_p",
scope_value: "deadbeef".repeat(8),
kinds: "[24200]",
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("channel-general")).toBeVisible({
timeout: 10_000,
});
// While the policy check is pending, useArchiveSync must not have
// started — no list_save_subscriptions call, no owner/24200 live
// filter. This is the discriminating half pass 2 found missing: the
// prior test only proved "eventually starts", not "doesn't start
// early."
const countWhilePending = await page.evaluate(
() =>
(
(window as Record<string, unknown>).__BUZZ_E2E_IPC_COUNTERS__ as
| Record<string, number>
| undefined
)?.list_save_subscriptions ?? 0,
);
expect(countWhilePending).toBe(0);
const hasSubscriptionWhilePending = await page.evaluate(
(ownerPubkey) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?: (input: {
ownerPubkey: string;
kind: number;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?.({
ownerPubkey,
kind: 24200,
}) ?? false,
"deadbeef".repeat(8),
);
expect(hasSubscriptionWhilePending).toBe(false);
// After the policy resolves, both the IPC call and the live filter
// appear.
await page.waitForFunction(
() =>
((
(window as Record<string, unknown>).__BUZZ_E2E_IPC_COUNTERS__ as
| Record<string, number>
| undefined
)?.list_save_subscriptions ?? 0) > 0,
null,
{ timeout: 10_000 },
);
await expect
.poll(
() =>
page.evaluate(
(ownerPubkey) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?: (input: {
ownerPubkey: string;
kind: number;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?.({
ownerPubkey,
kind: 24200,
}) ?? false,
"deadbeef".repeat(8),
),
{ timeout: 5_000 },
)
.toBe(true);
});
test("policy check fails: subscription path never opens", async ({
page,
}) => {
await installMockBridge(page, {
observerArchiveDefaultEnabled: true,
observerArchiveDefaultEnabledError: "policy check failed",
saveSubscriptions: [
{
scope_type: "owner_p",
scope_value: "deadbeef".repeat(8),
kinds: "[24200]",
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("channel-general")).toBeVisible({
timeout: 10_000,
});
// Give the rejected reconciliation time to settle, then assert the
// gate stayed shut: no list_save_subscriptions call, no live filter.
await page.waitForTimeout(500);
const count = await page.evaluate(
() =>
(
(window as Record<string, unknown>).__BUZZ_E2E_IPC_COUNTERS__ as
| Record<string, number>
| undefined
)?.list_save_subscriptions ?? 0,
);
expect(count).toBe(0);
const hasSubscription = await page.evaluate(
(ownerPubkey) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?: (input: {
ownerPubkey: string;
kind: number;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?.({
ownerPubkey,
kind: 24200,
}) ?? false,
"deadbeef".repeat(8),
);
expect(hasSubscription).toBe(false);
});
test("fresh internal install: reconciliation repairs an empty subscription list", async ({
page,
}) => {
// The actual production repair path Will's bug report was about: a
// fresh internal install with no owner_p/24200 row yet must end up
// with one after startup reconciliation runs — not just "no-op
// because the row was already there" (the prior fixture always
// pre-seeded the row).
await installMockBridge(page, {
observerArchiveDefaultEnabled: true,
saveSubscriptions: [],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("channel-general")).toBeVisible({
timeout: 10_000,
});
await expect
.poll(
() =>
page.evaluate(
(ownerPubkey) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?: (input: {
ownerPubkey: string;
kind: number;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?.({
ownerPubkey,
kind: 24200,
}) ?? false,
"deadbeef".repeat(8),
),
{ timeout: 10_000 },
)
.toBe(true);
const commands = await page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
);
expect(commands).toContain("merge_save_subscription_kinds");
});
});
+11
View File
@@ -218,6 +218,17 @@ type MockBridgeOptions = {
* build (toggle functional). Drives LocalArchiveSettingsCard policy state.
*/
observerArchiveDefaultEnabled?: boolean;
/**
* Delay (ms) applied to `observer_archive_default_enabled` so specs can
* assert the pending-reconciliation state (toggle disabled, no
* `list_save_subscriptions` call yet) before the policy resolves.
*/
observerArchiveDefaultEnabledDelayMs?: number;
/**
* When set, `observer_archive_default_enabled` throws with this message
* drives the fail-closed path when the policy check itself fails.
*/
observerArchiveDefaultEnabledError?: string;
// NIP-IA gate inputs — drive the archive-button gate matrix in
// tests/e2e/identity-archive.spec.ts.
/**