mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(desktop): enforce observer archive policy reconciliation on internal builds (#1923)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -169,6 +169,10 @@ jobs:
|
||||
run: just desktop-tauri-test
|
||||
env:
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
- name: Desktop Tauri compiled-flag verification
|
||||
run: just desktop-tauri-test-compiled-flags
|
||||
env:
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
- name: Upload desktop e2e artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
|
||||
17
Justfile
17
Justfile
@@ -193,6 +193,23 @@ desktop-tauri-check: _ensure-sidecar-stubs
|
||||
desktop-tauri-test: _ensure-sidecar-stubs
|
||||
cd desktop/src-tauri && cargo test
|
||||
|
||||
# Verify compiled-flag behavior under both compile states (clean + internal).
|
||||
# Runs the observer_archive focused test twice with independently supplied
|
||||
# expected values; build.rs rerun-if-env-changed triggers recompilation.
|
||||
desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd desktop/src-tauri
|
||||
echo "=== Clean build (no flag) → expect false ==="
|
||||
env -u BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT \
|
||||
BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=false \
|
||||
cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture
|
||||
echo "=== Internal build (flag set) → expect true ==="
|
||||
BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT=1 \
|
||||
BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=true \
|
||||
cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture
|
||||
echo "Both compiled states verified."
|
||||
|
||||
# Build the full desktop Tauri app locally (unsigned, for testing)
|
||||
# Sidecar binary list must stay in sync with _ensure-sidecar-stubs above.
|
||||
# pnpm install is unconditional here: release builds must start from a clean dep tree.
|
||||
|
||||
@@ -107,6 +107,7 @@ export default defineConfig({
|
||||
"**/agent-lifecycle-feedback.spec.ts",
|
||||
"**/inbox-live-update.spec.ts",
|
||||
"**/mesh-compute.spec.ts",
|
||||
"**/observer-archive-policy.spec.ts",
|
||||
],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Build-time flag and runtime dev-nest check for observer-feed archive default.
|
||||
//! Build-time flag and runtime dev-nest check for observer-feed archive policy.
|
||||
//!
|
||||
//! `observer_archive_default_enabled()` returns `true` when either:
|
||||
//! - `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` was set at build time (internal
|
||||
@@ -6,17 +6,18 @@
|
||||
//! - the running binary is using the dev nest (`~/.buzz-dev`), which is the
|
||||
//! case for all dev builds launched with `just staging` or `just dev`.
|
||||
//!
|
||||
//! When `true`, the frontend auto-seeds an `owner_p` save subscription for the
|
||||
//! current identity on first run, so the observer-feed archive is on by default.
|
||||
//! When `true`, the frontend reconciles the observer archive subscription
|
||||
//! every startup — unconditionally ensuring kind 24200 exists in the DB
|
||||
//! regardless of stale localStorage markers.
|
||||
//!
|
||||
//! OSS prod builds (baked flag unset, prod nest `~/.buzz`) return `false` —
|
||||
//! no auto-seeding; the user opts in manually via the Local Archive settings card.
|
||||
//! no reconciliation; the user manages the subscription via Settings.
|
||||
|
||||
/// Returns `true` when observer-feed archive should default to on.
|
||||
/// Returns `true` when observer-feed archive policy is enforced.
|
||||
///
|
||||
/// True when the build has the internal baked flag set, or when the running
|
||||
/// binary is using the dev nest (`~/.buzz-dev`). The frontend calls this once
|
||||
/// at startup to decide whether to seed the `owner_p` save subscription.
|
||||
/// binary is using the dev nest (`~/.buzz-dev`). The frontend calls this
|
||||
/// every startup to decide whether to reconcile the `owner_p` subscription.
|
||||
#[tauri::command]
|
||||
pub fn observer_archive_default_enabled() -> bool {
|
||||
option_env!("BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_some()
|
||||
@@ -27,15 +28,27 @@ pub fn observer_archive_default_enabled() -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// `nest_is_dev()` is deterministic-false in unit tests: NEST_DIR OnceLock
|
||||
// is uninitialized → falls back to prod `~/.buzz` (nest.rs:101-106), so
|
||||
// the compiled flag is the sole variable. No runner normalization needed.
|
||||
//
|
||||
// #[ignore]: requires BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT to be
|
||||
// set — `just desktop-tauri-test-compiled-flags` runs it explicitly with
|
||||
// `--ignored` under both compile states; general `cargo test` skips it.
|
||||
#[test]
|
||||
fn test_observer_archive_default_enabled_returns_bool() {
|
||||
// The command must return a plain bool without panicking.
|
||||
// Whether it's true or false depends on the build environment;
|
||||
// what we assert here is just that the return type is correct and
|
||||
// the function is callable.
|
||||
#[ignore]
|
||||
fn test_observer_archive_default_enabled_matches_expected() {
|
||||
let result = observer_archive_default_enabled();
|
||||
// In a standard OSS/test build (no BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT
|
||||
// baked in), this should be false.
|
||||
assert!(!result, "expected false in OSS/test build");
|
||||
let expected_str = std::env::var("BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT").expect(
|
||||
"BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT must be set — \
|
||||
the dual-compile CI step supplies it; bare `cargo test` is \
|
||||
not sufficient to validate compiled-flag behavior",
|
||||
);
|
||||
let expected = expected_str == "true" || expected_str == "1";
|
||||
assert_eq!(
|
||||
result, expected,
|
||||
"observer_archive_default_enabled() returned {result}, \
|
||||
expected {expected} (BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT={expected_str:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
} from "@/features/user-status/hooks";
|
||||
import { useCommunityEmojiLiveUpdates } from "@/features/custom-emoji/hooks";
|
||||
import { useArchiveSync } from "@/features/local-archive/archiveSyncManager";
|
||||
import { useObserverArchiveSeed } from "@/features/local-archive/useObserverArchiveSeed";
|
||||
import { useObserverArchiveReconciliation } from "@/features/local-archive/useObserverArchiveSeed";
|
||||
import { useAgentMetricArchiveSeed } from "@/features/local-archive/useAgentMetricArchiveSeed";
|
||||
import { useProfileQuery } from "@/features/profile/hooks";
|
||||
import { SendFeedbackController } from "@/features/settings/ui/SendFeedbackController";
|
||||
@@ -186,15 +186,18 @@ export function AppShell() {
|
||||
// relay-owned agents join automatically once identity arrives. Adding a
|
||||
// guard here would drop managed-agent coverage during startup.
|
||||
useAgentObserverIngestion();
|
||||
useArchiveSync();
|
||||
// Defer the archive *seeds* until startup is idle: they're first-run catch-up
|
||||
// config (a one-shot mergeSaveSubscriptionKinds), not live-ingest — that's
|
||||
// useArchiveSync's job, which stays eager above. Passing deferredPubkey makes
|
||||
// each seed hook wait on its own `if (!pubkey) return` guard until the shell
|
||||
// is interactive, so their IPC + sqlite archive open doesn't compete with
|
||||
// first paint. The explicit-choice guard inside each hook is unchanged.
|
||||
// Kind 24200 is relay-ephemeral, so reconciliation runs eagerly (not
|
||||
// deferred) and unconditionally repairs the DB subscription on internal
|
||||
// builds — otherwise frames emitted before the listener opens are lost.
|
||||
const observerReconciled = useObserverArchiveReconciliation(
|
||||
identityQuery.data?.pubkey,
|
||||
);
|
||||
// useArchiveSync must wait for reconciliation, or listeners could open
|
||||
// before kind 24200 is guaranteed present in the subscription.
|
||||
useArchiveSync(observerReconciled);
|
||||
// Kind 44200 is relay-persisted (durable) and stays deferred: missed
|
||||
// startup frames can be replayed, so there's no ordering constraint.
|
||||
const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined;
|
||||
useObserverArchiveSeed(deferredPubkey);
|
||||
useAgentMetricArchiveSeed(deferredPubkey);
|
||||
const profileQuery = useProfileQuery();
|
||||
useRelayAutoHeal();
|
||||
|
||||
@@ -303,16 +303,19 @@ export class ArchiveSyncManager {
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Starts the ArchiveSyncManager at app-shell mount and tears it down when the
|
||||
* component unmounts (community switch). No return value needed — the manager
|
||||
* runs entirely in the background.
|
||||
* Starts the ArchiveSyncManager once `ready` is true and tears it down on
|
||||
* unmount. The `ready` gate ensures observer reconciliation completes before
|
||||
* any relay listeners open — kind 24200 is relay-ephemeral, so frames emitted
|
||||
* before the listener opens are permanently lost.
|
||||
*/
|
||||
export function useArchiveSync(): void {
|
||||
export function useArchiveSync(ready: boolean): void {
|
||||
React.useEffect(() => {
|
||||
if (!ready) return;
|
||||
|
||||
const manager = new ArchiveSyncManager();
|
||||
void manager.start();
|
||||
return () => {
|
||||
manager.destroy();
|
||||
};
|
||||
}, []);
|
||||
}, [ready]);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
SettingsOptionRow,
|
||||
} 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";
|
||||
|
||||
@@ -66,15 +67,18 @@ function kindSummary(kinds: number[]): string {
|
||||
|
||||
type ObserverSectionProps = {
|
||||
enabled: boolean;
|
||||
policy: boolean | undefined;
|
||||
toggling: boolean;
|
||||
onToggle: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
function ObserverArchiveSection({
|
||||
enabled,
|
||||
policy,
|
||||
toggling,
|
||||
onToggle,
|
||||
}: ObserverSectionProps) {
|
||||
const toggleDisabled = toggling || policy === undefined || policy === true;
|
||||
return (
|
||||
<div className="space-y-3" data-testid="local-archive-observer-section">
|
||||
<h2 className="text-lg font-semibold tracking-tight">
|
||||
@@ -90,15 +94,15 @@ function ObserverArchiveSection({
|
||||
Archive my agents' observer frames
|
||||
</label>
|
||||
<p className="text-sm font-normal text-muted-foreground">
|
||||
Saves kind {KIND_AGENT_OBSERVER_FRAME} observer frames addressed
|
||||
to your pubkey. These are ephemeral — not stored by the relay — so
|
||||
local archiving is the only way to retain them.
|
||||
{policy === true
|
||||
? `Always on for internal builds. Kind ${KIND_AGENT_OBSERVER_FRAME} observer frames are ephemeral — not stored by the relay — so local archiving is the only way to retain them.`
|
||||
: `Saves kind ${KIND_AGENT_OBSERVER_FRAME} observer frames addressed to your pubkey. These are ephemeral — not stored by the relay — so local archiving is the only way to retain them.`}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
data-testid="local-archive-observer-toggle"
|
||||
disabled={toggling}
|
||||
disabled={toggleDisabled}
|
||||
id="local-archive-observer-toggle"
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
@@ -389,6 +393,17 @@ export function LocalArchiveSettingsCard() {
|
||||
const [isAddingOpen, setIsAddingOpen] = React.useState(false);
|
||||
const [observerToggling, setObserverToggling] = React.useState(false);
|
||||
const [metricToggling, setMetricToggling] = React.useState(false);
|
||||
const [observerPolicy, setObserverPolicy] = React.useState<
|
||||
boolean | undefined
|
||||
>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
observerArchiveDefaultEnabled()
|
||||
.then((on) => setObserverPolicy(on))
|
||||
.catch(() => {
|
||||
// Fail closed: leave as undefined so toggle stays disabled.
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pubkey = identityQuery.data?.pubkey ?? "";
|
||||
|
||||
@@ -451,6 +466,7 @@ export function LocalArchiveSettingsCard() {
|
||||
const handleObserverToggle = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!pubkey) return;
|
||||
if (!checked && observerPolicy !== false) return;
|
||||
setObserverToggling(true);
|
||||
try {
|
||||
if (checked) {
|
||||
@@ -475,7 +491,7 @@ export function LocalArchiveSettingsCard() {
|
||||
setObserverToggling(false);
|
||||
}
|
||||
},
|
||||
[pubkey, reload],
|
||||
[pubkey, observerPolicy, reload],
|
||||
);
|
||||
|
||||
const handleMetricToggle = React.useCallback(
|
||||
@@ -525,6 +541,7 @@ export function LocalArchiveSettingsCard() {
|
||||
<ObserverArchiveSection
|
||||
enabled={observerEnabled}
|
||||
onToggle={(checked) => void handleObserverToggle(checked)}
|
||||
policy={observerPolicy}
|
||||
toggling={observerToggling}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,156 +1,317 @@
|
||||
/**
|
||||
* Tests for useObserverArchiveSeed seeding logic.
|
||||
*
|
||||
* The hook is a thin React wrapper around an async seed routine. We test the
|
||||
* behaviour by driving the deps-injection interface directly — no React needed.
|
||||
* This mirrors the pattern used in archiveSyncManager.test.mjs.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// We call the seeding function directly via deps-injection rather than
|
||||
// mounting the hook, so we import the seed-coordination logic by
|
||||
// re-implementing a minimal version of `maybeSeed` driven by the same deps
|
||||
// interface. The test boundary is the async coordination logic, not React.
|
||||
import {
|
||||
isReconciledFor,
|
||||
reconcileObserverArchive,
|
||||
} from "./useObserverArchiveSeed.ts";
|
||||
import { ArchiveSyncManager } from "./archiveSyncManager.ts";
|
||||
|
||||
// ── Fake deps factory ────────────────────────────────────────────────────────
|
||||
|
||||
function makeDeps({
|
||||
defaultOn = false,
|
||||
hasExplicitChoice = false,
|
||||
policyOn = false,
|
||||
mergeShouldFail = false,
|
||||
flagShouldFail = false,
|
||||
} = {}) {
|
||||
const calls = { mergeSaveSubscriptionKinds: [], setExplicitChoice: [] };
|
||||
const calls = { merge: [], setChoice: [] };
|
||||
|
||||
return {
|
||||
calls,
|
||||
observerArchiveDefaultEnabled: async () => defaultOn,
|
||||
observerArchiveDefaultEnabled: async () => {
|
||||
if (flagShouldFail) throw new Error("flag check failed");
|
||||
return policyOn;
|
||||
},
|
||||
mergeSaveSubscriptionKinds: async (kind) => {
|
||||
if (mergeShouldFail) throw new Error("merge failed");
|
||||
calls.mergeSaveSubscriptionKinds.push({ kind });
|
||||
calls.merge.push({ kind });
|
||||
},
|
||||
hasExplicitChoice: (_pubkey) => hasExplicitChoice,
|
||||
setExplicitChoice: (pubkey, enabled) => {
|
||||
calls.setExplicitChoice.push({ pubkey, enabled });
|
||||
calls.setChoice.push({ pubkey, enabled });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Minimal re-implementation of the seeding logic from useObserverArchiveSeed.ts.
|
||||
// Kept in sync with the source by structural mirroring (not bytewise copy) —
|
||||
// change the source and update this accordingly.
|
||||
const KIND_AGENT_OBSERVER_FRAME = 24200;
|
||||
|
||||
async function runSeed(pubkey, deps) {
|
||||
if (!pubkey) return;
|
||||
if (deps.hasExplicitChoice(pubkey)) return;
|
||||
|
||||
let defaultOn;
|
||||
try {
|
||||
defaultOn = await deps.observerArchiveDefaultEnabled();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!defaultOn) return;
|
||||
|
||||
try {
|
||||
await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME);
|
||||
} catch {
|
||||
return; // transient failure — do NOT set explicit choice
|
||||
}
|
||||
|
||||
deps.setExplicitChoice(pubkey, true);
|
||||
/** Helper: wait for microtasks/promises to settle */
|
||||
function tick() {
|
||||
return new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
// ── Internal policy build ────────────────────────────────────────────────────
|
||||
|
||||
test("test_internal_build_unset_seeds_owner_p_subscription", async () => {
|
||||
const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false });
|
||||
await runSeed("pubkey123", deps);
|
||||
test("test_internal_policy_marker_null_seeds_24200", async () => {
|
||||
const deps = makeDeps({ policyOn: true });
|
||||
await reconcileObserverArchive("pk1", deps);
|
||||
|
||||
assert.equal(
|
||||
deps.calls.mergeSaveSubscriptionKinds.length,
|
||||
1,
|
||||
"should call mergeSaveSubscriptionKinds once",
|
||||
);
|
||||
const call = deps.calls.mergeSaveSubscriptionKinds[0];
|
||||
assert.equal(call.kind, 24200);
|
||||
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_build_unset_persists_explicit_choice_after_seed", async () => {
|
||||
const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false });
|
||||
await runSeed("pubkey123", deps);
|
||||
test("test_internal_policy_marker_0_still_seeds_24200", async () => {
|
||||
const deps = makeDeps({ policyOn: true });
|
||||
await reconcileObserverArchive("pk1", deps);
|
||||
|
||||
assert.equal(
|
||||
deps.calls.setExplicitChoice.length,
|
||||
1,
|
||||
"should persist explicit choice after successful seed",
|
||||
);
|
||||
assert.equal(deps.calls.setExplicitChoice[0].pubkey, "pubkey123");
|
||||
assert.equal(deps.calls.setExplicitChoice[0].enabled, true);
|
||||
assert.equal(deps.calls.merge.length, 1, "must merge even with stale marker");
|
||||
assert.equal(deps.calls.merge[0].kind, 24200);
|
||||
});
|
||||
|
||||
test("test_explicit_choice_set_does_not_reseed", async () => {
|
||||
const deps = makeDeps({ defaultOn: true, hasExplicitChoice: true });
|
||||
await runSeed("pubkey123", deps);
|
||||
test("test_internal_policy_marker_1_still_seeds_24200", async () => {
|
||||
const deps = makeDeps({ policyOn: true });
|
||||
await reconcileObserverArchive("pk1", deps);
|
||||
|
||||
assert.equal(
|
||||
deps.calls.mergeSaveSubscriptionKinds.length,
|
||||
0,
|
||||
"should not call mergeSaveSubscriptionKinds when explicit choice is already set",
|
||||
);
|
||||
assert.equal(
|
||||
deps.calls.setExplicitChoice.length,
|
||||
0,
|
||||
"should not update explicit choice when already set",
|
||||
);
|
||||
assert.equal(deps.calls.merge.length, 1, "must reconcile even with marker 1");
|
||||
});
|
||||
|
||||
test("test_oss_build_does_not_seed", async () => {
|
||||
const deps = makeDeps({ defaultOn: false, hasExplicitChoice: false });
|
||||
await runSeed("pubkey123", deps);
|
||||
// ── OSS build — policy-off is a pure no-op ──────────────────────────────────
|
||||
|
||||
assert.equal(
|
||||
deps.calls.mergeSaveSubscriptionKinds.length,
|
||||
0,
|
||||
"should not call mergeSaveSubscriptionKinds in OSS build",
|
||||
);
|
||||
assert.equal(
|
||||
deps.calls.setExplicitChoice.length,
|
||||
0,
|
||||
"should not persist explicit choice in OSS build",
|
||||
);
|
||||
test("test_oss_marker_null_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_merge_failure_does_not_persist_explicit_choice", async () => {
|
||||
const deps = makeDeps({
|
||||
defaultOn: true,
|
||||
hasExplicitChoice: false,
|
||||
mergeShouldFail: true,
|
||||
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 () => {
|
||||
const deps = makeDeps({ policyOn: true, mergeShouldFail: true });
|
||||
|
||||
await assert.rejects(() => reconcileObserverArchive("pk1", deps), {
|
||||
message: "merge failed",
|
||||
});
|
||||
await runSeed("pubkey123", deps);
|
||||
|
||||
assert.equal(
|
||||
deps.calls.setExplicitChoice.length,
|
||||
deps.calls.setChoice.length,
|
||||
0,
|
||||
"should NOT persist explicit choice after a transient merge failure",
|
||||
"must not persist marker on merge failure",
|
||||
);
|
||||
});
|
||||
|
||||
test("test_empty_pubkey_does_nothing", async () => {
|
||||
const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false });
|
||||
await runSeed("", deps);
|
||||
test("test_flag_check_failure_rejects", async () => {
|
||||
const deps = makeDeps({ flagShouldFail: true });
|
||||
|
||||
assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0);
|
||||
assert.equal(deps.calls.setExplicitChoice.length, 0);
|
||||
await assert.rejects(() => reconcileObserverArchive("pk1", deps), {
|
||||
message: "flag check failed",
|
||||
});
|
||||
assert.equal(deps.calls.merge.length, 0);
|
||||
assert.equal(deps.calls.setChoice.length, 0);
|
||||
});
|
||||
|
||||
test("test_undefined_pubkey_does_nothing", async () => {
|
||||
const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false });
|
||||
await runSeed(undefined, deps);
|
||||
// ── Startup ordering (real ArchiveSyncManager + real reconciler) ─────────────
|
||||
|
||||
assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0);
|
||||
assert.equal(deps.calls.setExplicitChoice.length, 0);
|
||||
test("test_archive_sync_blocked_until_reconciliation", async () => {
|
||||
let resolveFlag;
|
||||
const flagPromise = new Promise((resolve) => {
|
||||
resolveFlag = resolve;
|
||||
});
|
||||
|
||||
const subscribeCalls = [];
|
||||
const fakeRelay = {
|
||||
subscribeLive(filter, _callback) {
|
||||
subscribeCalls.push(filter);
|
||||
return Promise.resolve(async () => {});
|
||||
},
|
||||
};
|
||||
|
||||
const reconcilerDeps = {
|
||||
observerArchiveDefaultEnabled: () => flagPromise,
|
||||
mergeSaveSubscriptionKinds: async () => {},
|
||||
setExplicitChoice: () => {},
|
||||
};
|
||||
|
||||
const manager = new ArchiveSyncManager({
|
||||
relayClient: fakeRelay,
|
||||
listSaveSubscriptions: async () => [
|
||||
{
|
||||
scopeType: "owner_p",
|
||||
scopeValue: "pk1",
|
||||
kinds: [24200],
|
||||
identityPubkey: "pk1",
|
||||
relayUrl: "wss://r",
|
||||
createdAt: 0,
|
||||
},
|
||||
],
|
||||
archiveEvents: async () => ({ persisted: 0, dropped: 0 }),
|
||||
onSubscriptionChange: () => () => {},
|
||||
});
|
||||
|
||||
// Start reconciliation (pending — flag check not yet resolved).
|
||||
const reconciling = reconcileObserverArchive("pk1", reconcilerDeps);
|
||||
|
||||
// Before reconciliation resolves, manager must not have been started.
|
||||
await tick();
|
||||
assert.equal(
|
||||
subscribeCalls.length,
|
||||
0,
|
||||
"subscribeLive must not run before reconciliation",
|
||||
);
|
||||
|
||||
// Resolve reconciliation — now start the manager (simulating the gate).
|
||||
resolveFlag(true);
|
||||
await reconciling;
|
||||
await manager.start();
|
||||
|
||||
assert.ok(
|
||||
subscribeCalls.length > 0,
|
||||
"subscribeLive must run after gate opens",
|
||||
);
|
||||
const hasOwnerP = subscribeCalls.some((f) => f["#p"]?.length > 0);
|
||||
assert.ok(hasOwnerP, "subscription must use owner_p (#p) filter");
|
||||
const hasKind24200 = subscribeCalls.some((f) => f.kinds?.includes(24200));
|
||||
assert.ok(hasKind24200, "subscription filter must include kind 24200");
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("test_archive_sync_blocked_on_reconciliation_rejection", async () => {
|
||||
const reconcilerDeps = makeDeps({ policyOn: true, mergeShouldFail: true });
|
||||
|
||||
const subscribeCalls = [];
|
||||
const fakeRelay = {
|
||||
subscribeLive(filter) {
|
||||
subscribeCalls.push(filter);
|
||||
return Promise.resolve(async () => {});
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new ArchiveSyncManager({
|
||||
relayClient: fakeRelay,
|
||||
listSaveSubscriptions: async () => [
|
||||
{
|
||||
scopeType: "owner_p",
|
||||
scopeValue: "pk1",
|
||||
kinds: [24200],
|
||||
identityPubkey: "pk1",
|
||||
relayUrl: "wss://r",
|
||||
createdAt: 0,
|
||||
},
|
||||
],
|
||||
archiveEvents: async () => ({ persisted: 0, dropped: 0 }),
|
||||
onSubscriptionChange: () => () => {},
|
||||
});
|
||||
|
||||
// Reconciliation rejects — gate must remain closed.
|
||||
let rejected = false;
|
||||
try {
|
||||
await reconcileObserverArchive("pk1", reconcilerDeps);
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
assert.ok(rejected, "reconciliation must reject on merge failure");
|
||||
|
||||
// Manager must NOT start after failed reconciliation.
|
||||
assert.equal(
|
||||
subscribeCalls.length,
|
||||
0,
|
||||
"subscribeLive must not run after failed reconciliation",
|
||||
);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
// ── Identity-scoped readiness (exercises exported isReconciledFor) ──────────
|
||||
|
||||
test("test_isReconciledFor_null_returns_false", () => {
|
||||
assert.equal(isReconciledFor(null, "pk1"), false);
|
||||
assert.equal(isReconciledFor(null, undefined), false);
|
||||
});
|
||||
|
||||
test("test_isReconciledFor_undefined_pubkey_returns_false", () => {
|
||||
assert.equal(isReconciledFor("pk1", undefined), false);
|
||||
});
|
||||
|
||||
test("test_isReconciledFor_matching_returns_true", () => {
|
||||
assert.equal(isReconciledFor("pk1", "pk1"), true);
|
||||
});
|
||||
|
||||
test("test_isReconciledFor_mismatch_returns_false", () => {
|
||||
assert.equal(isReconciledFor("pkA", "pkB"), false);
|
||||
});
|
||||
|
||||
test("test_identity_change_resets_readiness", async () => {
|
||||
let reconciledPubkey = null;
|
||||
|
||||
// Identity A reconciles successfully.
|
||||
const depsA = makeDeps({ policyOn: true });
|
||||
await reconcileObserverArchive("pkA", depsA);
|
||||
reconciledPubkey = "pkA";
|
||||
assert.equal(
|
||||
isReconciledFor(reconciledPubkey, "pkA"),
|
||||
true,
|
||||
"A is reconciled",
|
||||
);
|
||||
|
||||
// Identity changes to B — gate must be false before B reconciles.
|
||||
assert.equal(
|
||||
isReconciledFor(reconciledPubkey, "pkB"),
|
||||
false,
|
||||
"gate must be false for new identity before reconciliation",
|
||||
);
|
||||
|
||||
// B reconciles successfully.
|
||||
const depsB = makeDeps({ policyOn: true });
|
||||
await reconcileObserverArchive("pkB", depsB);
|
||||
reconciledPubkey = "pkB";
|
||||
assert.equal(
|
||||
isReconciledFor(reconciledPubkey, "pkB"),
|
||||
true,
|
||||
"B is reconciled",
|
||||
);
|
||||
assert.equal(
|
||||
isReconciledFor(reconciledPubkey, "pkA"),
|
||||
false,
|
||||
"gate must be false for previous identity",
|
||||
);
|
||||
});
|
||||
|
||||
test("test_identity_change_b_failure_stays_closed", async () => {
|
||||
let reconciledPubkey = null;
|
||||
|
||||
// Identity A reconciles successfully.
|
||||
const depsA = makeDeps({ policyOn: true });
|
||||
await reconcileObserverArchive("pkA", depsA);
|
||||
reconciledPubkey = "pkA";
|
||||
|
||||
// Identity changes to B — B's reconciliation fails.
|
||||
const depsB = makeDeps({ policyOn: true, mergeShouldFail: true });
|
||||
try {
|
||||
await reconcileObserverArchive("pkB", depsB);
|
||||
reconciledPubkey = "pkB";
|
||||
} catch {
|
||||
// B failed — reconciledPubkey stays "pkA" (stale).
|
||||
}
|
||||
|
||||
// Gate for B must be false (stale A pubkey !== current B).
|
||||
assert.equal(
|
||||
isReconciledFor(reconciledPubkey, "pkB"),
|
||||
false,
|
||||
"gate must be false when B reconciliation fails",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Metric seed independence ─────────────────────────────────────────────────
|
||||
|
||||
test("test_metric_seed_remains_independently_deferrable", async () => {
|
||||
const deps = makeDeps({ policyOn: true });
|
||||
await reconcileObserverArchive("pk1", deps);
|
||||
|
||||
assert.equal(deps.calls.merge.length, 1);
|
||||
assert.equal(deps.calls.merge[0].kind, 24200, "must only touch kind 24200");
|
||||
});
|
||||
|
||||
@@ -1,20 +1,3 @@
|
||||
/**
|
||||
* First-run seeding for observer-feed archive.
|
||||
*
|
||||
* When an internal build has `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` set and the
|
||||
* current identity has not yet made an explicit choice, this hook
|
||||
* auto-creates an `owner_p` save subscription including kind 24200 observer
|
||||
* frames, scoped to the current identity's pubkey.
|
||||
*
|
||||
* Uses `mergeSaveSubscriptionKinds` (atomic DB-side merge) so a concurrently
|
||||
* running metric seed (44200) cannot clobber this kind — the union happens
|
||||
* under a single SQLite transaction regardless of await ordering.
|
||||
*
|
||||
* OSS builds return `false` from `observer_archive_default_enabled` → no-op.
|
||||
* After any explicit user action (seeding or opt-out), the localStorage flag
|
||||
* prevents re-seeding on subsequent starts.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { KIND_AGENT_OBSERVER_FRAME } from "@/shared/constants/kinds";
|
||||
@@ -22,93 +5,94 @@ import {
|
||||
mergeSaveSubscriptionKinds,
|
||||
observerArchiveDefaultEnabled,
|
||||
} from "@/shared/api/tauriArchive";
|
||||
import {
|
||||
hasExplicitObserverArchiveChoice,
|
||||
setExplicitObserverArchiveChoice,
|
||||
} from "./observerArchivePreference";
|
||||
import { setExplicitObserverArchiveChoice } from "./observerArchivePreference";
|
||||
|
||||
/**
|
||||
* Deps interface for testing. Production callers pass nothing.
|
||||
*/
|
||||
export interface ObserverArchiveSeedDeps {
|
||||
observerArchiveDefaultEnabled: () => Promise<boolean>;
|
||||
mergeSaveSubscriptionKinds: (kind: number) => Promise<void>;
|
||||
hasExplicitChoice: (pubkey: string) => boolean;
|
||||
setExplicitChoice: (pubkey: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const defaultDeps: ObserverArchiveSeedDeps = {
|
||||
observerArchiveDefaultEnabled,
|
||||
mergeSaveSubscriptionKinds,
|
||||
hasExplicitChoice: hasExplicitObserverArchiveChoice,
|
||||
setExplicitChoice: setExplicitObserverArchiveChoice,
|
||||
};
|
||||
|
||||
/**
|
||||
* Seed the observer-feed archive subscription for `pubkey` once per identity
|
||||
* per device on internal builds.
|
||||
* Reconcile observer-feed archive state for `pubkey`.
|
||||
*
|
||||
* @param pubkey - current identity pubkey. When undefined (identity not yet
|
||||
* loaded), the hook waits until it becomes available.
|
||||
* @param deps - optional dep-injection for tests.
|
||||
* Internal builds (policy flag ON): unconditionally ensure kind 24200 exists
|
||||
* in the DB subscription, regardless of localStorage marker state.
|
||||
*
|
||||
* OSS builds (policy flag OFF): no-op. The Settings toggle is the only
|
||||
* mutation path for OSS users.
|
||||
*
|
||||
* Rejects on failure — callers must not open archive listeners against
|
||||
* unreconciled state.
|
||||
*/
|
||||
export function useObserverArchiveSeed(
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure gate: `true` iff `reconciledPubkey` matches `currentPubkey`.
|
||||
* Exported for direct unit testing without React mount infra.
|
||||
*/
|
||||
export function isReconciledFor(
|
||||
reconciledPubkey: string | null,
|
||||
currentPubkey: string | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
reconciledPubkey !== null &&
|
||||
currentPubkey !== undefined &&
|
||||
reconciledPubkey === currentPubkey
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs observer archive reconciliation eagerly when `pubkey` resolves.
|
||||
* Returns `true` only after successful reconciliation for the current
|
||||
* pubkey — archive sync must not start until this is `true`.
|
||||
*
|
||||
* Identity-scoped: changing pubkey resets readiness so the old manager
|
||||
* tears down before the new identity's reconciliation completes.
|
||||
*
|
||||
* On failure: stays `false`; the reconciler retries on next app startup
|
||||
* since no success marker is persisted.
|
||||
*/
|
||||
export function useObserverArchiveReconciliation(
|
||||
pubkey: string | undefined,
|
||||
deps: ObserverArchiveSeedDeps = defaultDeps,
|
||||
): void {
|
||||
): boolean {
|
||||
const [reconciledPubkey, setReconciledPubkey] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pubkey) return;
|
||||
|
||||
// Already made an explicit choice for this identity — never re-seed.
|
||||
if (deps.hasExplicitChoice(pubkey)) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function maybeSeed(): Promise<void> {
|
||||
// pubkey is checked above but TypeScript doesn't narrow across the async
|
||||
// boundary — re-guard here so the call below is type-safe.
|
||||
if (!pubkey) return;
|
||||
|
||||
let defaultOn: boolean;
|
||||
try {
|
||||
defaultOn = await deps.observerArchiveDefaultEnabled();
|
||||
} catch (err) {
|
||||
console.warn("[useObserverArchiveSeed] flag check failed:", err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (!defaultOn) {
|
||||
// OSS build (flag off): don't persist a choice — leave null so seeding
|
||||
// can still fire if this identity later runs an internal build.
|
||||
return;
|
||||
}
|
||||
|
||||
// Internal build + no prior choice → auto-seed via atomic DB merge.
|
||||
try {
|
||||
await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[useObserverArchiveSeed] mergeSaveSubscriptionKinds failed:",
|
||||
err,
|
||||
);
|
||||
// Do NOT set the localStorage flag — a transient failure (relay
|
||||
// unreachable, archive DB not yet initialized) should retry on next
|
||||
// startup rather than permanently suppress seeding.
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// Persist the explicit choice so this never re-fires.
|
||||
deps.setExplicitChoice(pubkey, true);
|
||||
}
|
||||
|
||||
void maybeSeed();
|
||||
reconcileObserverArchive(pubkey, deps)
|
||||
.then(() => {
|
||||
if (!cancelled) setReconciledPubkey(pubkey);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[useObserverArchiveReconciliation] failed:", err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pubkey, deps]);
|
||||
|
||||
return isReconciledFor(reconciledPubkey, pubkey);
|
||||
}
|
||||
|
||||
@@ -88,11 +88,11 @@ function decodeRawSubscription(raw: RawSaveSubscription): SaveSubscription {
|
||||
// ── API wrappers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns `true` when the build has observer-feed archive default-on.
|
||||
* Returns `true` when observer-feed archive policy is enforced.
|
||||
*
|
||||
* Internal builds set `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` at build time;
|
||||
* OSS builds never set it, so this returns `false`. The frontend calls this
|
||||
* once at startup to decide whether to auto-seed an `owner_p` subscription.
|
||||
* every startup to decide whether to reconcile the `owner_p` subscription.
|
||||
*/
|
||||
export async function observerArchiveDefaultEnabled(): Promise<boolean> {
|
||||
return invokeTauri<boolean>("observer_archive_default_enabled");
|
||||
|
||||
@@ -240,6 +240,8 @@ type E2eConfig = {
|
||||
// Seed rows returned by `list_save_subscriptions`. Each entry uses the same
|
||||
// snake_case wire shape the Rust backend returns so tests can drive the
|
||||
// LocalArchiveSettingsCard without a real SQLite database.
|
||||
observerArchiveDefaultEnabled?: boolean;
|
||||
agentMetricArchiveDefaultEnabled?: boolean;
|
||||
saveSubscriptions?: Array<{
|
||||
scope_type: string;
|
||||
scope_value: string;
|
||||
@@ -9782,6 +9784,16 @@ export function maybeInstallE2eTauriMocks() {
|
||||
// seeds the initial list; create/delete return success shapes so the
|
||||
// component's reload path behaves correctly.
|
||||
case "list_save_subscriptions": {
|
||||
const win = window as unknown as Record<string, unknown>;
|
||||
if (!win.__BUZZ_E2E_IPC_COUNTERS__) {
|
||||
win.__BUZZ_E2E_IPC_COUNTERS__ = {};
|
||||
}
|
||||
const ipcCounters = win.__BUZZ_E2E_IPC_COUNTERS__ as Record<
|
||||
string,
|
||||
number
|
||||
>;
|
||||
ipcCounters.list_save_subscriptions =
|
||||
(ipcCounters.list_save_subscriptions ?? 0) + 1;
|
||||
const ident = activeConfig?.identity ?? DEFAULT_MOCK_IDENTITY;
|
||||
return (activeConfig?.mock?.saveSubscriptions ?? []).map((s) => ({
|
||||
identity_pubkey: ident.pubkey,
|
||||
@@ -9802,6 +9814,14 @@ export function maybeInstallE2eTauriMocks() {
|
||||
case "archive_events":
|
||||
// Returns the ArchiveBatchResult shape the UI expects.
|
||||
return { persisted: 0, dropped: 0 };
|
||||
case "observer_archive_default_enabled":
|
||||
return activeConfig?.mock?.observerArchiveDefaultEnabled ?? false;
|
||||
case "agent_metric_archive_default_enabled":
|
||||
return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? false;
|
||||
case "merge_save_subscription_kinds":
|
||||
return null;
|
||||
case "remove_save_subscription_kind":
|
||||
return null;
|
||||
default:
|
||||
throw new Error(`Unsupported mocked Tauri command: ${command}`);
|
||||
}
|
||||
|
||||
124
desktop/tests/e2e/observer-archive-policy.spec.ts
Normal file
124
desktop/tests/e2e/observer-archive-policy.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
async function openLocalArchiveSettings(page: import("@playwright/test").Page) {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("open-settings").click();
|
||||
await page.getByTestId("profile-popover-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
await page.getByTestId("settings-nav-local-archive").click();
|
||||
const card = page.getByTestId("settings-local-archive");
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
return card;
|
||||
}
|
||||
|
||||
test.describe("observer archive policy — Settings toggle", () => {
|
||||
test("internal policy: toggle disabled with policy-locked copy", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
observerArchiveDefaultEnabled: true,
|
||||
saveSubscriptions: [
|
||||
{
|
||||
scope_type: "owner_p",
|
||||
scope_value: "deadbeef".repeat(8),
|
||||
kinds: "[24200]",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const card = await openLocalArchiveSettings(page);
|
||||
const toggle = card.getByTestId("local-archive-observer-toggle");
|
||||
await expect(toggle).toBeVisible({ timeout: 5_000 });
|
||||
await expect(toggle).toBeDisabled();
|
||||
await expect(
|
||||
card.getByText(/always on for internal builds/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("OSS policy: toggle is functional", async ({ page }) => {
|
||||
await installMockBridge(page, {
|
||||
observerArchiveDefaultEnabled: false,
|
||||
saveSubscriptions: [
|
||||
{
|
||||
scope_type: "owner_p",
|
||||
scope_value: "deadbeef".repeat(8),
|
||||
kinds: "[24200]",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const card = await openLocalArchiveSettings(page);
|
||||
const toggle = card.getByTestId("local-archive-observer-toggle");
|
||||
await expect(toggle).toBeVisible({ timeout: 5_000 });
|
||||
await expect(toggle).toBeEnabled();
|
||||
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.
|
||||
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).toBeEnabled();
|
||||
await expect(toggle).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("observer archive policy — reconciliation gate", () => {
|
||||
test("internal policy: archive sync reaches subscription path after reconciliation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
observerArchiveDefaultEnabled: true,
|
||||
saveSubscriptions: [
|
||||
{
|
||||
scope_type: "owner_p",
|
||||
scope_value: "deadbeef".repeat(8),
|
||||
kinds: "[24200]",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Wait for the channel list to appear (proves AppShell mounted fully).
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// The reconciliation gate (useObserverArchiveReconciliation) must have
|
||||
// resolved successfully, allowing useArchiveSync to start the
|
||||
// ArchiveSyncManager, which calls list_save_subscriptions. The IPC
|
||||
// counter proves the subscription path was reached.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const counters = (window as Record<string, unknown>)
|
||||
.__BUZZ_E2E_IPC_COUNTERS__ as Record<string, number> | undefined;
|
||||
return (counters?.list_save_subscriptions ?? 0) > 0;
|
||||
},
|
||||
null,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
const count = await page.evaluate(() => {
|
||||
const counters = (window as Record<string, unknown>)
|
||||
.__BUZZ_E2E_IPC_COUNTERS__ as Record<string, number> | undefined;
|
||||
return counters?.list_save_subscriptions ?? 0;
|
||||
});
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -212,6 +212,12 @@ type MockBridgeOptions = {
|
||||
nostrBindSignDelayMs?: number;
|
||||
stallWebsocketSends?: boolean;
|
||||
userSearchDelayMs?: number;
|
||||
/**
|
||||
* Value returned by the `observer_archive_default_enabled` mock command.
|
||||
* `true` = internal-policy build (toggle locked ON); `false`/omitted = OSS
|
||||
* build (toggle functional). Drives LocalArchiveSettingsCard policy state.
|
||||
*/
|
||||
observerArchiveDefaultEnabled?: boolean;
|
||||
// NIP-IA gate inputs — drive the archive-button gate matrix in
|
||||
// tests/e2e/identity-archive.spec.ts.
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user