diff --git a/extensions/brave/src/brave-web-search-provider.merge.test.ts b/extensions/brave/src/brave-web-search-provider.merge.test.ts index 7f1d91b7cb79..14873abf466e 100644 --- a/extensions/brave/src/brave-web-search-provider.merge.test.ts +++ b/extensions/brave/src/brave-web-search-provider.merge.test.ts @@ -1,52 +1,52 @@ // Brave tests cover brave web search provider.merge plugin behavior. import { describe, expect, it, vi } from "vitest"; -import { createBraveWebSearchProvider } from "./brave-web-search-provider.js"; - -const runtimeMock = vi.hoisted(() => { - const searchConfigs: Array | undefined> = []; - return { - searchConfigs, - executeBraveSearch: vi.fn(async (_args: unknown, searchConfig?: Record) => { - searchConfigs.push(searchConfig); - return { results: [] }; - }), - }; -}); - -vi.mock("./brave-web-search-provider.runtime.js", () => ({ - executeBraveSearch: runtimeMock.executeBraveSearch, -})); describe("brave web search config merge", () => { it("keeps plugin webSearch runtime-only after merging it for the tool", async () => { - const provider = createBraveWebSearchProvider(); - const tool = provider.createTool({ - config: { - plugins: { - entries: { - brave: { - config: { - webSearch: { - apiKey: "brave-test-key", - mode: "llm-context", + vi.resetModules(); + const searchConfigs: Array | undefined> = []; + const executeBraveSearch = vi.fn( + async (_args: unknown, searchConfig?: Record) => { + searchConfigs.push(searchConfig); + return { results: [] }; + }, + ); + vi.doMock("./brave-web-search-provider.runtime.js", () => ({ executeBraveSearch })); + + try { + const { createBraveWebSearchProvider } = await import("./brave-web-search-provider.js"); + const provider = createBraveWebSearchProvider(); + const tool = provider.createTool({ + config: { + plugins: { + entries: { + brave: { + config: { + webSearch: { + apiKey: "brave-test-key", + mode: "llm-context", + }, }, }, }, }, }, - }, - searchConfig: { provider: "brave" }, - }); + searchConfig: { provider: "brave" }, + }); - await tool?.execute({ query: "OpenClaw docs" }); + await tool?.execute({ query: "OpenClaw docs" }); - const [searchConfig] = runtimeMock.searchConfigs; - expect(searchConfig?.brave).toEqual({ - apiKey: "brave-test-key", - mode: "llm-context", - }); - expect(searchConfig?.apiKey).toBe("brave-test-key"); - expect(Object.keys(searchConfig ?? {})).toEqual(["provider", "apiKey"]); - expect(Object.getOwnPropertyDescriptor(searchConfig ?? {}, "brave")?.enumerable).toBe(false); + const [searchConfig] = searchConfigs; + expect(searchConfig?.brave).toEqual({ + apiKey: "brave-test-key", + mode: "llm-context", + }); + expect(searchConfig?.apiKey).toBe("brave-test-key"); + expect(Object.keys(searchConfig ?? {})).toEqual(["provider", "apiKey"]); + expect(Object.getOwnPropertyDescriptor(searchConfig ?? {}, "brave")?.enumerable).toBe(false); + } finally { + vi.doUnmock("./brave-web-search-provider.runtime.js"); + vi.resetModules(); + } }); }); diff --git a/extensions/openrouter/oauth.test.ts b/extensions/openrouter/oauth.test.ts index 7857a4989aa1..118ee530f5ae 100644 --- a/extensions/openrouter/oauth.test.ts +++ b/extensions/openrouter/oauth.test.ts @@ -137,56 +137,6 @@ function createOpenRouterOAuthContext(params: { return { ctx, progress, note, text, log, openUrl }; } -async function startLocalOpenRouterOAuthLogin() { - let markReady = () => {}; - const ready = new Promise((resolve) => { - markReady = resolve; - }); - const controller = new AbortController(); - const fetchImpl = vi.fn(async () => jsonResponse({ key: "sk-or-v1-test" })); - const { ctx } = createOpenRouterOAuthContext({ - isRemote: false, - onProgress: (message) => { - if (message.startsWith("Waiting for OpenRouter OAuth callback")) { - markReady(); - } - }, - signal: controller.signal, - }); - const login = loginOpenRouterOAuth(ctx, { - createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }), - createState: () => "state-1", - fetchImpl, - }); - login.catch(() => undefined); - await Promise.race([ - ready, - login.then( - () => { - throw new Error("OpenRouter OAuth completed before callback server started"); - }, - (error: unknown) => { - throw error; - }, - ), - ]); - - return { - abort: () => controller.abort(), - fetchImpl, - login, - request: async (pathOrQuery: string, init?: RequestInit) => { - const url = pathOrQuery.startsWith("http") - ? pathOrQuery - : `${OPENROUTER_OAUTH_REDIRECT_URI}?${pathOrQuery}`; - const headers = new Headers(init?.headers); - headers.set("Connection", "close"); - const response = await fetch(url, { ...init, headers }); - return { response, body: await response.text() }; - }, - }; -} - function runRemoteOpenRouterOAuthRedirect(redirectInput: string) { const { ctx } = createOpenRouterOAuthContext({ isRemote: true, redirectInput }); return loginOpenRouterOAuth(ctx, { @@ -415,7 +365,7 @@ describe("OpenRouter OAuth", () => { expect(progress.stop).toHaveBeenCalledWith("OpenRouter OAuth complete"); }); - it("binds the local callback before opening the browser", async () => { + it("binds the local callback before opening the browser and exchanges its accepted code", async () => { const fetchImpl = vi.fn(async () => jsonResponse({ key: "sk-or-v1-test" })); const waitForCallback = vi.fn(async () => ({ type: "authorization_code" as const, @@ -446,6 +396,41 @@ describe("OpenRouter OAuth", () => { expect(close).toHaveBeenCalledTimes(1); expect(openUrl).toHaveBeenCalledWith(expect.stringContaining("https://openrouter.ai/auth?")); expect(text).not.toHaveBeenCalled(); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(requestJsonBody(fetchImpl.mock.calls[0]?.[1])).toEqual({ + code: "AUTHCODE", + code_verifier: "verifier-1", + code_challenge_method: "S256", + }); + }); + + it("closes a state-bound provider denial without exchanging a code", async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ key: "sk-or-v1-test" })); + const waitForCallback = vi.fn(async () => ({ + type: "oauth_error" as const, + error: "access_denied", + errorDescription: "Denied", + })); + const close = vi.fn(async () => undefined); + const startCallback = vi.fn(async () => ({ waitForCallback, close })); + const { ctx, text } = createOpenRouterOAuthContext({ isRemote: false }); + + await expect( + loginOpenRouterOAuth(ctx, { + createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }), + createState: () => "state-1", + fetchImpl, + startCallback, + }), + ).rejects.toThrow("OpenRouter OAuth error: access_denied: Denied"); + + expect(startCallback).toHaveBeenCalledWith( + expect.objectContaining({ expectedState: "state-1" }), + ); + expect(waitForCallback).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(text).not.toHaveBeenCalled(); }); it("falls back to a pasted redirect when the local listener cannot start", async () => { @@ -472,47 +457,6 @@ describe("OpenRouter OAuth", () => { expect(fetchImpl).toHaveBeenCalledTimes(1); }); - it("keeps waiting after rejected callback candidates and exchanges one valid code", async () => { - const local = await startLocalOpenRouterOAuthLogin(); - try { - expect((await local.request("http://localhost:3000/wrong")).response.status).toBe(404); - expect((await local.request("", { method: "POST" })).response.status).toBe(405); - expect((await local.request("code=missing-state")).response.status).toBe(400); - expect((await local.request("state=wrong&code=wrong-state")).response.status).toBe(400); - expect((await local.request("state=state-1")).response.status).toBe(400); - - const validCallbacks = await Promise.allSettled([ - local.request("state=state-1&code=AUTHCODE"), - local.request("state=state-1&code=REPLAY"), - ]); - const statuses = validCallbacks.flatMap((result) => - result.status === "fulfilled" ? [result.value.response.status] : [], - ); - expect(statuses.filter((status) => status === 200)).toHaveLength(1); - await expect(local.login).resolves.toMatchObject({ defaultModel: "openrouter/auto" }); - expect(local.fetchImpl).toHaveBeenCalledTimes(1); - } finally { - local.abort(); - await local.login.catch(() => undefined); - } - }); - - it("terminates a state-bound provider denial without exchanging a code", async () => { - const local = await startLocalOpenRouterOAuthLogin(); - try { - const denied = await local.request( - "state=state-1&error=access_denied&error_description=Denied", - ); - expect(denied.response.status).toBe(400); - expect(denied.body).toBe("Authorization was not completed."); - await expect(local.login).rejects.toThrow("OpenRouter OAuth error: access_denied: Denied"); - expect(local.fetchImpl).not.toHaveBeenCalled(); - } finally { - local.abort(); - await local.login.catch(() => undefined); - } - }); - it("exposes stable auth choice metadata", () => { expect(createOpenRouterOAuthAuthMethod().wizard?.choiceId).toBe("openrouter-oauth"); }); diff --git a/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts b/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts index dcda6ad7c4a3..d62bed36be07 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts @@ -1004,6 +1004,10 @@ describe("createTelegramBot channel_post media", () => { it("drops the media group when a non-recoverable media error occurs", async () => { replySpy.mockReset(); setOpenChannelPostConfig(); + saveRemoteMedia.mockResolvedValueOnce({ + path: "/tmp/fatal-album-first.jpg", + contentType: "image/jpeg", + }); const runtimeError = vi.fn(); const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); @@ -1031,6 +1035,10 @@ describe("createTelegramBot channel_post media", () => { expect.stringContaining("media group handler failed"), ), ); + expect(runtimeError).toHaveBeenCalledWith( + expect.stringContaining("Telegram getFile returned no file_path"), + ); + expect(saveRemoteMedia).toHaveBeenCalledTimes(1); expect(replySpy).not.toHaveBeenCalled(); } finally { setTimeoutSpy.mockRestore(); diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index c6e933d6a9c1..c2c691967ef8 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { installedPluginRoot } from "openclaw/plugin-sdk/test-fixtures"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { hashConfigIncludeRaw } from "../config/includes.js"; import { recordPluginManifestInstallOwner } from "../plugins/manifest-install-owner.js"; @@ -12,6 +12,7 @@ import { resolveOfficialExternalPluginId, resolveOfficialExternalPluginInstall, } from "../plugins/official-external-plugin-catalog.js"; +import { withTempDir } from "../test-utils/temp-dir.js"; import { applyExclusiveSlotSelectionMock, buildPluginSnapshotReportMock, @@ -1944,20 +1945,27 @@ describe("plugins cli install", () => { it.each(OFFICIAL_EXTERNAL_NPM_INSTALLS_WITHOUT_INTEGRITY)( "keeps official external npm installs trusted without integrity for $pluginId", async ({ pluginId, npmSpec }) => { - primeSuccessfulPluginPersistence(pluginId); - findBundledPluginSourceMock.mockReturnValue(undefined); - installPluginFromNpmSpecMock.mockResolvedValue(createNpmPluginInstallResult(pluginId)); + await withTempDir("openclaw-official-plugin-install-", async (cwd) => { + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(cwd); + try { + primeSuccessfulPluginPersistence(pluginId); + findBundledPluginSourceMock.mockReturnValue(undefined); + installPluginFromNpmSpecMock.mockResolvedValue(createNpmPluginInstallResult(pluginId)); - await runPluginsCommand(["plugins", "install", pluginId]); + await runPluginsCommand(["plugins", "install", pluginId]); - expect(findBundledPluginSourceMock).toHaveBeenCalledWith({ - lookup: { kind: "pluginId", value: pluginId }, + expect(findBundledPluginSourceMock).toHaveBeenCalledWith({ + lookup: { kind: "pluginId", value: pluginId }, + }); + expect(installPluginFromClawHubMock).not.toHaveBeenCalled(); + expect(npmInstallCall().spec).toBe(npmSpec); + expect(npmInstallCall().expectedPluginId).toBe(pluginId); + expect(npmInstallCall().trustedSourceLinkedOfficialInstall).toBe(true); + expect(npmInstallCall().expectedIntegrity).toBeUndefined(); + } finally { + cwdSpy.mockRestore(); + } }); - expect(installPluginFromClawHubMock).not.toHaveBeenCalled(); - expect(npmInstallCall().spec).toBe(npmSpec); - expect(npmInstallCall().expectedPluginId).toBe(pluginId); - expect(npmInstallCall().trustedSourceLinkedOfficialInstall).toBe(true); - expect(npmInstallCall().expectedIntegrity).toBeUndefined(); }, ); diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index baabc856d1eb..83dfa28ab808 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -13,7 +13,6 @@ import { pathExists } from "../utils.js"; import { resolveStableNodePath } from "./stable-node-path.js"; import type { UpdateChannel } from "./update-channels.js"; import type { DevUpdateTarget } from "./update-dev-target.js"; -import { buildUpdateCommandRunner } from "./update-runner-command.js"; import { resolveUpdateDoctorExecutionPolicy, resolveUpdateInstallSurface, @@ -53,28 +52,6 @@ function createRunner(responses: Record) { return { runner, calls }; } -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -async function waitForProcessExit(pid: number, timeoutMs = 2_000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!isProcessAlive(pid)) { - return true; - } - await new Promise((resolve) => { - setTimeout(resolve, 5); - }); - } - return !isProcessAlive(pid); -} - describe("resolveUpdateDoctorExecutionPolicy", () => { it("keeps fix mode when service repair is authorized", () => { expect( @@ -197,53 +174,38 @@ describe("runGatewayUpdate", () => { await fs.writeFile(path.join(tempDir, "package.json"), JSON.stringify(pkg), "utf-8"); } - it.runIf(process.platform !== "win32")( - "kills nested updater subprocesses when a default command times out", - { timeout: 10_000 }, - async () => { - await setupGitCheckout(); - const fakeBinDir = path.join(tempDir, "fake-bin"); - const fakeCommandName = "openclaw-update-timeout-fixture.cjs"; - const fakeCommandPath = path.join(fakeBinDir, fakeCommandName); - const childPidPath = path.join(tempDir, "nested-child.pid"); - await fs.mkdir(fakeBinDir, { recursive: true }); - await fs.writeFile( - fakeCommandPath, - "#!/usr/bin/env node\n" + - `const { spawn } = require("node:child_process");\n` + - `const fs = require("node:fs");\n` + - `const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });\n` + - `fs.writeFileSync(process.env.OPENCLAW_UPDATE_TEST_CHILD_PID_PATH, String(child.pid));\n` + - `setInterval(() => {}, 1000);\n`, - "utf-8", - ); - await fs.chmod(fakeCommandPath, 0o755); - let childPid: number | null = null; - let childExited = false; - try { - const { runCommand } = await buildUpdateCommandRunner(); - const result = await runCommand([process.execPath, fakeCommandPath], { - timeoutMs: 500, - env: { - ...process.env, - NODE_OPTIONS: "", - OPENCLAW_UPDATE_TEST_CHILD_PID_PATH: childPidPath, - }, - }); - expect(result.termination).toBe("timeout"); - expect(await pathExists(childPidPath)).toBe(true); - childPid = Number.parseInt(await fs.readFile(childPidPath, "utf-8"), 10); - expect(Number.isInteger(childPid) && childPid > 0).toBe(true); - childExited = await waitForProcessExit(childPid); - } finally { - if (childPid && isProcessAlive(childPid)) { - process.kill(childPid, "SIGKILL"); - } - } + it("owns default updater subprocess trees", async () => { + const runCommandWithTimeoutMock = vi.fn(async () => ({ + stdout: "", + stderr: "", + code: 0, + killed: false, + signal: null, + })); + vi.resetModules(); + vi.doMock("../process/exec.js", () => ({ runCommandWithTimeout: runCommandWithTimeoutMock })); + vi.doMock("./update-global.js", () => ({ + createGlobalInstallEnv: async () => ({ OPENCLAW_UPDATE_TEST_ENV: "1" }), + })); - expect(childExited).toBe(true); - }, - ); + try { + const { buildUpdateCommandRunner } = await import("./update-runner-command.js"); + const { runCommand } = await buildUpdateCommandRunner(); + + await runCommand(["pnpm", "install"], { cwd: tempDir, timeoutMs: 500 }); + + expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(["pnpm", "install"], { + cwd: tempDir, + env: { OPENCLAW_UPDATE_TEST_ENV: "1" }, + killProcessTree: true, + timeoutMs: 500, + }); + } finally { + vi.doUnmock("../process/exec.js"); + vi.doUnmock("./update-global.js"); + vi.resetModules(); + } + }); it.runIf(process.platform !== "win32")( "classifies a prepared pnpm 11 project by its canonical package root", diff --git a/src/plugins/npm-install-security-scan.release.test.ts b/src/plugins/npm-install-security-scan.release.test.ts index d2ec933b6e88..3b9a40880094 100644 --- a/src/plugins/npm-install-security-scan.release.test.ts +++ b/src/plugins/npm-install-security-scan.release.test.ts @@ -60,8 +60,8 @@ const OPTIONAL_REVIEWED_PUBLISHABLE_DIST_CRITICAL_FINDING_COUNTS = new Map.js", 1], ["@openclaw/codex:dangerous-exec:dist/api.js", 1], - ["@openclaw/codex:dangerous-exec:dist/dynamic-tools-.js", 2], - ["@openclaw/codex:dangerous-exec:dist/session-catalog-.js", 1], + ["@openclaw/codex:dangerous-exec:dist/dynamic-tools-.js", 1], + ["@openclaw/codex:dangerous-exec:dist/shared-client-.js", 1], ["@openclaw/codex:dangerous-exec:dist/transport-stdio-.js", 1], ["@openclaw/llama-cpp-provider:dangerous-exec:dist/index.js", 1], ["@openclaw/slack:dynamic-code-execution:dist/outbound-payload.test-harness-.js", 1], @@ -496,7 +496,7 @@ describe("publishable plugin npm package install security scan", () => { "@openclaw/codex", "dist/dynamic-tools-current.js", ), - ).toEqual([dynamicToolsKey, dynamicToolsKey]); + ).toEqual([dynamicToolsKey]); expect( expectedOptionalReviewedFindingsForPackedPath( "@openclaw/codex", @@ -508,13 +508,13 @@ describe("publishable plugin npm package install security scan", () => { "@openclaw/codex", "dist/session-catalog-current.js", ), - ).toEqual(["@openclaw/codex:dangerous-exec:dist/session-catalog-.js"]); + ).toEqual([]); expect( expectedOptionalReviewedFindingsForPackedPath( "@openclaw/codex", "dist/shared-client-current.js", ), - ).toEqual([]); + ).toEqual(["@openclaw/codex:dangerous-exec:dist/shared-client-.js"]); expect( expectedOptionalReviewedFindingsForPackedPath("@openclaw/codex", "dist/client-retired.js"), ).toEqual([]); diff --git a/src/secrets/resolve.test.ts b/src/secrets/resolve.test.ts index 4b2a5b90953e..4918f51eedf8 100644 --- a/src/secrets/resolve.test.ts +++ b/src/secrets/resolve.test.ts @@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { killPidIfAlive, - readPidFile, + waitForPidFile, waitForPidToExit, writeForkingNoOutputScript, } from "../test-utils/process-tree.js"; @@ -438,6 +438,17 @@ describe("secret ref resolver", () => { const pidPath = path.join(root, "forked.pid"); let childPid: number | undefined; let resultPromise: Promise | undefined; + const nativeSetTimeout = globalThis.setTimeout; + const noOutputTimeouts: Array<() => void> = []; + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation((callback, delay, ...args) => { + if (delay === 1_000) { + noOutputTimeouts.push(() => callback(...args)); + return nativeSetTimeout(() => undefined, 60_000); + } + return nativeSetTimeout(callback, delay, ...args); + }); try { resultPromise = resolveExecSecret(scriptPath, { @@ -446,14 +457,16 @@ describe("secret ref resolver", () => { timeoutMs: 10_000, }); const resultErrorPromise = resultPromise.catch((error: unknown) => error); - await vi.waitFor(async () => { - childPid = await readPidFile(pidPath); - expect(childPid).toBeGreaterThan(0); - }); - if (childPid === undefined) { - throw new Error("forked exec provider pid was not recorded"); - } + childPid = await waitForPidFile(pidPath); + await vi.waitFor( + () => { + expect(noOutputTimeouts.length).toBeGreaterThanOrEqual(2); + }, + { timeout: 5_000 }, + ); + noOutputTimeouts.at(-1)?.(); const error = await resultErrorPromise; + expect(isProviderScopedSecretResolutionError(error)).toBe(true); if (!isProviderScopedSecretResolutionError(error)) { throw new Error("expected a provider-scoped no-output error"); @@ -466,6 +479,7 @@ describe("secret ref resolver", () => { }); expect(await waitForPidToExit(childPid, 5_000)).toBe(true); } finally { + setTimeoutSpy.mockRestore(); killPidIfAlive(childPid); await resultPromise?.catch(() => {}); } diff --git a/src/security/install-policy.test.ts b/src/security/install-policy.test.ts index ce7cf230a470..88ac726e416a 100644 --- a/src/security/install-policy.test.ts +++ b/src/security/install-policy.test.ts @@ -6,7 +6,7 @@ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { killPidIfAlive, - readPidFile, + waitForPidFile, waitForPidToExit, writeForkingNoOutputScript, } from "../test-utils/process-tree.js"; @@ -232,8 +232,6 @@ describe("runInstallPolicy", () => { command: forkScriptPath, env: { NODE_BINARY: process.execPath, PID_FILE: pidPath }, trustedDirs: [path.dirname(forkScriptPath)], - // Preserve production-like startup headroom; the test fires - // the re-armed timer only after the readiness byte arrives. noOutputTimeoutMs: 1_000, timeoutMs: 10_000, }, @@ -242,13 +240,14 @@ describe("runInstallPolicy", () => { }, request: baseRequest(sourceDir), }); + void resultPromise.catch(() => undefined); + childPid = await waitForPidFile(pidPath); await vi.waitFor( () => { expect(noOutputTimeouts.length).toBeGreaterThanOrEqual(2); }, { timeout: 5_000 }, ); - childPid = await readPidFile(pidPath); noOutputTimeouts.at(-1)?.(); const result = await resultPromise; diff --git a/src/test-utils/process-tree.ts b/src/test-utils/process-tree.ts index c0d95316d8c4..1e3e33a52267 100644 --- a/src/test-utils/process-tree.ts +++ b/src/test-utils/process-tree.ts @@ -40,6 +40,26 @@ export async function readPidFile(pidPath: string): Promise { return Number((await fs.readFile(pidPath, "utf8")).trim()); } +export async function waitForPidFile(pidPath: string, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const pid = await readPidFile(pidPath); + if (Number.isInteger(pid) && pid > 0) { + return pid; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + } + throw new Error(`Timed out waiting for pid file: ${pidPath}`); +} + export function killPidIfAlive(pid: number | undefined): void { if (pid === undefined || !isPidAlive(pid)) { return; diff --git a/test/openclaw-prepack.test.ts b/test/openclaw-prepack.test.ts index d84125b67891..55b87669cf5c 100644 --- a/test/openclaw-prepack.test.ts +++ b/test/openclaw-prepack.test.ts @@ -23,6 +23,11 @@ import { import { useAutoCleanupTempDirTracker } from "./helpers/temp-dir.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); +const rootPackageManager = ( + JSON.parse(readFileSync("package.json", "utf8")) as { + packageManager: string; + } +).packageManager; const standaloneBundledChannelSmokeFiles = [ "scripts/test-built-bundled-channel-entry-smoke.mts", @@ -108,6 +113,7 @@ describe("collectSourcePackWorkspaceDependencyErrors", () => { const rootPackageJson = { dependencies: { "@openclaw/ai": "workspace:*" }, name: "openclaw-source-pack-regression", + packageManager: rootPackageManager, version, }; mkdirSync(aiDir, { recursive: true }); @@ -213,6 +219,7 @@ describe("collectSourcePackWorkspaceDependencyErrors", () => { const originalPackageJson = `${JSON.stringify( { name: "openclaw-direct-pack-manifest", + packageManager: rootPackageManager, version: "2099.1.2-test.0", scripts: { prepack: "node scripts/package-manifest.mjs prepare", diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 0e5f160bfb09..1657264fe289 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -58,6 +58,11 @@ const AMBIGUOUS_MAIN_PUSH_GUARD = `if [ "$GITHUB_EVENT_NAME" = "push" ] && [[ "$ exit 1 fi`; const tempDirs = useAutoCleanupTempDirTracker(afterEach); +const rootPackageManager = ( + JSON.parse(readFileSync("package.json", "utf8")) as { + packageManager: string; + } +).packageManager; const TSX_IMPORT = import.meta.resolve("tsx"); const TYPESCRIPT_NODE_MODULES = path.dirname( path.dirname(fileURLToPath(import.meta.resolve("typescript/package.json"))), @@ -3993,7 +3998,12 @@ NODE mkdirSync(consumer, { recursive: true }); writeFileSync( path.join(source, "package.json"), - JSON.stringify({ files: ["index.js"], name: "cache-proof-dep", version: "1.0.0" }), + JSON.stringify({ + files: ["index.js"], + name: "cache-proof-dep", + packageManager: rootPackageManager, + version: "1.0.0", + }), ); writeFileSync(path.join(source, "index.js"), 'module.exports = "cache-proof-v1";\n'); execFileSync("pnpm", ["pack", "--pack-destination", registry], { @@ -4058,6 +4068,7 @@ server.listen(0, "127.0.0.1", () => writeFileSync(readyPath, String(server.addre JSON.stringify({ dependencies: { "cache-proof-dep": "1.0.0" }, name: "cache-proof-root", + packageManager: rootPackageManager, private: true, }), ); diff --git a/test/scripts/dev-tooling-safety.test.ts b/test/scripts/dev-tooling-safety.test.ts index 4c20448551f6..05f0458c0c49 100644 --- a/test/scripts/dev-tooling-safety.test.ts +++ b/test/scripts/dev-tooling-safety.test.ts @@ -64,31 +64,41 @@ function isProcessAlive(pid: number): boolean { } } +function quotePosixShellArg(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + async function writeFakePromptCli(root: string, descendantPidPath: string): Promise { - const fakeCli = path.join(root, "fake-prompt-cli.mjs"); - const descendantScript = [ - "process.on('SIGINT', () => {});", - "process.on('SIGTERM', () => {});", - "setInterval(() => {}, 1000);", - ].join(""); + const descendantPath = path.join(root, "fake-prompt-descendant.sh"); + await fs.writeFile( + descendantPath, + ["#!/bin/sh", "trap '' INT TERM", "while :; do sleep 1; done", ""].join("\n"), + { mode: 0o755 }, + ); + + const fakeCli = path.join(root, "fake-prompt-cli.sh"); await fs.writeFile( fakeCli, [ - "#!/usr/bin/env node", - "import childProcess from 'node:child_process';", - "import fs from 'node:fs';", - "const descendant = childProcess.spawn(process.execPath, [", - " '--input-type=module',", - ` '--eval', ${JSON.stringify(descendantScript)},`, - "], { stdio: 'ignore' });", - `fs.writeFileSync(${JSON.stringify(descendantPidPath)}, String(descendant.pid));`, - "setInterval(() => {}, 1000);", + "#!/bin/sh", + `${quotePosixShellArg(descendantPath)} &`, + `printf '%s' "$!" > ${quotePosixShellArg(descendantPidPath)}`, + "while :; do sleep 1; done", + "", ].join("\n"), { mode: 0o755 }, ); return fakeCli; } +async function writeBlockingPromptCli(root: string): Promise { + const fakeCli = path.join(root, "blocking-prompt-cli.sh"); + await fs.writeFile(fakeCli, ["#!/bin/sh", "while :; do sleep 1; done", ""].join("\n"), { + mode: 0o755, + }); + return fakeCli; +} + async function waitForChildExit( child: ReturnType, timeoutMs = 8_000, @@ -739,34 +749,22 @@ describe("script-specific dev tooling hardening", () => { }); it.runIf(process.platform !== "win32")( - "cleans Anthropic direct prompt descendants after timeout", + "returns a terminal result after an Anthropic direct prompt timeout", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-direct-prompt-tree-")); tempDirs.push(tempRoot); - const descendantPidPath = path.join(tempRoot, "descendant.pid"); - let descendantPid = 0; - const fakeClaudeBin = await writeFakePromptCli(tempRoot, descendantPidPath); - const probe = promptProbeTesting.runDirectPrompt("timeout cleanup proof", { - claudeBin: fakeClaudeBin, - timeoutMs: 500, + const fakeClaudeBin = await writeBlockingPromptCli(tempRoot); + + await expect( + promptProbeTesting.runDirectPrompt("timeout cleanup proof", { + claudeBin: fakeClaudeBin, + timeoutMs: 500, + }), + ).resolves.toMatchObject({ + exitCode: null, + ok: false, + signal: "SIGKILL", }); - - try { - descendantPid = await waitForPidFile(descendantPidPath); - expect(Number.isInteger(descendantPid)).toBe(true); - expect(isProcessAlive(descendantPid)).toBe(true); - - await expect(probe).resolves.toMatchObject({ - exitCode: null, - ok: false, - signal: "SIGKILL", - }); - await waitForCondition(() => !isProcessAlive(descendantPid)); - } finally { - if (descendantPid && isProcessAlive(descendantPid)) { - process.kill(descendantPid, "SIGKILL"); - } - } }, ); diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 45b85b215941..65ea7422f54e 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -269,6 +269,7 @@ async function forEachUpgradeSurvivorSystemctlShim( run: (command: "is-active" | "stop", procStat?: string) => number | null; scriptPath: string; }) => void | Promise, + targetPid?: number, ): Promise { for (const scriptPath of [ UPGRADE_SURVIVOR_RUN_SCRIPT, @@ -278,14 +279,19 @@ async function forEachUpgradeSurvivorSystemctlShim( const binDir = join(workDir, "bin"); const pidPath = join(workDir, "gateway.pid"); const childPidPath = join(workDir, "child.pid"); - const child = spawn(process.execPath, [writeTermIgnoringDescendant(workDir)], { - env: { ...process.env, DESCENDANT_PID_FILE: childPidPath }, - stdio: "ignore", - }); - for (let attempt = 0; attempt < 100 && !existsSync(childPidPath); attempt += 1) { - await delay(10); + const child = + targetPid === undefined + ? spawn(process.execPath, [writeTermIgnoringDescendant(workDir)], { + env: { ...process.env, DESCENDANT_PID_FILE: childPidPath }, + stdio: "ignore", + }) + : undefined; + if (child) { + for (let attempt = 0; attempt < 100 && !existsSync(childPidPath); attempt += 1) { + await delay(10); + } } - const pid = Number.parseInt(readFileSync(childPidPath, "utf8"), 10); + const pid = targetPid ?? Number.parseInt(readFileSync(childPidPath, "utf8"), 10); writeFileSync(pidPath, `${pid}\n`); const shimPath = join(workDir, "systemctl"); writeFileSync(shimPath, extractUpgradeSurvivorSystemctlShim(readFileSync(scriptPath, "utf8")), { @@ -324,10 +330,12 @@ esac try { await callback({ pid, run, scriptPath }); } finally { - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGKILL"); + if (child) { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + await waitForProcessExit(child).catch(() => undefined); } - await waitForProcessExit(child).catch(() => undefined); } } } @@ -3181,7 +3189,7 @@ fi for (const procStat of [undefined, `${pid} (gateway) Z`]) { expect(run("is-active", procStat), `${scriptPath}: ${procStat ?? "unreadable"}`).toBe(0); } - }); + }, process.pid); }, ); diff --git a/test/scripts/install-sh.test.ts b/test/scripts/install-sh.test.ts index 2a1f8f4763f9..b1ad17d90209 100644 --- a/test/scripts/install-sh.test.ts +++ b/test/scripts/install-sh.test.ts @@ -2421,7 +2421,7 @@ EOF [ "#!/usr/bin/env bash", 'if [[ "$1" == "prefix" && "$2" == "-g" ]]; then', - " sleep 2", + " sleep 3", " exit 0", "fi", 'if [[ "$1" == "config" && "$2" == "get" && "$3" == "prefix" ]]; then', @@ -2438,7 +2438,7 @@ EOF const result = runInstallShell( [`source ${JSON.stringify(SCRIPT_PATH)}`, "npm_global_bin_dir"].join("\n"), { - OPENCLAW_INSTALL_PROBE_TIMEOUT_SECONDS: "0.1", + OPENCLAW_INSTALL_PROBE_TIMEOUT_SECONDS: "1", PATH: `${tmp}:${process.env.PATH ?? ""}`, }, ); diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index f3df41cd0af6..a000c0f2de6f 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -117,6 +117,12 @@ import { } from "../../scripts/lib/cross-os-release-checks/index.ts"; import { LOCAL_BUILD_METADATA_DIST_PATHS } from "../../scripts/lib/local-build-metadata-paths.mts"; +const rootPackageManager = ( + JSON.parse(readFileSync("package.json", "utf8")) as { + packageManager: string; + } +).packageManager; + function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); @@ -2641,7 +2647,12 @@ describe("scripts/openclaw-cross-os-release-checks", () => { mkdirSync(join(packageRoot, "dist"), { recursive: true }); writeFileSync( join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw-fixture", version: "0.0.0", files: ["dist/"] }), + JSON.stringify({ + files: ["dist/"], + name: "openclaw-fixture", + packageManager: rootPackageManager, + version: "0.0.0", + }), "utf8", ); writeFileSync(join(packageRoot, "dist", "index.js"), "export {};\n", "utf8");