mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): pass-2 review corrections (round 3) — captured respawn, behavioral tests, residue
Global-config respawn: replace live-scope wrappers with one transition epoch.
restart_under_captured_epoch acquires managed_agent_runtime_transition +
managed_agents_store_lock, validates captured generation, stops, builds a
captured spawn context (records/personas/global config/owner hex/scope_id
from captured_scope), spawns/registers, saves — no live wrapper past the stop.
persist_last_error takes captured_scope, validates generation under its own
acquired store lock, never called while that lock is held, fails closed on
poison. Two tauri::test::mock_app() tests: fresh-scope no-runtime → Skipped
(proves generation guard passes + path proceeds), stale-scope → Skipped at
generation step (switch-between-stop-and-spawn test, calls production fn).
Behavioral tests on production paths: tauri = { version = "2", features = ["test"] }
added to dev-dependencies. compensate_drain_for extracted with injectable
start_fn; tests call it directly (compensate_drain_for is the production core).
capture_agent_snapshot_import_entry and capture_team_snapshot_import_entry
extracted as testable entry guards; tests call the real production functions
(no-scope reject, owner-mismatch reject, matching-owner passes + relay verified,
stale-generation rejects validate_scope_generation). registerNestNotifications
extracted from useNestNotifications; test imports and calls the real function,
asserts all three event registrations, workspace-degraded toast payload,
unlisten cleanup per event. fail_if_client_mesh_active and mesh_stop_client
tested via tauri::test::mock_builder() with real AppHandle (no-runtime,
client-runtime, no-runtime stop paths). Generalized fail_if_client_mesh_active
and mesh_stop_client to tauri::Runtime to allow mock_app usage.
Residue: _compensation_gate_removed placeholder deleted from AppState.
identity.rs:346-348 comment corrected (guard passed by value, not dropped).
mesh_llm_scope.rs:59-64 stale re-arm comment replaced with accurate Option A note.
Duplicate generation bump at identity.rs:544 removed (clear_active_scope
calls next_scope_generation internally; no second bump needed).
File-size gate: AgentSnapshotImportEntry + capture_agent_snapshot_import_entry
extracted to import_entry.rs (import.rs: 995 lines); TeamSnapshotImportEntry +
capture_team_snapshot_import_entry extracted to team_snapshot_entry.rs
(team_snapshot.rs: 997 lines). Both within the 1000-line ratchet.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
co-authored by
Will Pfleger
parent
6dc4e29155
commit
ff16a80b5b
@@ -1,126 +1,139 @@
|
||||
/**
|
||||
* Behavioral tests for useNestNotifications.
|
||||
* Behavioral tests for useNestNotifications / registerNestNotifications.
|
||||
*
|
||||
* Verifies that:
|
||||
* - The hook registers a listener for `workspace-degraded` on mount.
|
||||
* - When the event fires, `toast.error` is called with the payload.
|
||||
* - The listener is cleaned up (unlisten called) on unmount.
|
||||
* Tests call `registerNestNotifications` — the extracted production
|
||||
* registration helper used by `useNestNotifications` inside its `useEffect`.
|
||||
* This is the real production function, not a reconstruction of its logic.
|
||||
*
|
||||
* Uses a module-scope mock for @tauri-apps/api/event and sonner so we can
|
||||
* drive events synchronously without a real Tauri runtime.
|
||||
* Proves:
|
||||
* - `registerNestNotifications` registers listeners for all three event names
|
||||
* (repos-dir-error, legacy-nest-migrated, workspace-degraded) by inspecting
|
||||
* the `listenFn` call record.
|
||||
* - When `workspace-degraded` fires, `toast.error` is called with the payload.
|
||||
* - The returned cleanup function calls every unlisten function.
|
||||
* - The event name and payload wiring survive rename/refactor of the production
|
||||
* code (the test would fail if the event name or toast call were deleted).
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// ── Minimal React/hook harness ───────────────────────────────────────────────
|
||||
// useNestNotifications is a React hook (calls useEffect). We run it by
|
||||
// providing a minimal shim that invokes the cleanup function directly, making
|
||||
// the test synchronous and environment-free.
|
||||
const effectCallbacks = [];
|
||||
const effectCleanups = [];
|
||||
globalThis.React = {
|
||||
useEffect: (cb) => {
|
||||
const cleanup = cb();
|
||||
effectCallbacks.push(cb);
|
||||
effectCleanups.push(cleanup);
|
||||
},
|
||||
};
|
||||
import { registerNestNotifications } from "./useNestNotifications.ts";
|
||||
|
||||
// ── Mocks for @tauri-apps/api/event and sonner ──────────────────────────────
|
||||
let registeredListeners = [];
|
||||
const toastCalls = [];
|
||||
test("registerNestNotifications: registers listeners for all three events", () => {
|
||||
const registeredEvents = [];
|
||||
const unlistenFns = [];
|
||||
|
||||
globalThis.__mockListen = (event, handler) => {
|
||||
registeredListeners.push({ event, handler });
|
||||
// Return a promise that resolves to the unlisten function (matches Tauri API).
|
||||
return Promise.resolve(() => {
|
||||
registeredListeners = registeredListeners.filter(
|
||||
(l) => l.event !== event || l.handler !== handler,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.__mockToastError = (title, options) => {
|
||||
toastCalls.push({ title, options });
|
||||
};
|
||||
|
||||
// ── Module stubs are injected before import via globalThis ──────────────────
|
||||
// The hook imports "@tauri-apps/api/event" { listen } and "sonner" { toast }.
|
||||
// We inject module-resolution stubs via the TypeScript transpiler shim.
|
||||
// Since the test runner resolves bare imports, we patch globalThis so the TS
|
||||
// transpilation sees them.
|
||||
//
|
||||
// Instead of running the hook (which requires a full Node ESM module mock),
|
||||
// we exercise the behavioral contract directly: a captured `listen` call with
|
||||
// event="workspace-degraded" whose handler calls toast.error.
|
||||
|
||||
test("workspace-degraded listener calls toast.error with payload", () => {
|
||||
const toastErrorCalls = [];
|
||||
const unlistenCalls = [];
|
||||
|
||||
// Construct the exact handler the hook registers, extracted here for direct
|
||||
// verification. This mirrors the hook's implementation and verifies the
|
||||
// behavior described in the work order.
|
||||
//
|
||||
// Real contract: listen("workspace-degraded", (event) => toast.error(...))
|
||||
const fakeToast = {
|
||||
error: (title, opts) => toastErrorCalls.push({ title, opts }),
|
||||
const mockListen = (event, _handler) => {
|
||||
registeredEvents.push(event);
|
||||
const unlistenFn = () => unlistenFns.push(event);
|
||||
return Promise.resolve(unlistenFn);
|
||||
};
|
||||
const _fakeUnlisten = () => unlistenCalls.push("workspace-degraded");
|
||||
|
||||
// Simulate the handler registration and invocation.
|
||||
const handler = (event) => {
|
||||
fakeToast.error("Workspace partially degraded", {
|
||||
description: event.payload,
|
||||
const mockToast = { error: () => {}, success: () => {} };
|
||||
|
||||
const cleanup = registerNestNotifications(mockListen, mockToast);
|
||||
|
||||
assert.deepEqual(
|
||||
registeredEvents.sort(),
|
||||
["legacy-nest-migrated", "repos-dir-error", "workspace-degraded"].sort(),
|
||||
"must register listeners for all three events",
|
||||
);
|
||||
|
||||
// Cleanup calls all three unlisten functions.
|
||||
return Promise.resolve()
|
||||
.then(() => {
|
||||
cleanup();
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
})
|
||||
.then(() => {
|
||||
assert.equal(
|
||||
unlistenFns.length,
|
||||
3,
|
||||
"cleanup must call unlisten for all three events",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("registerNestNotifications: workspace-degraded fires toast.error with payload", () => {
|
||||
const toastCalls = [];
|
||||
|
||||
let degradedHandler = null;
|
||||
const mockListen = (event, handler) => {
|
||||
if (event === "workspace-degraded") {
|
||||
degradedHandler = handler;
|
||||
}
|
||||
return Promise.resolve(() => {});
|
||||
};
|
||||
|
||||
// Invoke handler as if the event fired with a degradation message.
|
||||
handler({ payload: "restore failed: agent runtime could not be restarted" });
|
||||
const mockToast = {
|
||||
error: (title, opts) => toastCalls.push({ title, opts }),
|
||||
success: () => {},
|
||||
};
|
||||
|
||||
assert.equal(toastErrorCalls.length, 1, "toast.error must be called once");
|
||||
assert.equal(toastErrorCalls[0].title, "Workspace partially degraded");
|
||||
registerNestNotifications(mockListen, mockToast);
|
||||
|
||||
assert.ok(degradedHandler, "workspace-degraded handler must be registered");
|
||||
|
||||
// Fire the event as the Tauri event system would.
|
||||
degradedHandler({
|
||||
payload: "restore failed: agent runtime could not be restarted",
|
||||
});
|
||||
|
||||
assert.equal(toastCalls.length, 1, "toast.error must be called once");
|
||||
assert.equal(toastCalls[0].title, "Workspace partially degraded");
|
||||
assert.equal(
|
||||
toastErrorCalls[0].opts.description,
|
||||
toastCalls[0].opts.description,
|
||||
"restore failed: agent runtime could not be restarted",
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace-degraded listener cleanup unlists on unmount", () => {
|
||||
// Verify that listen returns a promise whose resolution is the unlisten
|
||||
// function, and that the hook's cleanup calls it.
|
||||
let unlistenWasCalled = false;
|
||||
const unlistenFn = () => {
|
||||
unlistenWasCalled = true;
|
||||
test("registerNestNotifications: cleanup calls all unlisten functions", () => {
|
||||
let unlistenCallCount = 0;
|
||||
const unlisten = () => {
|
||||
unlistenCallCount++;
|
||||
};
|
||||
const listenPromise = Promise.resolve(unlistenFn);
|
||||
const mockListen = (_event, _handler) => Promise.resolve(unlisten);
|
||||
const mockToast = { error: () => {}, success: () => {} };
|
||||
|
||||
// Simulate the cleanup path: the hook calls `unlisten.then((fn) => fn())`
|
||||
void listenPromise.then((fn) => fn());
|
||||
const cleanup = registerNestNotifications(mockListen, mockToast);
|
||||
|
||||
// Flush the microtask queue.
|
||||
return listenPromise.then(() => {
|
||||
assert.equal(
|
||||
unlistenWasCalled,
|
||||
true,
|
||||
"unlisten must be called when the hook unmounts",
|
||||
);
|
||||
});
|
||||
// Call cleanup after promises resolve.
|
||||
return new Promise((resolve) => setTimeout(resolve, 0))
|
||||
.then(() => {
|
||||
cleanup();
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
})
|
||||
.then(() => {
|
||||
assert.equal(
|
||||
unlistenCallCount,
|
||||
3,
|
||||
"cleanup must call unlisten for all three registered listeners",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace-degraded handler passes raw payload as description", () => {
|
||||
// Boundary check: empty payload, long payload, special characters.
|
||||
test("registerNestNotifications: workspace-degraded handler passes raw payload as description", () => {
|
||||
const cases = ["", "a".repeat(500), "error: file not found\npath: /foo/bar"];
|
||||
|
||||
for (const payload of cases) {
|
||||
const toastArgs = [];
|
||||
const handler = (event) => {
|
||||
toastArgs.push({
|
||||
title: "Workspace partially degraded",
|
||||
description: event.payload,
|
||||
});
|
||||
let handler = null;
|
||||
|
||||
const mockListen = (event, h) => {
|
||||
if (event === "workspace-degraded") handler = h;
|
||||
return Promise.resolve(() => {});
|
||||
};
|
||||
const mockToast = {
|
||||
error: (title, opts) =>
|
||||
toastArgs.push({ title, description: opts?.description }),
|
||||
success: () => {},
|
||||
};
|
||||
|
||||
registerNestNotifications(mockListen, mockToast);
|
||||
assert.ok(handler, "workspace-degraded handler must be set");
|
||||
handler({ payload });
|
||||
|
||||
assert.equal(toastArgs.length, 1);
|
||||
assert.equal(toastArgs[0].description, payload);
|
||||
toastArgs.length = 0;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,53 +5,65 @@ import { toast } from "sonner";
|
||||
const MIGRATION_TOAST_KEY = "buzz-legacy-nest-migrated-notified";
|
||||
|
||||
/**
|
||||
* Surface nest-related backend events as toasts.
|
||||
* Register all nest-related backend event listeners.
|
||||
*
|
||||
* Extracted for testability: accepts `listenFn` and `toastFn` as parameters
|
||||
* so unit tests can inject mocks without a Tauri runtime. The production hook
|
||||
* calls this with the real `listen` and `toast`.
|
||||
*
|
||||
* Returns a cleanup function that calls every unlisten function.
|
||||
*
|
||||
* Covered events:
|
||||
* - `repos-dir-error`: a configured `repos_dir` failed to validate or its
|
||||
* symlink could not be applied (invalid path, downgrade refused, external
|
||||
* target gone). Emitted by `apply_workspace` on both the validate-reject
|
||||
* and the runtime symlink-failure paths, so a bad `repos_dir` is always
|
||||
* visibly surfaced rather than silently logged to console.
|
||||
* symlink could not be applied.
|
||||
* - `legacy-nest-migrated`: the agent's knowledge was carried over from a
|
||||
* legacy `~/.sprout` nest. Shown once per machine (deduped via
|
||||
* localStorage); the backend re-emits each launch while `~/.sprout` exists,
|
||||
* which also covers the event being emitted before this listener mounts.
|
||||
* legacy `~/.sprout` nest. Shown once per machine (deduped via localStorage).
|
||||
* - `workspace-degraded`: a post-commit restore step failed after the workspace
|
||||
* switch succeeded. The switch is live; the degradation is recoverable by
|
||||
* restarting the app or re-applying the workspace. Event-sync dispatch failure
|
||||
* does not emit this event (shutdown-time error, no toast surface exists).
|
||||
* switch succeeded. Event-sync dispatch failure does not emit this event
|
||||
* (shutdown-time error, no toast surface exists).
|
||||
*/
|
||||
export function registerNestNotifications(
|
||||
listenFn: typeof listen,
|
||||
toastFn: typeof toast,
|
||||
): () => void {
|
||||
const unlistenReposError = listenFn<string>("repos-dir-error", (event) => {
|
||||
toastFn.error("Repos directory not applied", {
|
||||
description: event.payload,
|
||||
});
|
||||
});
|
||||
|
||||
const unlistenMigrated = listenFn("legacy-nest-migrated", () => {
|
||||
if (localStorage.getItem(MIGRATION_TOAST_KEY) === "true") {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(MIGRATION_TOAST_KEY, "true");
|
||||
toastFn.success("Migrated notes from ~/.sprout", {
|
||||
description: "You can delete it to reclaim disk space.",
|
||||
});
|
||||
});
|
||||
|
||||
const unlistenDegraded = listenFn<string>("workspace-degraded", (event) => {
|
||||
toastFn.error("Workspace partially degraded", {
|
||||
description: event.payload,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
void unlistenReposError.then((fn) => fn());
|
||||
void unlistenMigrated.then((fn) => fn());
|
||||
void unlistenDegraded.then((fn) => fn());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface nest-related backend events as toasts.
|
||||
*
|
||||
* Mounted at the app root ahead of the community-init effect so the listener
|
||||
* is registered before the first `apply_workspace` call.
|
||||
*/
|
||||
export function useNestNotifications(): void {
|
||||
useEffect(() => {
|
||||
const unlistenReposError = listen<string>("repos-dir-error", (event) => {
|
||||
toast.error("Repos directory not applied", {
|
||||
description: event.payload,
|
||||
});
|
||||
});
|
||||
|
||||
const unlistenMigrated = listen("legacy-nest-migrated", () => {
|
||||
if (localStorage.getItem(MIGRATION_TOAST_KEY) === "true") {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(MIGRATION_TOAST_KEY, "true");
|
||||
toast.success("Migrated notes from ~/.sprout", {
|
||||
description: "You can delete it to reclaim disk space.",
|
||||
});
|
||||
});
|
||||
|
||||
const unlistenDegraded = listen<string>("workspace-degraded", (event) => {
|
||||
toast.error("Workspace partially degraded", {
|
||||
description: event.payload,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
void unlistenReposError.then((fn) => fn());
|
||||
void unlistenMigrated.then((fn) => fn());
|
||||
void unlistenDegraded.then((fn) => fn());
|
||||
};
|
||||
const cleanup = registerNestNotifications(listen, toast);
|
||||
return cleanup;
|
||||
}, []);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user