mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
2310b03f9d
commit
fd2eaac73b
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user