diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d0c6480..00d69a3f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI: resolve bundled Chrome extension assets by walking up to the nearest assets directory; add resolver and clipboard tests. (#8914) Thanks @kelvinCB. - Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411. - TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras. - Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard. diff --git a/src/cli/browser-cli-extension.test.ts b/src/cli/browser-cli-extension.test.ts index f92a264c7..de279dd23 100644 --- a/src/cli/browser-cli-extension.test.ts +++ b/src/cli/browser-cli-extension.test.ts @@ -1,20 +1,116 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { installChromeExtension } from "./browser-cli-extension"; +import { describe, expect, it, vi } from "vitest"; -describe("browser extension install", () => { - it("installs bundled chrome extension into a state dir", async () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-state-")); +const copyToClipboard = vi.fn(); +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +vi.mock("../infra/clipboard.js", () => ({ + copyToClipboard, +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime: runtime, +})); + +function writeManifest(dir: string) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify({ manifest_version: 3 })); +} + +describe("bundled extension resolver", () => { + it("walks up to find the assets directory", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-root-")); + const here = path.join(root, "dist", "cli"); + const assets = path.join(root, "assets", "chrome-extension"); try { - const result = await installChromeExtension({ stateDir: tmp }); + writeManifest(assets); + fs.mkdirSync(here, { recursive: true }); + + const { resolveBundledExtensionRootDir } = await import("./browser-cli-extension.js"); + expect(resolveBundledExtensionRootDir(here)).toBe(assets); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("prefers the nearest assets directory", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-root-")); + const here = path.join(root, "dist", "cli"); + const distAssets = path.join(root, "dist", "assets", "chrome-extension"); + const rootAssets = path.join(root, "assets", "chrome-extension"); + + try { + writeManifest(distAssets); + writeManifest(rootAssets); + fs.mkdirSync(here, { recursive: true }); + + const { resolveBundledExtensionRootDir } = await import("./browser-cli-extension.js"); + expect(resolveBundledExtensionRootDir(here)).toBe(distAssets); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("browser extension install", () => { + it("installs into the state dir (never node_modules)", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-")); + + try { + const { installChromeExtension } = await import("./browser-cli-extension.js"); + const sourceDir = path.resolve(process.cwd(), "assets/chrome-extension"); + const result = await installChromeExtension({ stateDir: tmp, sourceDir }); expect(result.path).toBe(path.join(tmp, "browser", "chrome-extension")); expect(fs.existsSync(path.join(result.path, "manifest.json"))).toBe(true); + expect(result.path.includes("node_modules")).toBe(false); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); + + it("copies extension path to clipboard", async () => { + const prev = process.env.OPENCLAW_STATE_DIR; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-path-")); + process.env.OPENCLAW_STATE_DIR = tmp; + + try { + copyToClipboard.mockReset(); + copyToClipboard.mockResolvedValue(true); + runtime.log.mockReset(); + runtime.error.mockReset(); + runtime.exit.mockReset(); + + const dir = path.join(tmp, "browser", "chrome-extension"); + writeManifest(dir); + + vi.resetModules(); + const { Command } = await import("commander"); + const { registerBrowserExtensionCommands } = await import("./browser-cli-extension.js"); + + const program = new Command(); + const browser = program.command("browser").option("--json", false); + registerBrowserExtensionCommands( + browser, + (cmd) => cmd.parent?.opts?.() as { json?: boolean }, + ); + + await program.parseAsync(["browser", "extension", "path"], { from: "user" }); + + expect(copyToClipboard).toHaveBeenCalledWith(dir); + } finally { + if (prev === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = prev; + } + } + }); }); diff --git a/src/cli/browser-cli-extension.ts b/src/cli/browser-cli-extension.ts index 9c99d0763..1ca53d985 100644 --- a/src/cli/browser-cli-extension.ts +++ b/src/cli/browser-cli-extension.ts @@ -12,26 +12,23 @@ import { theme } from "../terminal/theme.js"; import { shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; -function bundledExtensionRootDir() { - const here = path.dirname(fileURLToPath(import.meta.url)); - - // `here` is the directory containing this file. - // - In source runs/tests, it's typically `/src/cli`. - // - In transpiled builds, it's typically `/dist/cli`. - // - In bundled builds, it may be `/dist`. - // The bundled extension lives at `/assets/chrome-extension`. - // - // Prefer the most common layouts first and fall back if needed. - const candidates = [ - path.resolve(here, "../assets/chrome-extension"), - path.resolve(here, "../../assets/chrome-extension"), - ]; - for (const candidate of candidates) { +export function resolveBundledExtensionRootDir( + here = path.dirname(fileURLToPath(import.meta.url)), +) { + let current = here; + while (true) { + const candidate = path.join(current, "assets", "chrome-extension"); if (hasManifest(candidate)) { return candidate; } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; } - return candidates[0]; + + return path.resolve(here, "../../assets/chrome-extension"); } function installedExtensionRootDir() { @@ -46,7 +43,7 @@ export async function installChromeExtension(opts?: { stateDir?: string; sourceDir?: string; }): Promise<{ path: string }> { - const src = opts?.sourceDir ?? bundledExtensionRootDir(); + const src = opts?.sourceDir ?? resolveBundledExtensionRootDir(); if (!hasManifest(src)) { throw new Error("Bundled Chrome extension is missing. Reinstall OpenClaw and try again."); }