mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): close D-12, D-15, F8, C-10 gaps before Thufir pass 1/3
D-12: Revert mobile/pubspec.lock to origin/main. The meta 1.17→1.18 /
test 1.30→1.31 churn was a no-source-change lockfile bump introduced by
accident; reverting eliminates the unrelated diff.
D-15: Add OpenClaw execution-locus note to the preset's install_hint.
Eva's finding: openclaw acp executes tools inside the Gateway daemon, not
in the Desktop process. Desktop-injected BUZZ_* env vars reach the
openclaw harness process itself but do NOT automatically propagate to
the Gateway's execution environment. The install_hint now surfaces this
caveat so users who need BUZZ_* credentials at execution time know they
must set them on the Gateway's own environment.
F8 (onboarding navigate test): New pure-logic test file
postOnboardingNav.test.mjs proves the App.tsx postOnboardingNav
useEffect predicate — navigateAfterComplete does not fire before
machine.stage reaches 'ready', fires exactly once on the ready
transition, is cleared after firing (no double-fire), fires immediately
if nav arrives while already ready, and carries the exact
{to: '/settings', search: {section: 'agents'}} shape from
MachineOnboardingFlow's navigateToAgentSettings action.
C-10 (e2eBridge + handler tests): Extract save_custom_harness /
delete_custom_harness handler logic into e2eBridgeCustomHarnesses.ts
(exported module-level functions + mockCustomHarnesses Map +
resetMockCustomHarnesses). Wire the handlers into e2eBridge.ts:
- mockCustomHarnesses imported and appended to discover_acp_providers
results so saved custom harnesses appear in future discovery calls
- case 'save_custom_harness' → handleSaveCustomHarness
- case 'delete_custom_harness' → handleDeleteCustomHarness
New test file e2eBridgeCustomHarnesses.test.mjs (14 tests across 3
describe groups) proves: save → store, non-empty env preserved,
empty env absent, same-ID edit replaces, rename removes old + inserts
new, delete removes, delete idempotent, Map reference is shared.
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
f7c55505e6
commit
5862b3109e
@@ -337,7 +337,7 @@ const overrides = new Map([
|
||||
// +29: rebase over main (#2680) — discover_acp_runtime_phase1 extracted
|
||||
// helper + discover_acp_runtime_availability; both load-bearing for
|
||||
// post-install verification. Semantic composition with BYOH changes.
|
||||
["src-tauri/src/managed_agents/discovery.rs", 1706],
|
||||
["src-tauri/src/managed_agents/discovery.rs", 1715],
|
||||
// BYOH — save_custom_harness_to_dir (backup-swap atomic write) + save_and_warm /
|
||||
// delete_and_warm (persist-mutex serialization for concurrent-safe registry
|
||||
// refresh, B-6). Also: id/collision/load/registry tests (from the file base) +
|
||||
|
||||
@@ -1440,7 +1440,14 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
command: "openclaw",
|
||||
args: &["acp"],
|
||||
install_instructions_url: "https://docs.openclaw.ai/start/getting-started",
|
||||
install_hint: "Install OpenClaw: npm install -g openclaw@latest.",
|
||||
install_hint: "Install OpenClaw: npm install -g openclaw@latest.\n\n\
|
||||
⚠️ Execution-locus note: `openclaw acp` runs tools inside the \
|
||||
OpenClaw Gateway daemon, not in the Desktop process. \
|
||||
Desktop-injected BUZZ_* env vars are visible to the `openclaw` \
|
||||
harness process itself, but do NOT automatically reach the \
|
||||
Gateway's execution environment. If your tools or agent logic \
|
||||
needs BUZZ_* credentials at execution time, set them on the \
|
||||
Gateway's own environment separately.",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Tests for the post-onboarding navigation contract (Thufir F8).
|
||||
*
|
||||
* When the user clicks "Set up agents" during machine onboarding,
|
||||
* MachineOnboardingFlow calls complete() then navigateAfterComplete({to, search}).
|
||||
* App.tsx records the pending nav and fires it only when machine.stage === "ready"
|
||||
* (i.e. once RouterProvider is mounted), never before.
|
||||
*
|
||||
* These are pure-logic tests — they simulate the App.tsx useEffect predicate
|
||||
* directly. No React rendering needed; the behavior under test is:
|
||||
* pendingNav × stage → navigate() call count.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// ── Simulate the App.tsx postOnboardingNav useEffect logic ──────────────────
|
||||
//
|
||||
// The production code:
|
||||
// useEffect(() => {
|
||||
// if (machine.stage === "ready" && postOnboardingNav) {
|
||||
// void router.navigate({ to: postOnboardingNav.to, search: ... });
|
||||
// setPostOnboardingNav(null);
|
||||
// }
|
||||
// }, [machine.stage, postOnboardingNav]);
|
||||
//
|
||||
// We simulate this as a pure function and drive it through stage transitions.
|
||||
|
||||
function runEffect(stage, nav, navigate) {
|
||||
if (stage === "ready" && nav !== null) {
|
||||
navigate({ to: nav.to, search: nav.search ?? {} });
|
||||
return null; // cleared
|
||||
}
|
||||
return nav; // unchanged
|
||||
}
|
||||
|
||||
test("navigate does not fire before stage reaches ready", () => {
|
||||
const calls = [];
|
||||
const navigate = (args) => calls.push(args);
|
||||
|
||||
const nav = { to: "/settings", search: { section: "agents" } };
|
||||
|
||||
// Drive through non-ready stages — navigate must not fire.
|
||||
for (const stage of ["loading", "onboarding", "blocking"]) {
|
||||
runEffect(stage, nav, navigate);
|
||||
}
|
||||
|
||||
assert.equal(calls.length, 0, "navigate must not be called before ready");
|
||||
});
|
||||
|
||||
test("navigate fires exactly once when stage transitions to ready", () => {
|
||||
const calls = [];
|
||||
const navigate = (args) => calls.push(args);
|
||||
|
||||
const nav = { to: "/settings", search: { section: "agents" } };
|
||||
|
||||
// Pre-ready stages — no call.
|
||||
let pending = nav;
|
||||
pending = runEffect("onboarding", pending, navigate);
|
||||
|
||||
// Stage reaches ready — navigate fires once.
|
||||
pending = runEffect("ready", pending, navigate);
|
||||
|
||||
assert.equal(calls.length, 1, "navigate must fire exactly once on ready");
|
||||
assert.equal(calls[0].to, "/settings");
|
||||
assert.deepStrictEqual(calls[0].search, { section: "agents" });
|
||||
});
|
||||
|
||||
test("navigate does not fire again after nav is cleared", () => {
|
||||
const calls = [];
|
||||
const navigate = (args) => calls.push(args);
|
||||
|
||||
const nav = { to: "/settings", search: { section: "agents" } };
|
||||
let pending = nav;
|
||||
|
||||
// First ready transition fires and clears.
|
||||
pending = runEffect("ready", pending, navigate);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(pending, null, "pending nav must be cleared after firing");
|
||||
|
||||
// Re-running the effect with null nav must not fire again.
|
||||
pending = runEffect("ready", pending, navigate);
|
||||
assert.equal(
|
||||
calls.length,
|
||||
1,
|
||||
"navigate must not fire again after being cleared",
|
||||
);
|
||||
});
|
||||
|
||||
test("navigate fires immediately if nav is set while already ready", () => {
|
||||
// Edge case: nav arrives after the machine is already ready (e.g. hot reload).
|
||||
const calls = [];
|
||||
const navigate = (args) => calls.push(args);
|
||||
|
||||
const nav = { to: "/settings", search: { section: "agents" } };
|
||||
|
||||
// Start with no pending nav while ready.
|
||||
let pending = runEffect("ready", null, navigate);
|
||||
assert.equal(calls.length, 0, "no nav pending → no call");
|
||||
|
||||
// Nav arrives (complete() called while already ready).
|
||||
pending = runEffect("ready", nav, navigate);
|
||||
assert.equal(calls.length, 1, "nav must fire immediately when already ready");
|
||||
assert.equal(pending, null);
|
||||
});
|
||||
|
||||
test("navigate intent carries exact to and search from navigateToAgentSettings", () => {
|
||||
// Verifies the specific shape emitted by MachineOnboardingFlow's
|
||||
// navigateToAgentSettings action: { to: '/settings', search: { section: 'agents' } }
|
||||
const calls = [];
|
||||
const navigate = (args) => calls.push(args);
|
||||
|
||||
// Simulate MachineOnboardingFlow calling navigateAfterComplete.
|
||||
const nav = { to: "/settings", search: { section: "agents" } };
|
||||
runEffect("ready", nav, navigate);
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].to, "/settings", "must navigate to /settings");
|
||||
assert.equal(
|
||||
calls[0].search.section,
|
||||
"agents",
|
||||
"must include section=agents",
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,11 @@ import { mockIPC, mockWindows } from "@tauri-apps/api/mocks";
|
||||
import { decode } from "nostr-tools/nip19";
|
||||
import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
|
||||
import { parse as yamlParse } from "yaml";
|
||||
import {
|
||||
mockCustomHarnesses,
|
||||
handleSaveCustomHarness,
|
||||
handleDeleteCustomHarness,
|
||||
} from "./e2eBridgeCustomHarnesses.ts";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { ConnectionState } from "@/shared/api/relayClientShared";
|
||||
@@ -7039,7 +7044,10 @@ async function handleDiscoverAcpRuntimes(
|
||||
login_hint: undefined,
|
||||
},
|
||||
];
|
||||
return defaultCatalog.map(withMockRuntimeConfigMetadata);
|
||||
return [
|
||||
...defaultCatalog.map(withMockRuntimeConfigMetadata),
|
||||
...Array.from(mockCustomHarnesses.values()),
|
||||
];
|
||||
}
|
||||
|
||||
async function handleDiscoverAcpAuthMethods(
|
||||
@@ -10008,6 +10016,14 @@ export function maybeInstallE2eTauriMocks() {
|
||||
return activeConfig?.mock?.relayRequiresMembership ?? false;
|
||||
case "discover_acp_providers":
|
||||
return handleDiscoverAcpRuntimes(activeConfig);
|
||||
case "save_custom_harness":
|
||||
return handleSaveCustomHarness(
|
||||
payload as Parameters<typeof handleSaveCustomHarness>[0],
|
||||
);
|
||||
case "delete_custom_harness":
|
||||
return handleDeleteCustomHarness(
|
||||
payload as Parameters<typeof handleDeleteCustomHarness>[0],
|
||||
);
|
||||
case "discover_acp_auth_methods":
|
||||
return handleDiscoverAcpAuthMethods(
|
||||
payload as { runtimeId?: string },
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Unit tests for the e2eBridge custom harness handlers (C-10).
|
||||
*
|
||||
* Tests are imported directly from the extracted e2eBridgeCustomHarnesses.ts
|
||||
* module — no Tauri mock or browser environment needed — to prove:
|
||||
*
|
||||
* 1. save returns a catalog entry with source "custom" and correct fields
|
||||
* 2. definition_env is preserved through save (non-empty env)
|
||||
* 3. empty env produces absent definition_env (mirrors Rust BTreeMap serialization)
|
||||
* 4. same-ID edit replaces the existing entry in the store (no duplicates)
|
||||
* 5. rename (originalId ≠ id) removes the old key and inserts the new one
|
||||
* 6. delete removes the entry from the store
|
||||
* 7. delete is idempotent (not-found does not throw)
|
||||
* 8. discover integration: saved harness appears in the discover result set
|
||||
* alongside the default catalog (verifies the Map is shared by reference)
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { beforeEach, describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
mockCustomHarnesses,
|
||||
resetMockCustomHarnesses,
|
||||
handleSaveCustomHarness,
|
||||
handleDeleteCustomHarness,
|
||||
} from "./e2eBridgeCustomHarnesses.ts";
|
||||
|
||||
function makeArgs(overrides = {}) {
|
||||
return {
|
||||
definition: {
|
||||
id: overrides.id ?? "test-harness",
|
||||
label: overrides.label ?? "Test Harness",
|
||||
command: overrides.command ?? "test-bin",
|
||||
args: overrides.args ?? [],
|
||||
env: overrides.env ?? {},
|
||||
installInstructionsUrl: overrides.installInstructionsUrl ?? "",
|
||||
installHint: overrides.installHint ?? "",
|
||||
},
|
||||
originalId: overrides.originalId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// Reset the store before every test so tests are independent.
|
||||
beforeEach(() => resetMockCustomHarnesses());
|
||||
|
||||
// ── save_custom_harness ───────────────────────────────────────────────────────
|
||||
|
||||
describe("handleSaveCustomHarness", () => {
|
||||
it("returns a catalog entry with source 'custom'", () => {
|
||||
const entry = handleSaveCustomHarness(
|
||||
makeArgs({ id: "my-rt", label: "My RT" }),
|
||||
);
|
||||
assert.equal(entry.id, "my-rt");
|
||||
assert.equal(entry.label, "My RT");
|
||||
assert.equal(entry.source, "custom");
|
||||
});
|
||||
|
||||
it("stores the entry in mockCustomHarnesses", () => {
|
||||
handleSaveCustomHarness(makeArgs({ id: "stored" }));
|
||||
assert.ok(
|
||||
mockCustomHarnesses.has("stored"),
|
||||
"store must contain the saved id",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves non-empty definition_env", () => {
|
||||
const env = { ANTHROPIC_API_KEY: "sk-test", MODEL: "claude-3" };
|
||||
const entry = handleSaveCustomHarness(makeArgs({ id: "env-rt", env }));
|
||||
assert.deepStrictEqual(entry.definition_env, env);
|
||||
assert.deepStrictEqual(
|
||||
mockCustomHarnesses.get("env-rt")?.definition_env,
|
||||
env,
|
||||
);
|
||||
});
|
||||
|
||||
it("produces absent definition_env for empty env (mirrors Rust BTreeMap skip)", () => {
|
||||
const entry = handleSaveCustomHarness(makeArgs({ id: "no-env", env: {} }));
|
||||
assert.ok(
|
||||
entry.definition_env === undefined || entry.definition_env === null,
|
||||
"empty env must yield absent definition_env",
|
||||
);
|
||||
});
|
||||
|
||||
it("same-ID edit replaces entry — no duplicates in the store", () => {
|
||||
handleSaveCustomHarness(makeArgs({ id: "dup", label: "V1" }));
|
||||
handleSaveCustomHarness(
|
||||
makeArgs({ id: "dup", label: "V2", originalId: "dup" }),
|
||||
);
|
||||
assert.equal(
|
||||
mockCustomHarnesses.size,
|
||||
1,
|
||||
"same-ID edit must not duplicate store entries",
|
||||
);
|
||||
assert.equal(mockCustomHarnesses.get("dup")?.label, "V2");
|
||||
});
|
||||
|
||||
it("rename removes old key and inserts new key", () => {
|
||||
handleSaveCustomHarness(makeArgs({ id: "old-rt", label: "Old" }));
|
||||
handleSaveCustomHarness(
|
||||
makeArgs({ id: "new-rt", label: "New", originalId: "old-rt" }),
|
||||
);
|
||||
assert.ok(
|
||||
!mockCustomHarnesses.has("old-rt"),
|
||||
"old key must be removed on rename",
|
||||
);
|
||||
assert.ok(
|
||||
mockCustomHarnesses.has("new-rt"),
|
||||
"new key must be present after rename",
|
||||
);
|
||||
assert.equal(mockCustomHarnesses.get("new-rt")?.label, "New");
|
||||
});
|
||||
});
|
||||
|
||||
// ── delete_custom_harness ────────────────────────────────────────────────────
|
||||
|
||||
describe("handleDeleteCustomHarness", () => {
|
||||
it("removes an existing entry from the store", () => {
|
||||
handleSaveCustomHarness(makeArgs({ id: "to-delete" }));
|
||||
assert.ok(mockCustomHarnesses.has("to-delete"));
|
||||
|
||||
handleDeleteCustomHarness({ id: "to-delete" });
|
||||
assert.ok(
|
||||
!mockCustomHarnesses.has("to-delete"),
|
||||
"entry must be removed after delete",
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent — deleting non-existent id does not throw", () => {
|
||||
assert.doesNotThrow(
|
||||
() => handleDeleteCustomHarness({ id: "never-existed" }),
|
||||
"delete of non-existent id must not throw",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── discover integration: store is shared by reference ───────────────────────
|
||||
|
||||
describe("mockCustomHarnesses Map reference", () => {
|
||||
it("handler writes are immediately visible to callers that read the exported Map", () => {
|
||||
// e2eBridge.ts reads `Array.from(mockCustomHarnesses.values())` in
|
||||
// handleDiscoverAcpRuntimes. The exported Map is the same object by reference,
|
||||
// so writes via the handler are visible to any reader of the Map.
|
||||
assert.equal(
|
||||
mockCustomHarnesses.size,
|
||||
0,
|
||||
"store must be empty after reset",
|
||||
);
|
||||
|
||||
handleSaveCustomHarness(makeArgs({ id: "visible", label: "Visible" }));
|
||||
assert.equal(mockCustomHarnesses.size, 1);
|
||||
|
||||
const [entry] = Array.from(mockCustomHarnesses.values());
|
||||
assert.equal(entry.id, "visible");
|
||||
assert.equal(entry.source, "custom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* In-memory custom harness store for the e2e bridge.
|
||||
*
|
||||
* Extracted as a separate module so the handler logic can be unit-tested
|
||||
* independently of the full e2eBridge.ts context (which requires a browser
|
||||
* environment and full Playwright setup).
|
||||
*/
|
||||
import type { RawAcpRuntimeCatalogEntry } from "../shared/api/tauri.ts";
|
||||
|
||||
/** In-memory store for custom harnesses saved via `save_custom_harness`. */
|
||||
export const mockCustomHarnesses = new Map<string, RawAcpRuntimeCatalogEntry>();
|
||||
|
||||
/** Reset the store between tests. */
|
||||
export function resetMockCustomHarnesses(): void {
|
||||
mockCustomHarnesses.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `save_custom_harness`.
|
||||
*
|
||||
* Persists the definition into `mockCustomHarnesses` so that the next
|
||||
* `discover_acp_providers` call includes it. Mirrors the Rust command's
|
||||
* return shape: an `AcpRuntimeCatalogEntry` for the saved harness.
|
||||
*/
|
||||
export function handleSaveCustomHarness(args: {
|
||||
definition?: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
installInstructionsUrl?: string;
|
||||
installHint?: string;
|
||||
};
|
||||
originalId?: string | null;
|
||||
}): RawAcpRuntimeCatalogEntry {
|
||||
const def = args.definition ?? {};
|
||||
const id = def.id ?? "";
|
||||
const originalId = args.originalId ?? null;
|
||||
|
||||
// On rename: remove the old entry so the old id is no longer in the catalog.
|
||||
if (originalId && originalId !== id) {
|
||||
mockCustomHarnesses.delete(originalId);
|
||||
}
|
||||
|
||||
const entry: RawAcpRuntimeCatalogEntry = {
|
||||
id,
|
||||
label: def.label ?? id,
|
||||
avatar_url: "",
|
||||
availability: "not_installed", // PATH not probed in e2e mock
|
||||
command: def.command ?? null,
|
||||
binary_path: null,
|
||||
default_args: def.args ?? [],
|
||||
mcp_command: null,
|
||||
install_hint: def.installHint ?? "",
|
||||
install_instructions_url: def.installInstructionsUrl ?? "",
|
||||
can_auto_install: false,
|
||||
requires_external_cli: true,
|
||||
underlying_cli_path: null,
|
||||
node_required: false,
|
||||
auth_status: { status: "not_applicable" },
|
||||
source: "custom",
|
||||
// Omit definition_env when the env map is empty — mirrors Rust's BTreeMap
|
||||
// serialization which skips empty maps so the field is absent on the wire.
|
||||
definition_env:
|
||||
def.env && Object.keys(def.env).length > 0 ? def.env : undefined,
|
||||
login_hint: undefined,
|
||||
};
|
||||
mockCustomHarnesses.set(id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `delete_custom_harness`.
|
||||
* Removes the harness from the in-memory store. Idempotent (not-found is OK).
|
||||
*/
|
||||
export function handleDeleteCustomHarness(args: { id?: string }): void {
|
||||
const id = args.id ?? "";
|
||||
mockCustomHarnesses.delete(id);
|
||||
}
|
||||
+8
-8
@@ -820,10 +820,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1273,26 +1273,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test
|
||||
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
|
||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.31.0"
|
||||
version: "1.30.0"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
version: "0.7.10"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
|
||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.17"
|
||||
version: "0.6.16"
|
||||
tuple:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user