diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 1af584877..966ece631 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -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) + diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index a6d419cfd..10102ea8b 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -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.", }, ]; diff --git a/desktop/src/app/postOnboardingNav.test.mjs b/desktop/src/app/postOnboardingNav.test.mjs new file mode 100644 index 000000000..2be2eab95 --- /dev/null +++ b/desktop/src/app/postOnboardingNav.test.mjs @@ -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", + ); +}); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 96dcd8ba5..d685221e2 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -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[0], + ); + case "delete_custom_harness": + return handleDeleteCustomHarness( + payload as Parameters[0], + ); case "discover_acp_auth_methods": return handleDiscoverAcpAuthMethods( payload as { runtimeId?: string }, diff --git a/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs b/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs new file mode 100644 index 000000000..795b04c3d --- /dev/null +++ b/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs @@ -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"); + }); +}); diff --git a/desktop/src/testing/e2eBridgeCustomHarnesses.ts b/desktop/src/testing/e2eBridgeCustomHarnesses.ts new file mode 100644 index 000000000..c21e0a478 --- /dev/null +++ b/desktop/src/testing/e2eBridgeCustomHarnesses.ts @@ -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(); + +/** 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; + 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); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 64cc1becf..55185fc9c 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -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: