diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index e59c68bfa..ba35481bd 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -120,6 +120,7 @@ export default defineConfig({ "**/inbox-live-update.spec.ts", "**/mesh-compute.spec.ts", "**/observer-archive-policy.spec.ts", + "**/harness-management.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index c27e0203f..23edc74a4 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -4,7 +4,7 @@ import { decode } from "nostr-tools/nip19"; import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; import { parse as yamlParse } from "yaml"; import { - mockCustomHarnesses, + mergeMockCustomHarnesses, handleSaveCustomHarness, handleDeleteCustomHarness, } from "./e2eBridgeCustomHarnesses.ts"; @@ -6964,7 +6964,9 @@ async function handleDiscoverAcpRuntimes( } const configured = config?.mock?.acpRuntimesCatalog; if (configured) { - return configured.map(withMockRuntimeConfigMetadata); + return mergeMockCustomHarnesses( + configured.map(withMockRuntimeConfigMetadata), + ); } const defaultCatalog: RawAcpRuntimeCatalogEntry[] = [ { @@ -7046,10 +7048,9 @@ async function handleDiscoverAcpRuntimes( login_hint: undefined, }, ]; - return [ - ...defaultCatalog.map(withMockRuntimeConfigMetadata), - ...Array.from(mockCustomHarnesses.values()), - ]; + return mergeMockCustomHarnesses( + defaultCatalog.map(withMockRuntimeConfigMetadata), + ); } async function handleDiscoverAcpAuthMethods( diff --git a/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs b/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs index 5bb929d4d..0a0fb36a9 100644 --- a/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs +++ b/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs @@ -19,6 +19,7 @@ import { beforeEach, describe, it } from "node:test"; import { mockCustomHarnesses, + mergeMockCustomHarnesses, resetMockCustomHarnesses, handleSaveCustomHarness, handleDeleteCustomHarness, @@ -149,11 +150,86 @@ describe("handleDeleteCustomHarness", () => { // ── discover integration: store is shared by reference ─────────────────────── +describe("mergeMockCustomHarnesses", () => { + const seeded = (id, label = id) => ({ id, label, source: "custom" }); + + it("appends a newly saved harness that is not in the seeded catalog", () => { + handleSaveCustomHarness(makeArgs({ id: "added", label: "Added" })); + const merged = mergeMockCustomHarnesses([seeded("preset-a")]); + assert.deepEqual( + merged.map((e) => e.id), + ["preset-a", "added"], + ); + }); + + it("replaces a seeded entry in place on same-id save — no duplicate row", () => { + handleSaveCustomHarness(makeArgs({ id: "seeded-one", label: "V2" })); + const merged = mergeMockCustomHarnesses([ + seeded("preset-a"), + seeded("seeded-one", "V1"), + ]); + assert.deepEqual( + merged.map((e) => e.id), + ["preset-a", "seeded-one"], + "must not duplicate the id", + ); + assert.equal( + merged.find((e) => e.id === "seeded-one").label, + "V2", + "saved entry must win over the seed", + ); + }); + + it("drops a deleted seeded entry (tombstone, not just store removal)", () => { + // The regression: a seeded row has no store entry to delete, so without a + // tombstone the row survived the delete and the spec failed. + handleDeleteCustomHarness({ id: "seeded-one" }); + const merged = mergeMockCustomHarnesses([ + seeded("preset-a"), + seeded("seeded-one"), + ]); + assert.deepEqual( + merged.map((e) => e.id), + ["preset-a"], + ); + }); + + it("drops the vacated old id after a rename and surfaces the new one", () => { + handleSaveCustomHarness( + makeArgs({ id: "new-id", label: "New", originalId: "old-id" }), + ); + const merged = mergeMockCustomHarnesses([seeded("old-id", "Old")]); + assert.deepEqual( + merged.map((e) => e.id), + ["new-id"], + ); + }); + + it("re-saving a deleted id resurrects it", () => { + handleDeleteCustomHarness({ id: "seeded-one" }); + handleSaveCustomHarness(makeArgs({ id: "seeded-one", label: "Back" })); + const merged = mergeMockCustomHarnesses([seeded("seeded-one", "Original")]); + assert.deepEqual( + merged.map((e) => e.id), + ["seeded-one"], + ); + assert.equal(merged[0].label, "Back"); + }); + + it("leaves a seeded catalog untouched when nothing has been mutated", () => { + const base = [seeded("preset-a"), seeded("preset-b")]; + assert.deepEqual( + mergeMockCustomHarnesses(base).map((e) => e.id), + ["preset-a", "preset-b"], + ); + }); +}); + 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. + // handleDiscoverAcpRuntimes merges this store into the catalog it returns. + // 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, diff --git a/desktop/src/testing/e2eBridgeCustomHarnesses.ts b/desktop/src/testing/e2eBridgeCustomHarnesses.ts index 175567784..b72ecadd3 100644 --- a/desktop/src/testing/e2eBridgeCustomHarnesses.ts +++ b/desktop/src/testing/e2eBridgeCustomHarnesses.ts @@ -10,9 +10,43 @@ import type { RawAcpRuntimeCatalogEntry } from "../shared/api/tauri.ts"; /** In-memory store for custom harnesses saved via `save_custom_harness`. */ export const mockCustomHarnesses = new Map(); +/** + * Ids removed via `delete_custom_harness` (or vacated by a rename). + * + * Needed because a test's `acpRuntimesCatalog` seed is static config, not the + * mutation store: deleting a seeded row leaves nothing to remove from + * `mockCustomHarnesses`, so without a tombstone the row would survive the + * delete and the mock would report success while the UI still shows it. + */ +export const mockDeletedCustomHarnesses = new Set(); + /** Reset the store between tests. */ export function resetMockCustomHarnesses(): void { mockCustomHarnesses.clear(); + mockDeletedCustomHarnesses.clear(); +} + +/** + * Overlay the mutation store onto a seeded catalog. + * + * Deleted ids drop out, saved ids replace their seeded entry in place (so a + * same-id edit updates rather than duplicates), and newly added ids append. + */ +export function mergeMockCustomHarnesses( + base: RawAcpRuntimeCatalogEntry[], +): RawAcpRuntimeCatalogEntry[] { + const merged = base.filter( + (entry) => !mockDeletedCustomHarnesses.has(entry.id), + ); + for (const entry of mockCustomHarnesses.values()) { + const index = merged.findIndex((existing) => existing.id === entry.id); + if (index === -1) { + merged.push(entry); + } else { + merged[index] = entry; + } + } + return merged; } /** @@ -41,7 +75,10 @@ export function handleSaveCustomHarness(args: { // On rename: remove the old entry so the old id is no longer in the catalog. if (originalId && originalId !== id) { mockCustomHarnesses.delete(originalId); + mockDeletedCustomHarnesses.add(originalId); } + // A save resurrects an id that an earlier test step deleted. + mockDeletedCustomHarnesses.delete(id); const entry: RawAcpRuntimeCatalogEntry = { id, @@ -86,4 +123,5 @@ export function handleDeleteCustomHarness( } const id = args.id ?? ""; mockCustomHarnesses.delete(id); + mockDeletedCustomHarnesses.add(id); } diff --git a/desktop/tests/e2e/harness-management.spec.ts b/desktop/tests/e2e/harness-management.spec.ts index ee5ca16c0..cf0064da4 100644 --- a/desktop/tests/e2e/harness-management.spec.ts +++ b/desktop/tests/e2e/harness-management.spec.ts @@ -197,7 +197,10 @@ test.describe("add custom harness", () => { }); // Submit. - await page.getByRole("button", { name: "Save" }).click(); + await page + .getByTestId("custom-harness-form") + .getByRole("button", { name: "Save", exact: true }) + .click(); // Row must appear in the list after save. await expect( @@ -247,7 +250,10 @@ test.describe("add custom harness", () => { await page.getByTestId("custom-harness-edit-my-custom-agent").click(); await expect(page.getByTestId("custom-harness-form")).toBeVisible(); await page.fill("#ch-label", "V2 Label"); - await page.getByRole("button", { name: "Save" }).click(); + await page + .getByTestId("custom-harness-form") + .getByRole("button", { name: "Save", exact: true }) + .click(); // Exactly one row with the same ID; label updated. const rows = page.locator( @@ -271,7 +277,10 @@ test.describe("add custom harness", () => { await expect(page.getByTestId("custom-harness-form")).toBeVisible(); await page.fill("#ch-label", "New Harness"); await page.fill("#ch-id", "new-harness"); - await page.getByRole("button", { name: "Save" }).click(); + await page + .getByTestId("custom-harness-form") + .getByRole("button", { name: "Save", exact: true }) + .click(); // Old row gone; new row present. await expect( @@ -381,6 +390,32 @@ test("onboarding setup More-harnesses click navigates to Settings → Agents", a skipCommunitySeed: true, skipOnboardingSeed: true, }); + // Seed a community stamped with a *foreign* pubkey. This is the only shape + // that satisfies both preconditions of this test at once: + // - machine onboarding must still run, so the community must NOT vouch for + // the active identity (migrateMachineOnboardingCompletion only accepts a + // community whose recorded pubkey matches — see machineOnboarding.ts:70). + // - after onboarding completes, useCommunityInit must NOT report + // needsSetup, or App.tsx:499 renders WelcomeSetup instead of the router + // and the navigation lands on a screen that has no settings tree. + // The default seed vouches (it uses the active pubkey) and skipping it + // entirely leaves zero communities, so neither default gets there. + await page.addInitScript(() => { + const communityId = "e2e-default-community"; + window.localStorage.setItem( + "buzz-communities", + JSON.stringify([ + { + id: communityId, + name: "E2E Test", + relayUrl: "ws://127.0.0.1:7777", + pubkey: "f".repeat(64), + addedAt: new Date().toISOString(), + }, + ]), + ); + window.localStorage.setItem("buzz-active-community-id", communityId); + }); await page.goto("/"); // Reach the setup page: create a new identity key → skip backup step.