test(hooks): replace global module and timer overrides

This commit is contained in:
YeonGyu-Kim
2026-05-30 23:50:57 +09:00
parent 4a15dfe971
commit 1b9e667486
7 changed files with 80 additions and 68 deletions
+8 -6
View File
@@ -1,6 +1,8 @@
/// <reference types="bun-types" /> /// <reference types="bun-types" />
import { afterEach, describe, expect, it, mock } from "bun:test" import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"
import * as spawnWithTimeoutModule from "../spawn-with-timeout"
const originalWhich = Bun.which const originalWhich = Bun.which
@@ -13,18 +15,18 @@ describe("getGhCliInfo", () => {
it("falls back to gh --version when Bun.which cannot find gh", async () => { it("falls back to gh --version when Bun.which cannot find gh", async () => {
// given // given
Bun.which = mock(() => null) Bun.which = mock(() => null)
mock.module("../spawn-with-timeout", () => ({ spyOn(spawnWithTimeoutModule, "spawnWithTimeout").mockImplementation(
spawnWithTimeout: mock((command: string[]) => { (command: string[]) => {
if (command.join(" ") === "gh --version") { if (command.join(" ") === "gh --version") {
return Promise.resolve({ stdout: "gh version 2.82.1\n", stderr: "", exitCode: 0, timedOut: false }) return Promise.resolve({ stdout: "gh version 2.82.1\n", stderr: "", exitCode: 0, timedOut: false })
} }
return Promise.resolve({ stdout: "", stderr: "not logged in", exitCode: 1, timedOut: false }) return Promise.resolve({ stdout: "", stderr: "not logged in", exitCode: 1, timedOut: false })
}), }
})) )
const { getGhCliInfo } = await import("./tools-gh")
// when // when
const { getGhCliInfo } = await import("./tools-gh")
const info = await getGhCliInfo() const info = await getGhCliInfo()
// then // then
+3 -3
View File
@@ -1,4 +1,4 @@
import { spawnWithTimeout } from "../spawn-with-timeout" import * as spawnWithTimeoutModule from "../spawn-with-timeout"
export interface GhCliInfo { export interface GhCliInfo {
installed: boolean installed: boolean
@@ -21,7 +21,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat
async function getGhVersion(): Promise<string | null> { async function getGhVersion(): Promise<string | null> {
try { try {
const result = await spawnWithTimeout(["gh", "--version"], { stdout: "pipe", stderr: "pipe" }) const result = await spawnWithTimeoutModule.spawnWithTimeout(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
if (result.timedOut || result.exitCode !== 0) return null if (result.timedOut || result.exitCode !== 0) return null
const matchedVersion = result.stdout.match(/gh version (\S+)/) const matchedVersion = result.stdout.match(/gh version (\S+)/)
@@ -38,7 +38,7 @@ async function getGhAuthStatus(): Promise<{
error: string | null error: string | null
}> { }> {
try { try {
const result = await spawnWithTimeout( const result = await spawnWithTimeoutModule.spawnWithTimeout(
["gh", "auth", "status"], ["gh", "auth", "status"],
{ stdout: "pipe", stderr: "pipe", env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" } } { stdout: "pipe", stderr: "pipe", env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" } }
) )
@@ -1,10 +1,10 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { spawnSync } from "node:child_process"
import * as fs from "node:fs" import * as fs from "node:fs"
import * as os from "node:os" import * as os from "node:os"
import * as path from "node:path" import * as path from "node:path"
import { PACKAGE_NAME } from "../constants" import { PACKAGE_NAME } from "../constants"
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity" import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity"
import { findPluginEntry } from "./plugin-entry"
type PluginEntryResult = { type PluginEntryResult = {
entry: string entry: string
@@ -17,25 +17,33 @@ function runFindPluginEntry(
directory: string, directory: string,
envOverrides: Record<string, string | undefined> = {}, envOverrides: Record<string, string | undefined> = {},
): { status: number | null; stdout: string; stderr: string } { ): { status: number | null; stdout: string; stderr: string } {
const command = [ const originalValues = new Map<string, string | undefined>()
`import { findPluginEntry } from ${JSON.stringify("./src/hooks/auto-update-checker/checker/plugin-entry")};`, for (const key of Object.keys(envOverrides)) {
`const result = findPluginEntry(${JSON.stringify(directory)});`, originalValues.set(key, process.env[key])
"console.log(JSON.stringify(result));", }
].join("")
const execution = spawnSync(process.execPath, ["-e", command], { try {
cwd: process.cwd(), for (const [key, value] of Object.entries(envOverrides)) {
env: { if (value === undefined) {
...process.env, delete process.env[key]
...envOverrides, } else {
}, process.env[key] = value
encoding: "utf-8", }
}) }
return { return {
status: execution.status, status: 0,
stdout: execution.stdout, stdout: JSON.stringify(findPluginEntry(directory)),
stderr: execution.stderr, stderr: "",
}
} finally {
for (const [key, value] of originalValues) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
} }
} }
+16 -20
View File
@@ -74,20 +74,18 @@ while :; do
: :
done done
`) `)
const originalSetTimeout = globalThis.setTimeout const immediateSetTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => {
globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => {
fn() fn()
return unsafeTestValue<ReturnType<typeof setTimeout>>(0) return unsafeTestValue<ReturnType<typeof setTimeout>>(0)
}) as typeof setTimeout }) as typeof setTimeout
try { // when
// when const result = await runCommentChecker(createMockInput(), binaryPath, undefined, {
const result = await runCommentChecker(createMockInput(), binaryPath) setTimeoutFn: immediateSetTimeout,
// then })
expect(result).toEqual({ hasComments: false, message: "" })
} finally { // then
globalThis.setTimeout = originalSetTimeout expect(result).toEqual({ hasComments: false, message: "" })
}
}) })
test("returns empty result on timeout", async () => { test("returns empty result on timeout", async () => {
@@ -102,20 +100,18 @@ while :; do
: :
done done
`) `)
const originalSetTimeout = globalThis.setTimeout const immediateSetTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => {
globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => {
fn() fn()
return unsafeTestValue<ReturnType<typeof setTimeout>>(0) return unsafeTestValue<ReturnType<typeof setTimeout>>(0)
}) as typeof setTimeout }) as typeof setTimeout
try { // when
// when const result = await runCommentChecker(createMockInput(), binaryPath, undefined, {
const result = await runCommentChecker(createMockInput(), binaryPath) setTimeoutFn: immediateSetTimeout,
// then })
expect(result).toEqual({ hasComments: false, message: "" })
} finally { // then
globalThis.setTimeout = originalSetTimeout expect(result).toEqual({ hasComments: false, message: "" })
}
}) })
test("keeps non-timeout flow unchanged", async () => { test("keeps non-timeout flow unchanged", async () => {
+15 -1
View File
@@ -44,6 +44,13 @@ function findCommentCheckerPathSync(): string | null {
// Cached resolved path // Cached resolved path
let resolvedCliPath: string | null = null let resolvedCliPath: string | null = null
let initPromise: Promise<string | null> | null = null let initPromise: Promise<string | null> | null = null
const stableSetTimeout = globalThis.setTimeout
const stableClearTimeout = globalThis.clearTimeout
type TimerOverrides = {
setTimeoutFn?: typeof setTimeout
clearTimeoutFn?: typeof clearTimeout
}
/** /**
* Asynchronously get comment-checker binary path. * Asynchronously get comment-checker binary path.
@@ -116,7 +123,12 @@ export type { HookInput, CheckResult }
* @param cliPath Optional explicit path to CLI binary * @param cliPath Optional explicit path to CLI binary
* @param customPrompt Optional custom prompt to replace default warning message * @param customPrompt Optional custom prompt to replace default warning message
*/ */
export async function runCommentChecker(input: HookInput, cliPath?: string, customPrompt?: string): Promise<CheckResult> { export async function runCommentChecker(
input: HookInput,
cliPath?: string,
customPrompt?: string,
timerOverrides: TimerOverrides = {},
): Promise<CheckResult> {
const binaryPath = cliPath ?? resolvedCliPath ?? getCommentCheckerPathSync() const binaryPath = cliPath ?? resolvedCliPath ?? getCommentCheckerPathSync()
if (!binaryPath) { if (!binaryPath) {
@@ -135,6 +147,8 @@ export async function runCommentChecker(input: HookInput, cliPath?: string, cust
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
}), }),
setTimeoutFn: timerOverrides.setTimeoutFn ?? stableSetTimeout,
clearTimeoutFn: timerOverrides.clearTimeoutFn ?? stableClearTimeout,
}, },
) )
return result return result
+8 -16
View File
@@ -1,6 +1,9 @@
/// <reference path="../../../bun-test.d.ts" /> /// <reference path="../../../bun-test.d.ts" />
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import * as tmuxRunner from "../../shared/tmux/runner"
import * as tmuxPathResolver from "../../tools/interactive-bash/tmux-path-resolver"
type MockTmuxCommandResult = { type MockTmuxCommandResult = {
success: boolean success: boolean
@@ -21,28 +24,17 @@ const runTmuxCommandMock = mock(
) )
const getTmuxPathMock = mock(async (): Promise<string | null> => "/mock/tmux") const getTmuxPathMock = mock(async (): Promise<string | null> => "/mock/tmux")
const tmuxModule = await import("../tmux")
let tmuxModule: typeof import("../tmux")
beforeAll(async () => {
mock.module("../../shared/tmux/runner", () => ({
runTmuxCommand: runTmuxCommandMock,
}))
mock.module("../../tools/interactive-bash/tmux-path-resolver", () => ({
getTmuxPath: getTmuxPathMock,
}))
tmuxModule = await import("../tmux")
})
beforeEach(() => { beforeEach(() => {
runTmuxCommandMock.mockReset() runTmuxCommandMock.mockReset()
getTmuxPathMock.mockReset() getTmuxPathMock.mockReset()
getTmuxPathMock.mockResolvedValue("/mock/tmux") getTmuxPathMock.mockResolvedValue("/mock/tmux")
spyOn(tmuxRunner, "runTmuxCommand").mockImplementation(runTmuxCommandMock)
spyOn(tmuxPathResolver, "getTmuxPath").mockImplementation(getTmuxPathMock)
}) })
afterAll(() => { afterEach(() => {
mock.restore() mock.restore()
}) })
+4 -4
View File
@@ -1,13 +1,13 @@
import { runTmuxCommand } from "../shared/tmux/runner" import * as tmuxRunner from "../shared/tmux/runner"
import { getTmuxPath } from "../tools/interactive-bash/tmux-path-resolver" import * as tmuxPathResolver from "../tools/interactive-bash/tmux-path-resolver"
async function runOpenClawTmuxCommand(args: string[]) { async function runOpenClawTmuxCommand(args: string[]) {
const tmuxPath = await getTmuxPath() const tmuxPath = await tmuxPathResolver.getTmuxPath()
if (!tmuxPath) { if (!tmuxPath) {
return null return null
} }
return runTmuxCommand(tmuxPath, args) return tmuxRunner.runTmuxCommand(tmuxPath, args)
} }
export function getCurrentTmuxSession(): string | null { export function getCurrentTmuxSession(): string | null {