mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Summary
Adds a locally stored **NIP-49 encrypted key backup** (`ncryptsec`) to
the desktop app, per the plan reviewed in buzz-development (Rev 3,
approved 9/10 by Wren; implementation also reviewed and approved 9/10).
**Two-artifact design — canonical bytes originate entirely in Rust:**
- `create_ncryptsec_backup` runs under the `identity_mutation` lock:
encrypt → decrypt-verify against the live pubkey → atomic `0o600` write
to `{app_data_dir}/identity.ncryptsec` → reread/byte-compare → return
the exact persisted bytes. The frontend never re-derives or re-encrypts.
- `save_ncryptsec_copy` writes a portable copy via the save dialog
(parse-gated, secret-file semantics) and never mutates canonical state.
- `generate_backup_passphrase`: 6 words from the EFF short wordlist via
`OsRng` (custom passphrases min 12 chars).
- Import accepts `ncryptsec1` with optional password; the raw-`nsec`
path is untouched. Different-pubkey import and sign-out wipe the
app-managed backup (post-commit, best-effort — a failed import can never
destroy the still-live identity's backup; regression-tested).
**Never-relay guarantee (egress guard + tripwires):**
- `egress_guard.rs` fail-closed at all 8 `/events` submission boundaries
(relay submit funnel, 3× `relay.rs`, huddle STT, both engram submitters,
native WS choke point), rejecting `ncryptsec1`/`NCRYPTSEC1` in text and
binary frames. Scope is deliberately ncryptsec-only: pairing
intentionally carries raw nsec inside its encrypted session.
- Site-granular `/events` inventory tripwire: per-file (`/events` count,
guard-call count) pairs; unlisted files expect zero. Mutation-style
tests prove a ninth site in an existing file, a removed guard, and a new
unlisted file all fail the scan.
- ncryptsec source-allowlist scans in **both** trees (Rust + TS).
**Frontend:** onboarding `BackupStep` is encrypted-by-default — the
default path never invokes `get_nsec` (e2e asserts the command log).
Raw-nsec export stays behind an explicit click with prior semantics.
Shared `EncryptedBackupCreator` powers onboarding + a new settings row;
the import form auto-switches to encrypted mode on `ncryptsec1` paste
(case-insensitive HRP).
**Open product call for @tlongwell-block:** onboarding default is
*encrypted* in this PR; flipping to raw-default is a small change either
way (documented in the plan).
Review history: plan Rev 3 and the implementation were both iterated
with Wren to 9/10 (two blockers from round 1 — import ordering,
inventory granularity — plus an uppercase-bech32 hardening gap, all
fixed in `dde37183e`). Thread: buzz-development.
### Related issue
Follow-up to the direction explored in #385 (NIP-PB, closed) — this
ships local NIP-49 (the standard) instead of a new NIP. No open
duplicate found.
### Testing
All at exactly `dde37183e` (same shell, HEAD verified):
- `cargo test` — 1680 passed / 0 failed / 14 ignored (includes a
deliberate ~70s log_n-18 NIP-49 round trip, spec vector, wrong-password,
NFKC, uppercase-vector decrypt, injection test per egress boundary,
inventory mutation tests, import-ordering regression tests)
- `cargo clippy --all-targets -- -D warnings` — clean; `cargo fmt
--check` — clean
- `pnpm typecheck` — clean; JS unit suite 3529/3529; biome (repo-pinned
2.4.16) clean
- Playwright `onboarding-backup` / `onboarding` /
`onboarding-agent-defaults` / `profile-nsec-reveal` — 86 passed, 1 known
avatar-reservation flake (passed on rerun; untouched by this diff).
`passThroughBackupStep` now exercises the encrypted default, so every
downstream onboarding spec covers the new path.
- Note: browser e2e fakes the crypto via the mock bridge (fixed
spec-vector blob); decryption correctness is proven in the Rust tests.
## Latest onboarding integration
The current head adds an additive `IdentityInfo.storage` field
(`ephemeral`, `system-keyring`, `local-file`, or `environment`) so
onboarding can accurately explain where the active identity is
protected. It surfaces storage metadata only—never key material—and
leaves the existing lost/keyring-locked recovery behavior intact.
---------
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
691 lines
25 KiB
TypeScript
691 lines
25 KiB
TypeScript
/**
|
|
* E2E spec for the consolidated Harnesses settings surface + Add-harnesses
|
|
* catalog dialog.
|
|
*
|
|
* Covers:
|
|
* - Ready preset gets a row in "Your harnesses"; needs-setup preset does NOT
|
|
* - Needs-setup preset appears in the Add-harnesses catalog with status,
|
|
* curated description, docs link, and setup action
|
|
* - Catalog search filters the list
|
|
* - Toggling install does not reorder rows (stable order)
|
|
* - Add custom harness via catalog (name+command → save → row appears)
|
|
* - Advanced disclosure hides ID/args/env/docs/hint until opened
|
|
* - Edit preserves env vars (round-trip through definitionEnv boundary)
|
|
* - Same-ID edit replaces entry (no duplicate row)
|
|
* - Rename removes old row and shows new row
|
|
* - Delete success removes row; delete failure shows inline error
|
|
* - PATH badge: custom harness row shows Detected when available
|
|
* - Preset rows render bundled logos, never initials
|
|
* - Onboarding navigate: setup-page "More harnesses" click → Settings → Agents (F8)
|
|
*/
|
|
import { expect, test } from "@playwright/test";
|
|
|
|
import { installMockBridge } from "../helpers/bridge";
|
|
import { passThroughBackupStep } from "../helpers/onboarding";
|
|
|
|
// ── Shared catalog fixtures ───────────────────────────────────────────────────
|
|
|
|
/** Hermes preset with availability "available" — earns a Your-harnesses row. */
|
|
const HERMES_AVAILABLE = {
|
|
id: "hermes",
|
|
label: "Hermes",
|
|
avatar_url: "",
|
|
availability: "available",
|
|
command: "hermes-acp",
|
|
binary_path: "/usr/local/bin/hermes-acp",
|
|
default_args: [],
|
|
mcp_command: null,
|
|
install_hint: "Buzz talks to Hermes Agent through its hermes-acp command.",
|
|
install_instructions_url: "https://hermes-agent.nousresearch.com",
|
|
can_auto_install: false,
|
|
requires_external_cli: true,
|
|
underlying_cli_path: null,
|
|
node_required: false,
|
|
auth_status: { status: "unknown" },
|
|
source: "preset",
|
|
} as const;
|
|
|
|
/** OpenClaw preset "not_installed" + no auto-install — catalog-only. */
|
|
const OPENCLAW_NOT_INSTALLED = {
|
|
id: "openclaw",
|
|
label: "OpenClaw",
|
|
avatar_url: "",
|
|
availability: "not_installed",
|
|
command: "openclaw",
|
|
binary_path: null,
|
|
default_args: ["acp"],
|
|
mcp_command: null,
|
|
install_hint:
|
|
"Buzz talks to OpenClaw through its ACP mode (openclaw acp), which relies on the OpenClaw Gateway daemon. Follow the setup guide to install both.",
|
|
install_instructions_url: "https://docs.openclaw.ai/start/getting-started",
|
|
can_auto_install: false,
|
|
requires_external_cli: true,
|
|
underlying_cli_path: null,
|
|
node_required: false,
|
|
auth_status: { status: "unknown" },
|
|
source: "preset",
|
|
} as const;
|
|
|
|
/** Cursor preset — deliberately has NO bundled logo (brand assets not
|
|
* licensed for redistribution). Must render the terminal glyph, never
|
|
* initials. */
|
|
const CURSOR_AVAILABLE = {
|
|
id: "cursor",
|
|
label: "Cursor",
|
|
avatar_url: "",
|
|
availability: "available",
|
|
command: "cursor-agent",
|
|
binary_path: "/usr/local/bin/cursor-agent",
|
|
default_args: [],
|
|
mcp_command: null,
|
|
install_hint: "Buzz talks to Cursor through the cursor-agent CLI's ACP mode.",
|
|
install_instructions_url: "https://cursor.com/cli",
|
|
can_auto_install: false,
|
|
requires_external_cli: true,
|
|
underlying_cli_path: null,
|
|
node_required: false,
|
|
auth_status: { status: "unknown" },
|
|
source: "preset",
|
|
} as const;
|
|
|
|
/** Custom harness entry already persisted — always gets a row. */
|
|
function makeCustomEntry(
|
|
overrides: {
|
|
id?: string;
|
|
label?: string;
|
|
command?: string;
|
|
availability?: "available" | "not_installed";
|
|
definition_env?: Record<string, string>;
|
|
} = {},
|
|
) {
|
|
return {
|
|
id: overrides.id ?? "my-custom-agent",
|
|
label: overrides.label ?? "My Custom Agent",
|
|
avatar_url: "",
|
|
availability: overrides.availability ?? "not_installed",
|
|
command: overrides.command ?? "my-custom-acp",
|
|
binary_path:
|
|
overrides.availability === "available"
|
|
? "/usr/local/bin/my-custom-acp"
|
|
: null,
|
|
default_args: [],
|
|
mcp_command: null,
|
|
install_hint: "",
|
|
install_instructions_url: "",
|
|
can_auto_install: false,
|
|
requires_external_cli: true,
|
|
underlying_cli_path: null,
|
|
node_required: false,
|
|
auth_status: { status: "unknown" },
|
|
source: "custom",
|
|
definition_env: overrides.definition_env,
|
|
};
|
|
}
|
|
|
|
// ── Navigation helpers ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Open Settings → Agents through the normal UI path.
|
|
* CI serves the app as a static SPA; direct navigation to /settings 404s
|
|
* before the client router starts.
|
|
*/
|
|
async function openHarnessSettings(page: import("@playwright/test").Page) {
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.getByTestId("open-settings").click();
|
|
await page.getByTestId("profile-popover-settings").click();
|
|
await expect(page.getByTestId("settings-view")).toBeVisible();
|
|
await page.getByTestId("settings-nav-agents").click();
|
|
await expect(page.getByTestId("settings-harnesses")).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
}
|
|
|
|
/** Open the Add-harnesses catalog dialog from the settings surface. */
|
|
async function openCatalog(page: import("@playwright/test").Page) {
|
|
await page.getByTestId("harness-add-button").click();
|
|
await expect(page.getByTestId("harness-catalog-dialog")).toBeVisible();
|
|
}
|
|
|
|
/** Fill the custom harness form (all fields render inline). */
|
|
async function fillHarnessForm(
|
|
page: import("@playwright/test").Page,
|
|
values: {
|
|
label: string;
|
|
id?: string;
|
|
command: string;
|
|
env?: Array<{ key: string; value: string }>;
|
|
},
|
|
) {
|
|
await page.fill("#ch-label", values.label);
|
|
await page.fill("#ch-command", values.command);
|
|
if (values.id !== undefined) {
|
|
await page.fill("#ch-id", values.id);
|
|
}
|
|
for (const pair of values.env ?? []) {
|
|
await page.getByRole("button", { name: "Add env var" }).click();
|
|
// Fill last appended row.
|
|
const keyInputs = page.locator('input[placeholder="KEY"]');
|
|
const valInputs = page.locator('input[placeholder="value"]');
|
|
await keyInputs.last().fill(pair.key);
|
|
await valInputs.last().fill(pair.value);
|
|
}
|
|
}
|
|
|
|
// ── Your harnesses vs catalog split ──────────────────────────────────────────
|
|
|
|
test.describe("your harnesses split", () => {
|
|
test("ready preset gets a row; needs-setup preset is catalog-only", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
acpRuntimesCatalog: [HERMES_AVAILABLE, OPENCLAW_NOT_INSTALLED],
|
|
});
|
|
await openHarnessSettings(page);
|
|
|
|
// Ready preset row with a Ready status chip.
|
|
const hermesRow = page.getByTestId("doctor-runtime-hermes");
|
|
await expect(hermesRow).toBeVisible();
|
|
await expect(page.getByTestId("doctor-runtime-ready-hermes")).toHaveText(
|
|
"Ready",
|
|
);
|
|
|
|
// Needs-setup preset must NOT render a row (and thus no Install button).
|
|
await expect(page.getByTestId("doctor-runtime-openclaw")).toHaveCount(0);
|
|
await expect(
|
|
page.getByTestId("doctor-runtime-install-openclaw"),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("needs-setup preset appears in catalog with status, description, docs and setup action", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
acpRuntimesCatalog: [HERMES_AVAILABLE, OPENCLAW_NOT_INSTALLED],
|
|
});
|
|
await openHarnessSettings(page);
|
|
await openCatalog(page);
|
|
|
|
// Needs-setup entries sort first, so OpenClaw is auto-selected.
|
|
await expect(
|
|
page.getByTestId("harness-catalog-list-item-openclaw"),
|
|
).toBeVisible();
|
|
const detail = page.getByTestId("harness-catalog-detail-pane");
|
|
await expect(detail).toContainText("OpenClaw");
|
|
// Status chip.
|
|
await expect(
|
|
page.getByTestId("harness-catalog-status-openclaw"),
|
|
).toHaveText("CLI needed");
|
|
// Curated one-liner (first-party-sourced category sentence).
|
|
await expect(detail).toContainText(
|
|
"A personal AI assistant that runs on your own devices.",
|
|
);
|
|
// Operational setup hint from runtime state.
|
|
await expect(detail).toContainText("OpenClaw Gateway daemon");
|
|
// Primary setup action under the header (no auto-install → setup guide).
|
|
await expect(
|
|
page.getByTestId("harness-catalog-setup-openclaw"),
|
|
).toBeVisible();
|
|
|
|
// Technical details are visible by default.
|
|
const technical = page.getByTestId("harness-catalog-technical-openclaw");
|
|
await expect(technical).toBeVisible();
|
|
await expect(technical).toContainText("openclaw");
|
|
await expect(technical).toContainText("acp");
|
|
});
|
|
|
|
test("ready preset shows in catalog as Ready with no install action", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
acpRuntimesCatalog: [HERMES_AVAILABLE, OPENCLAW_NOT_INSTALLED],
|
|
});
|
|
await openHarnessSettings(page);
|
|
await openCatalog(page);
|
|
|
|
// Ready entries live in the "Installed" accordion, collapsed by default.
|
|
await expect(
|
|
page.getByTestId("harness-catalog-list-item-hermes"),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.getByTestId("harness-catalog-section-installed-count"),
|
|
).toHaveText("1");
|
|
await page.getByTestId("harness-catalog-section-installed").click();
|
|
|
|
await page.getByTestId("harness-catalog-list-item-hermes").click();
|
|
const detail = page.getByTestId("harness-catalog-detail-pane");
|
|
await expect(detail).toContainText("Ready");
|
|
await expect(
|
|
page.getByTestId("harness-catalog-install-hermes"),
|
|
).toHaveCount(0);
|
|
await expect(page.getByTestId("harness-catalog-setup-hermes")).toHaveCount(
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("catalog Update for an outdated adapter requires confirmation before installing", async ({
|
|
page,
|
|
}) => {
|
|
// Wes's review blocker: Add runtimes → Setup → Update must honor the
|
|
// same machine-wide replacement confirmation the runtime row shows —
|
|
// never mutate straight from the catalog CTA.
|
|
await installMockBridge(page, {
|
|
acpRuntimesCatalog: [
|
|
HERMES_AVAILABLE,
|
|
{
|
|
...OPENCLAW_NOT_INSTALLED,
|
|
availability: "adapter_outdated",
|
|
binary_path: "/usr/local/bin/openclaw",
|
|
can_auto_install: true,
|
|
},
|
|
],
|
|
});
|
|
await openHarnessSettings(page);
|
|
await openCatalog(page);
|
|
|
|
const installCalls = () =>
|
|
page.evaluate(
|
|
() =>
|
|
(
|
|
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
|
|
.__BUZZ_E2E_COMMANDS__ ?? []
|
|
).filter((command) => command === "install_acp_runtime").length,
|
|
);
|
|
|
|
await page.getByTestId("harness-catalog-list-item-openclaw").click();
|
|
await expect(
|
|
page.getByTestId("harness-catalog-status-openclaw"),
|
|
).toHaveText("Update needed");
|
|
const updateButton = page.getByTestId("harness-catalog-install-openclaw");
|
|
await expect(updateButton).toHaveText("Update");
|
|
|
|
// Cancel path: clicking Update opens the warning, no mutation fires.
|
|
await updateButton.click();
|
|
const dialog = page.getByRole("alertdialog");
|
|
await expect(dialog).toContainText("Update OpenClaw adapter?");
|
|
await expect(dialog).toContainText(
|
|
"This replaces the machine-wide openclaw adapter.",
|
|
);
|
|
// Generic runtimes must never get Codex's package copy.
|
|
await expect(dialog).not.toContainText("codex-acp");
|
|
expect(await installCalls()).toBe(0);
|
|
await dialog.getByRole("button", { name: "Cancel" }).click();
|
|
await expect(dialog).toHaveCount(0);
|
|
expect(await installCalls()).toBe(0);
|
|
|
|
// Confirm path: exactly one install fires after confirmation.
|
|
await updateButton.click();
|
|
await page.getByTestId("harness-catalog-confirm-update-openclaw").click();
|
|
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
|
await expect.poll(installCalls).toBe(1);
|
|
// No duplicate mutation after the flow settles.
|
|
expect(await installCalls()).toBe(1);
|
|
});
|
|
|
|
test("catalog search filters the list", async ({ page }) => {
|
|
await installMockBridge(page, {
|
|
acpRuntimesCatalog: [
|
|
HERMES_AVAILABLE,
|
|
OPENCLAW_NOT_INSTALLED,
|
|
CURSOR_AVAILABLE,
|
|
],
|
|
});
|
|
await openHarnessSettings(page);
|
|
await openCatalog(page);
|
|
|
|
await page.getByTestId("harness-catalog-search").fill("claw");
|
|
await expect(
|
|
page.getByTestId("harness-catalog-list-item-openclaw"),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("harness-catalog-list-item-hermes"),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.getByTestId("harness-catalog-list-item-cursor"),
|
|
).toHaveCount(0);
|
|
// The custom-harness entry stays available regardless of the filter.
|
|
await expect(
|
|
page.getByTestId("harness-catalog-list-item-custom"),
|
|
).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ── Preset logos in Your harnesses rows ──────────────────────────────────────
|
|
|
|
test("harness rows render bundled preset logos, not initials", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
acpRuntimesCatalog: [HERMES_AVAILABLE, CURSOR_AVAILABLE],
|
|
});
|
|
await openHarnessSettings(page);
|
|
|
|
// Preset rows must show the same bundled logo the catalog uses
|
|
// (PRESET_LOGOS via RuntimeIcon), even though presets emit an empty
|
|
// avatar_url (the no-remote-icon security line).
|
|
const hermesLogo = page.getByTestId("doctor-runtime-logo-hermes");
|
|
await expect(hermesLogo).toBeVisible();
|
|
await expect(hermesLogo.locator("img")).toHaveAttribute(
|
|
"src",
|
|
"/harness-logos/hermes.png",
|
|
);
|
|
|
|
// Cursor renders its inline SVG mark (RUNTIME_MARKS, CC0 simple-icons
|
|
// path) — an svg, never an img or initials.
|
|
const cursorLogo = page.getByTestId("doctor-runtime-logo-cursor");
|
|
await expect(cursorLogo).toBeVisible();
|
|
await expect(cursorLogo.locator("svg")).toBeVisible();
|
|
await expect(cursorLogo.locator("img")).not.toBeVisible();
|
|
await expect(cursorLogo).not.toContainText("C");
|
|
});
|
|
|
|
// ── Custom harness add (via catalog) ─────────────────────────────────────────
|
|
|
|
test.describe("add custom harness", () => {
|
|
test("catalog custom form saves and row appears in Your harnesses", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
acpRuntimesCatalog: [HERMES_AVAILABLE, OPENCLAW_NOT_INSTALLED],
|
|
});
|
|
await openHarnessSettings(page);
|
|
|
|
// No custom rows yet.
|
|
await expect(
|
|
page.getByTestId("doctor-runtime-my-custom-agent"),
|
|
).not.toBeVisible();
|
|
|
|
await openCatalog(page);
|
|
await page.getByTestId("harness-catalog-list-item-custom").click();
|
|
await expect(page.getByTestId("custom-harness-form")).toBeVisible();
|
|
|
|
// All fields render inline.
|
|
await expect(page.locator("#ch-label")).toBeVisible();
|
|
await expect(page.locator("#ch-command")).toBeVisible();
|
|
await expect(page.locator("#ch-id")).toBeVisible();
|
|
|
|
await fillHarnessForm(page, {
|
|
label: "My Custom Agent",
|
|
command: "my-custom-acp",
|
|
});
|
|
|
|
// ID auto-derives from the label.
|
|
await expect(page.locator("#ch-id")).toHaveValue("my-custom-agent");
|
|
|
|
await page
|
|
.getByTestId("custom-harness-form")
|
|
.getByRole("button", { name: "Save", exact: true })
|
|
.click();
|
|
|
|
// Dialog closes; row appears in Your harnesses.
|
|
await expect(page.getByTestId("harness-catalog-dialog")).not.toBeVisible();
|
|
await expect(
|
|
page.getByTestId("doctor-runtime-my-custom-agent"),
|
|
).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test("edit preserves env vars (definitionEnv round-trip)", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
acpRuntimesCatalog: [
|
|
HERMES_AVAILABLE,
|
|
OPENCLAW_NOT_INSTALLED,
|
|
makeCustomEntry({ definition_env: { MY_API_KEY: "sk-test" } }),
|
|
],
|
|
});
|
|
await openHarnessSettings(page);
|
|
|
|
// Open edit form for the existing custom entry via the ••• menu.
|
|
await expect(
|
|
page.getByTestId("doctor-runtime-my-custom-agent"),
|
|
).toBeVisible();
|
|
await page.getByTestId("doctor-runtime-menu-my-custom-agent").click();
|
|
await page.getByTestId("custom-harness-edit-my-custom-agent").click();
|
|
await expect(page.getByTestId("custom-harness-form")).toBeVisible();
|
|
|
|
// Existing env vars must be pre-populated.
|
|
await expect(page.locator('input[placeholder="KEY"]').first()).toHaveValue(
|
|
"MY_API_KEY",
|
|
);
|
|
await expect(
|
|
page.locator('input[placeholder="value"]').first(),
|
|
).toHaveValue("sk-test");
|
|
});
|
|
|
|
test("same-ID edit replaces row — no duplicate", async ({ page }) => {
|
|
await installMockBridge(page, {
|
|
acpRuntimesCatalog: [
|
|
HERMES_AVAILABLE,
|
|
OPENCLAW_NOT_INSTALLED,
|
|
makeCustomEntry({ label: "V1 Label" }),
|
|
],
|
|
});
|
|
await openHarnessSettings(page);
|
|
|
|
// Edit, keep same ID, change label.
|
|
await page.getByTestId("doctor-runtime-menu-my-custom-agent").click();
|
|
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
|
|
.getByTestId("custom-harness-form")
|
|
.getByRole("button", { name: "Save", exact: true })
|
|
.click();
|
|
|
|
// Exactly one row with the same ID; label updated.
|
|
const rows = page.locator('[data-testid="doctor-runtime-my-custom-agent"]');
|
|
await expect(rows).toHaveCount(1);
|
|
await expect(rows.first()).toContainText("V2 Label");
|
|
});
|
|
|
|
test("rename removes old row and inserts new row", async ({ page }) => {
|
|
await installMockBridge(page, {
|
|
acpRuntimesCatalog: [
|
|
HERMES_AVAILABLE,
|
|
OPENCLAW_NOT_INSTALLED,
|
|
makeCustomEntry({ id: "old-harness", label: "Old" }),
|
|
],
|
|
});
|
|
await openHarnessSettings(page);
|
|
|
|
await page.getByTestId("doctor-runtime-menu-old-harness").click();
|
|
await page.getByTestId("custom-harness-edit-old-harness").click();
|
|
await expect(page.getByTestId("custom-harness-form")).toBeVisible();
|
|
await fillHarnessForm(page, {
|
|
label: "New Harness",
|
|
id: "new-harness",
|
|
command: "my-custom-acp",
|
|
});
|
|
await page
|
|
.getByTestId("custom-harness-form")
|
|
.getByRole("button", { name: "Save", exact: true })
|
|
.click();
|
|
|
|
// Old row gone; new row present.
|
|
await expect(
|
|
page.getByTestId("doctor-runtime-old-harness"),
|
|
).not.toBeVisible({ timeout: 5_000 });
|
|
await expect(page.getByTestId("doctor-runtime-new-harness")).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── Delete flow ───────────────────────────────────────────────────────────────
|
|
|
|
test.describe("delete custom harness", () => {
|
|
test("delete success removes the row", async ({ page }) => {
|
|
await installMockBridge(page, {
|
|
acpRuntimesCatalog: [
|
|
HERMES_AVAILABLE,
|
|
OPENCLAW_NOT_INSTALLED,
|
|
makeCustomEntry(),
|
|
],
|
|
});
|
|
await openHarnessSettings(page);
|
|
|
|
// Enter confirm-delete mode via the ••• menu.
|
|
await page.getByTestId("doctor-runtime-menu-my-custom-agent").click();
|
|
await page.getByTestId("custom-harness-delete-my-custom-agent").click();
|
|
// Blast-radius warning + confirm button must appear.
|
|
await expect(
|
|
page.getByTestId("custom-harness-delete-warning-my-custom-agent"),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("custom-harness-delete-confirm-my-custom-agent"),
|
|
).toBeVisible();
|
|
await page
|
|
.getByTestId("custom-harness-delete-confirm-my-custom-agent")
|
|
.click();
|
|
|
|
// Row disappears after successful delete.
|
|
await expect(
|
|
page.getByTestId("doctor-runtime-my-custom-agent"),
|
|
).not.toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test("delete failure shows inline error and keeps the row", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
acpRuntimesCatalog: [
|
|
HERMES_AVAILABLE,
|
|
OPENCLAW_NOT_INSTALLED,
|
|
makeCustomEntry(),
|
|
],
|
|
deleteCustomHarnessError: "permission denied: could not remove file",
|
|
});
|
|
await openHarnessSettings(page);
|
|
|
|
await page.getByTestId("doctor-runtime-menu-my-custom-agent").click();
|
|
await page.getByTestId("custom-harness-delete-my-custom-agent").click();
|
|
await page
|
|
.getByTestId("custom-harness-delete-confirm-my-custom-agent")
|
|
.click();
|
|
|
|
// Error text visible; row still present.
|
|
await expect(
|
|
page.getByText("permission denied: could not remove file"),
|
|
).toBeVisible({ timeout: 5_000 });
|
|
await expect(
|
|
page.getByTestId("doctor-runtime-my-custom-agent"),
|
|
).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ── Custom harness row readiness ─────────────────────────────────────────────
|
|
|
|
test("available custom harness row shows a Ready chip", async ({ page }) => {
|
|
await installMockBridge(page, {
|
|
acpRuntimesCatalog: [
|
|
HERMES_AVAILABLE,
|
|
OPENCLAW_NOT_INSTALLED,
|
|
makeCustomEntry({ availability: "available" }),
|
|
],
|
|
});
|
|
await openHarnessSettings(page);
|
|
|
|
const row = page.getByTestId("doctor-runtime-my-custom-agent");
|
|
await expect(row).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("doctor-runtime-ready-my-custom-agent"),
|
|
).toHaveText("Ready");
|
|
});
|
|
|
|
test("not-ready custom harness row shows status, no install action", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
acpRuntimesCatalog: [
|
|
HERMES_AVAILABLE,
|
|
OPENCLAW_NOT_INSTALLED,
|
|
makeCustomEntry(),
|
|
],
|
|
});
|
|
await openHarnessSettings(page);
|
|
|
|
const row = page.getByTestId("doctor-runtime-my-custom-agent");
|
|
await expect(row).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("doctor-runtime-status-my-custom-agent"),
|
|
).toHaveText("CLI needed");
|
|
// A row that setup can't fix with one click renders no Install button (and
|
|
// is not ready).
|
|
await expect(
|
|
page.getByTestId("doctor-runtime-install-my-custom-agent"),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.getByTestId("doctor-runtime-ready-my-custom-agent"),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
// ── F8: onboarding navigate-after-complete ────────────────────────────────────
|
|
//
|
|
// Verifies the parent-owned route intent introduced in B-8:
|
|
// 1. User reaches the machine-onboarding setup page.
|
|
// 2. Clicks "More harnesses" (onboarding-setup-more-harnesses).
|
|
// 3. App completes onboarding and immediately navigates to Settings → Agents.
|
|
//
|
|
// This test exercises the real App.tsx effect that gates router.navigate() on
|
|
// machine.stage === "ready", which the pure-logic tests in
|
|
// postOnboardingNav.test.mjs cannot cover (they simulate the predicate, not
|
|
// the real render path).
|
|
|
|
test("onboarding setup More-harnesses click navigates to Settings → Agents", async ({
|
|
page,
|
|
}) => {
|
|
// Start with a fresh machine (no machine-onboarding-complete flag).
|
|
// skipCommunitySeed: true so the user goes through machine onboarding.
|
|
// skipOnboardingSeed: true so the community/identity banner doesn't appear.
|
|
await installMockBridge(page, undefined, {
|
|
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 setup by creating a new identity key and continuing past the
|
|
// created-key page without opening the optional backup options.
|
|
await page.getByRole("button", { name: "Create a new identity key" }).click();
|
|
await passThroughBackupStep(page);
|
|
|
|
// Now on the setup page.
|
|
await expect(
|
|
page.getByRole("heading", { name: "Set up your agent harnesses" }),
|
|
).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Click the "More harnesses" link — fires navigateToAgentSettings.
|
|
await page.getByTestId("onboarding-setup-more-harnesses").click();
|
|
|
|
// After onboarding completes + router mounts, the app must land on
|
|
// Settings → Agents (consolidated harnesses section visible).
|
|
await expect(page.getByTestId("settings-harnesses")).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
});
|