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:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-07-25 01:55:03 -04:00
co-authored by Will Pfleger
parent f7c55505e6
commit 5862b3109e
7 changed files with 392 additions and 11 deletions
+1 -1
View File
@@ -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.",
},
];
+123
View File
@@ -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",
);
});
+17 -1
View File
@@ -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
View File
@@ -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: