diff --git a/extensions/diffs/src/browser.test.ts b/extensions/diffs/src/browser.test.ts index 6eae17c68065..ced22a4aa6ef 100644 --- a/extensions/diffs/src/browser.test.ts +++ b/extensions/diffs/src/browser.test.ts @@ -1,6 +1,7 @@ // Diffs tests cover browser plugin behavior. import fs from "node:fs/promises"; import type { IncomingMessage, ServerResponse } from "node:http"; +import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import type { @@ -48,10 +49,16 @@ describe("PlaywrightDiffScreenshotter", () => { let rootDir: string; let outputPath: string; let cleanupRootDir: () => Promise; + let originalPlatform: PropertyDescriptor; beforeEach(async () => { vi.useFakeTimers(); vi.resetModules(); + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!platformDescriptor) { + throw new Error("process.platform descriptor is unavailable"); + } + originalPlatform = platformDescriptor; ({ PlaywrightDiffScreenshotter } = await import("./browser.js")); ({ rootDir, cleanup: cleanupRootDir } = await createTempDiffRoot("openclaw-diffs-browser-")); outputPath = path.join(rootDir, "preview.png"); @@ -59,11 +66,114 @@ describe("PlaywrightDiffScreenshotter", () => { }); afterEach(async () => { + Object.defineProperty(process, "platform", originalPlatform); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); await vi.runAllTimersAsync(); vi.useRealTimers(); await cleanupRootDir(); }); + async function renderWithBrowserDiscovery(): Promise<{ executablePath?: string }> { + launchMock.mockResolvedValue(createMockBrowser([])); + const screenshotter = new PlaywrightDiffScreenshotter({ config: {}, browserIdleMs: 1_000 }); + await screenshotter.screenshotHtml({ + html: '
', + outputPath, + theme: "dark", + image: { + format: "png", + qualityPreset: "standard", + scale: 1, + maxWidth: 960, + maxPixels: 8_000_000, + }, + }); + return firstMockCall(launchMock, "browser launch")[0] as { executablePath?: string }; + } + + function stubWindowsBrowserDiscoveryEnv(params: { + localAppData: string; + programFiles: string; + programFilesX86: string; + }): void { + Object.defineProperty(process, "platform", { + ...originalPlatform, + value: "win32", + }); + vi.stubEnv("PATH", ""); + vi.stubEnv("OPENCLAW_BROWSER_EXECUTABLE_PATH", ""); + vi.stubEnv("BROWSER_EXECUTABLE_PATH", ""); + vi.stubEnv("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", ""); + vi.stubEnv("LOCALAPPDATA", params.localAppData); + vi.stubEnv("ProgramFiles", params.programFiles); + vi.stubEnv("ProgramFiles(x86)", params.programFilesX86); + } + + it("uses the Windows per-user install root when LOCALAPPDATA is blank", async () => { + stubWindowsBrowserDiscoveryEnv({ + localAppData: " \t ", + programFiles: "", + programFilesX86: " ", + }); + vi.spyOn(os, "homedir").mockReturnValue("C:\\Users\\test"); + const chromePath = "C:\\Users\\test\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe"; + const accessMock = vi.spyOn(fs, "access").mockImplementation(async (candidate) => { + if (String(candidate) !== chromePath) { + throw new Error("ENOENT"); + } + }); + + await expect(renderWithBrowserDiscovery()).resolves.toEqual( + expect.objectContaining({ executablePath: chromePath }), + ); + expect(accessMock.mock.calls.map(([candidate]) => String(candidate))).toEqual([chromePath]); + }); + + it("uses standard Windows system roots when install-root overrides are blank", async () => { + stubWindowsBrowserDiscoveryEnv({ + localAppData: " ", + programFiles: " \t ", + programFilesX86: "", + }); + vi.spyOn(os, "homedir").mockReturnValue("C:\\Users\\test"); + const accessMock = vi.spyOn(fs, "access").mockRejectedValue(new Error("ENOENT")); + + await expect(renderWithBrowserDiscovery()).resolves.not.toHaveProperty("executablePath"); + const candidates = accessMock.mock.calls.map(([candidate]) => String(candidate)); + expect(candidates).toEqual([ + "C:\\Users\\test\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe", + "C:\\Program Files (x86)\\BraveSoftware\\Brave-Browser\\Application\\brave.exe", + ]); + expect(candidates.every((candidate) => path.win32.isAbsolute(candidate))).toBe(true); + }); + + it("preserves custom Windows install-root precedence", async () => { + stubWindowsBrowserDiscoveryEnv({ + localAppData: "D:\\User Apps", + programFiles: "D:\\System Apps", + programFilesX86: "D:\\System Apps x86", + }); + const customChromePath = "D:\\User Apps\\Google\\Chrome\\Application\\chrome.exe"; + const accessMock = vi.spyOn(fs, "access").mockImplementation(async (candidate) => { + if (String(candidate) !== customChromePath) { + throw new Error("ENOENT"); + } + }); + + await expect(renderWithBrowserDiscovery()).resolves.toEqual( + expect.objectContaining({ executablePath: customChromePath }), + ); + expect(accessMock.mock.calls.map(([candidate]) => String(candidate))).toEqual([ + customChromePath, + ]); + }); + it("reuses the same browser across renders and closes it after the idle window", async () => { const { pages, browser, screenshotter } = await createScreenshotterHarness(); diff --git a/extensions/diffs/src/browser.ts b/extensions/diffs/src/browser.ts index 51fb2a027089..7c5d04d3d237 100644 --- a/extensions/diffs/src/browser.ts +++ b/extensions/diffs/src/browser.ts @@ -1,9 +1,11 @@ // Diffs plugin module implements browser behavior. import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { chromium } from "playwright-core"; import type { OpenClawConfig } from "../api.js"; import type { DiffRenderOptions, DiffTheme } from "./types.js"; @@ -520,17 +522,27 @@ function commonExecutablePathsForPlatform(): string[] { } if (process.platform === "win32") { - const localAppData = process.env.LOCALAPPDATA ?? ""; - const programFiles = process.env.ProgramFiles ?? "C:\\Program Files"; - const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"; + const joinWindowsPath = path.win32.join; + const localAppData = + normalizeOptionalString(process.env.LOCALAPPDATA) ?? + joinWindowsPath(os.homedir(), "AppData", "Local"); + const programFiles = normalizeOptionalString(process.env.ProgramFiles) ?? "C:\\Program Files"; + const programFilesX86 = + normalizeOptionalString(process.env["ProgramFiles(x86)"]) ?? "C:\\Program Files (x86)"; return [ - path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"), - path.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"), - path.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"), - path.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"), - path.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"), - path.join(programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), - path.join(programFilesX86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + joinWindowsPath(localAppData, "Google", "Chrome", "Application", "chrome.exe"), + joinWindowsPath(programFiles, "Google", "Chrome", "Application", "chrome.exe"), + joinWindowsPath(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"), + joinWindowsPath(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"), + joinWindowsPath(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"), + joinWindowsPath(programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + joinWindowsPath( + programFilesX86, + "BraveSoftware", + "Brave-Browser", + "Application", + "brave.exe", + ), ]; }