openclaw / src /hooks /gmail-watcher.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
d197cf3 verified
Raw History Blame Contribute Delete
24.2 kB
// Gmail watcher tests cover watcher events and Gmail hook message flow.
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../test/helpers/promise.js";
import { withMockedPlatform } from "../test-utils/vitest-spies.js";
import { isAddressInUseError } from "./gmail-watcher-errors.js";
describe("gmail watcher errors", () => {
it("detects address already in use errors", () => {
expect(isAddressInUseError("listen tcp 127.0.0.1:8788: bind: address already in use")).toBe(
true,
);
expect(isAddressInUseError("EADDRINUSE: address already in use")).toBe(true);
expect(isAddressInUseError("some other error")).toBe(false);
});
});
// Tracks spawned children by pid so the killProcessTree mock can emit close on them.
const spawnRegistry = new Map<number, EventEmitter>();
const mocks = vi.hoisted(() => ({
hasBinary: vi.fn(() => true),
resolveExecutable: vi.fn((name: string) => name),
runCommandWithTimeout: vi.fn(),
spawn: vi.fn(),
killProcessTree: vi.fn((pid: number) => {
const child = spawnRegistry.get(pid);
if (child) {
queueMicrotask(() => child.emit("close", 0, null));
}
}),
}));
vi.mock("node:child_process", async () => {
const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:child_process")>("node:child_process"),
{ spawn: mocks.spawn },
);
});
vi.mock("../skills/loading/config.js", () => ({
hasBinary: mocks.hasBinary,
}));
vi.mock("../infra/executable-path.js", () => ({
resolveExecutable: mocks.resolveExecutable,
}));
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: mocks.runCommandWithTimeout,
}));
vi.mock("../process/kill-tree.js", () => ({
killProcessTree: mocks.killProcessTree,
}));
const { startGmailWatcher, stopGmailWatcher } = await import("./gmail-watcher.js");
function createGmailConfig(account = "me@example.com", renewEveryMinutes?: number) {
return {
hooks: {
enabled: true,
token: "hook-token",
gmail: {
account,
topic: "projects/demo/topics/gmail",
pushToken: "push-token",
renewEveryMinutes,
},
},
} as never;
}
function deferredCommandResult() {
return createDeferred<{ code: number; stdout: string; stderr: string }>();
}
type MockWatcherChild = EventEmitter & {
kill: ReturnType<typeof vi.fn>;
pid?: number;
stdout: PassThrough;
stderr: PassThrough;
};
let nextMockPid = 1234;
function createMockWatcherChild(spawned = true): MockWatcherChild {
const child = new EventEmitter();
const pid = spawned ? nextMockPid++ : undefined;
const mockedChild = Object.assign(child, {
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: vi.fn(() => {
queueMicrotask(() => {
child.emit("exit", null, "SIGTERM");
child.emit("close", null, "SIGTERM");
});
return true;
}),
...(pid !== undefined ? { pid } : {}),
});
if (pid !== undefined) {
spawnRegistry.set(pid, mockedChild);
}
return mockedChild;
}
async function startMockWatcher(spawned = true): Promise<MockWatcherChild[]> {
mocks.runCommandWithTimeout.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
const children: MockWatcherChild[] = [];
mocks.spawn.mockImplementation(() => {
const child = createMockWatcherChild(spawned);
children.push(child);
return child;
});
await startGmailWatcher(createGmailConfig());
return children;
}
describe("startGmailWatcher", () => {
beforeEach(async () => {
// stopGmailWatcher uses the killProcessTree mock from the previous beforeEach run,
// which looks up spawnRegistry entries populated by that test's children.
await stopGmailWatcher();
spawnRegistry.clear();
mocks.hasBinary.mockReturnValue(true);
mocks.resolveExecutable.mockImplementation((name: string) => name);
mocks.runCommandWithTimeout.mockReset();
mocks.spawn.mockReset();
mocks.killProcessTree.mockReset();
mocks.killProcessTree.mockImplementation((pid: number) => {
const child = spawnRegistry.get(pid);
if (child) {
queueMicrotask(() => child.emit("close", 0, null));
}
});
mocks.spawn.mockImplementation(() => createMockWatcherChild(false));
});
afterEach(async () => {
vi.useRealTimers();
await stopGmailWatcher();
});
it("does not let a stale cancelled startup clear newer watcher config", async () => {
vi.useFakeTimers();
try {
const oldController = new AbortController();
const oldWatchStart = deferredCommandResult();
const spawnedChildren: MockWatcherChild[] = [];
mocks.runCommandWithTimeout
.mockImplementationOnce(async () => await oldWatchStart.promise)
.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
mocks.spawn.mockImplementation(() => {
const child = createMockWatcherChild(false);
spawnedChildren.push(child);
return child;
});
const staleStart = startGmailWatcher(createGmailConfig(), {
signal: oldController.signal,
});
expect(mocks.runCommandWithTimeout).toHaveBeenCalledTimes(1);
await expect(startGmailWatcher(createGmailConfig("newer@example.com"))).resolves.toEqual({
started: true,
});
expect(mocks.spawn).toHaveBeenCalledTimes(1);
oldController.abort();
oldWatchStart.resolve({ code: 0, stdout: "", stderr: "" });
await expect(staleStart).resolves.toEqual({
started: false,
reason: "startup cancelled",
});
spawnedChildren[0]?.emit("close", 1, null);
await vi.advanceTimersByTimeAsync(5000);
expect(mocks.spawn).toHaveBeenCalledTimes(2);
expect(mocks.spawn.mock.calls[1]?.[1]).toContain("newer@example.com");
} finally {
vi.useRealTimers();
}
});
it("aborts watch start and does not spawn gog serve when cancelled in flight", async () => {
let watchStartSignal: AbortSignal | undefined;
const controller = new AbortController();
mocks.runCommandWithTimeout.mockImplementation(
async (_args, options: { signal?: AbortSignal }) =>
await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
watchStartSignal = options.signal;
options.signal?.addEventListener(
"abort",
() => resolve({ code: 1, stdout: "", stderr: "aborted" }),
{ once: true },
);
}),
);
const startPromise = startGmailWatcher(createGmailConfig(), {
signal: controller.signal,
});
await Promise.resolve();
expect(watchStartSignal).toBe(controller.signal);
controller.abort();
expect(watchStartSignal?.aborted).toBe(true);
await expect(startPromise).resolves.toEqual({
started: false,
reason: "startup cancelled",
});
expect(mocks.spawn).not.toHaveBeenCalled();
});
it("aborts tailscale setup and does not spawn gog serve when cancelled in flight", async () => {
const controller = new AbortController();
let tailscaleSignal: AbortSignal | undefined;
mocks.runCommandWithTimeout.mockImplementation(
async (_args, options: { signal?: AbortSignal }) =>
await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve) => {
tailscaleSignal = options.signal;
options.signal?.addEventListener(
"abort",
() => resolve({ code: null, stdout: "", stderr: "aborted" }),
{ once: true },
);
}),
);
const startPromise = startGmailWatcher(
{
hooks: {
enabled: true,
token: "hook-token",
gmail: {
account: "me@example.com",
topic: "projects/demo/topics/gmail",
pushToken: "push-token",
tailscale: { mode: "serve" },
},
},
} as never,
{
signal: controller.signal,
},
);
await vi.waitFor(() => {
expect(tailscaleSignal).toBeDefined();
});
controller.abort();
expect(tailscaleSignal).toBe(controller.signal);
expect(tailscaleSignal?.aborted).toBe(true);
await expect(startPromise).resolves.toEqual({
started: false,
reason: "startup cancelled",
});
expect(mocks.spawn).not.toHaveBeenCalled();
});
it("kills existing watcher process on re-entry before spawning new one", async () => {
mocks.runCommandWithTimeout.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
const spawnedChildren: MockWatcherChild[] = [];
mocks.spawn.mockImplementation(() => {
const child = createMockWatcherChild(false);
spawnedChildren.push(child);
return child;
});
// First start
await startGmailWatcher(createGmailConfig());
expect(spawnedChildren).toHaveLength(1);
expect(
expectDefined(spawnedChildren[0], "spawnedChildren[0] test invariant").kill,
).not.toHaveBeenCalled();
// Second start (re-entry) should kill the first process
await startGmailWatcher(createGmailConfig());
expect(spawnedChildren).toHaveLength(2);
expect(
expectDefined(spawnedChildren[0], "spawnedChildren[0] test invariant").kill,
).toHaveBeenCalledWith("SIGTERM");
});
it("clears existing renewInterval on re-entry to prevent interval leak", async () => {
vi.useFakeTimers();
try {
mocks.runCommandWithTimeout.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
// First start - creates a renewal interval
await startGmailWatcher(createGmailConfig());
const timersAfterFirstStart = vi.getTimerCount();
expect(timersAfterFirstStart).toBeGreaterThanOrEqual(1);
// Second start (re-entry without stop) - the guard should clear the old
// interval before creating a new one, keeping the timer count stable.
await startGmailWatcher(createGmailConfig());
expect(vi.getTimerCount()).toBe(timersAfterFirstStart);
} finally {
vi.useRealTimers();
}
});
it("only one renewal fires per tick after multiple starts", async () => {
vi.useFakeTimers();
try {
// Resolve watch-start immediately on every call
mocks.runCommandWithTimeout.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
// Start twice without stopping
await startGmailWatcher(createGmailConfig());
await startGmailWatcher(createGmailConfig());
// runCommandWithTimeout is called once per start (the gog watch start
// call). After two successful starts it has been called twice.
expect(mocks.runCommandWithTimeout).toHaveBeenCalledTimes(2);
// Advance by one full renewal cycle.
// Default renewEveryMinutes = 720 (12 h) = 43_200_000 ms.
// If the old interval leaked, the callback would fire twice per cycle.
await vi.advanceTimersByTimeAsync(720 * 60_000);
// Only ONE renewal should have fired (the latest interval).
expect(mocks.runCommandWithTimeout).toHaveBeenCalledTimes(3);
} finally {
vi.useRealTimers();
}
});
it("keeps a stalled periodic renewal single-flight", async () => {
vi.useFakeTimers();
try {
const renewal = deferredCommandResult();
mocks.runCommandWithTimeout
.mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" })
.mockImplementation(async () => await renewal.promise);
await startGmailWatcher(createGmailConfig("me@example.com", 1));
await vi.advanceTimersByTimeAsync(60_000);
expect(mocks.runCommandWithTimeout).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(60_000);
const callsWhileStalled = mocks.runCommandWithTimeout.mock.calls.length;
renewal.resolve({ code: 0, stdout: "", stderr: "" });
await Promise.resolve();
expect(callsWhileStalled).toBe(2);
} finally {
vi.useRealTimers();
}
});
it("does not let a stalled renewal survive stop and suppress a replacement watcher", async () => {
vi.useFakeTimers();
try {
let stalledSignal: AbortSignal | undefined;
mocks.runCommandWithTimeout
.mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" })
.mockImplementationOnce(
async (_args, options: { signal?: AbortSignal }) =>
await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
stalledSignal = options.signal;
options.signal?.addEventListener(
"abort",
() => resolve({ code: 1, stdout: "", stderr: "aborted" }),
{ once: true },
);
}),
)
.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
await startGmailWatcher(createGmailConfig("old@example.com", 1));
await vi.advanceTimersByTimeAsync(60_000);
expect(stalledSignal?.aborted).toBe(false);
await stopGmailWatcher();
expect(stalledSignal?.aborted).toBe(true);
await startGmailWatcher(createGmailConfig("new@example.com", 1));
await vi.advanceTimersByTimeAsync(60_000);
expect(mocks.runCommandWithTimeout).toHaveBeenCalledTimes(4);
expect(mocks.runCommandWithTimeout.mock.calls[3]?.[0]).toContain("new@example.com");
} finally {
vi.useRealTimers();
}
});
it("uses killProcessTree for gog shutdown and resolves on final timeout when process ignores signals", async () => {
vi.useFakeTimers();
try {
mocks.runCommandWithTimeout.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
// Spawn a process with a known pid that never emits exit/close/error
const stubbornChild = new EventEmitter();
Object.assign(stubbornChild, {
pid: 9999,
kill: vi.fn(() => true),
killed: false,
});
mocks.spawn.mockReturnValueOnce(stubbornChild);
await startGmailWatcher(createGmailConfig());
expect(mocks.spawn).toHaveBeenCalledTimes(1);
// Now spawn a normal child for the second start so re-entry triggers settle
mocks.spawn.mockImplementation(() => createMockWatcherChild(false));
// Re-entry starts settle on stubbornChild; advance past the 8 s final
// timeout (stubbornChild never emits exit), then verify the outcome.
const startPromise = startGmailWatcher(createGmailConfig());
await vi.advanceTimersByTimeAsync(8_000);
await expect(startPromise).resolves.toEqual({ started: true });
// killProcessTree must have been called with stubbornChild's pid before settle gave up.
expect(mocks.killProcessTree).toHaveBeenCalledWith(
9999,
expect.objectContaining({ graceMs: 3_000 }),
);
} finally {
vi.useRealTimers();
}
});
it("cancels stale respawn timeout when re-entry happens during 5s window", async () => {
vi.useFakeTimers();
try {
mocks.runCommandWithTimeout.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
const spawnedChildren: MockWatcherChild[] = [];
mocks.spawn.mockImplementation(() => {
const child = createMockWatcherChild(false);
spawnedChildren.push(child);
return child;
});
// First start
await startGmailWatcher(createGmailConfig());
expect(spawnedChildren).toHaveLength(1);
// Process crashes (exit code 1). This queues a 5s respawn timeout.
expectDefined(spawnedChildren[0], "spawnedChildren[0] test invariant").emit("close", 1, null);
// Before the 5s timer fires, a config reload triggers re-entry.
// The re-entry guard should cancel the stale respawn timeout.
await startGmailWatcher(createGmailConfig());
expect(spawnedChildren).toHaveLength(2);
// Advance past the 5s respawn window. If the stale timeout was NOT
// cancelled, it would spawn a 3rd process (duplicate).
await vi.advanceTimersByTimeAsync(6000);
expect(spawnedChildren).toHaveLength(2); // No duplicate spawned
} finally {
vi.useRealTimers();
}
});
it("ignores a retired child's late close while replacement startup is pending", async () => {
vi.useFakeTimers();
const pendingStart = deferredCommandResult();
const children = await startMockWatcher();
mocks.killProcessTree.mockImplementation(() => {});
mocks.runCommandWithTimeout.mockImplementationOnce(() => pendingStart.promise);
const restarting = startGmailWatcher(createGmailConfig("new@example.com"));
try {
await vi.advanceTimersByTimeAsync(8_000);
expect(mocks.runCommandWithTimeout).toHaveBeenCalledTimes(2);
const retired = expectDefined(children[0], "retired watcher");
retired.emit("exit", 1, null);
retired.emit("close", 1, null);
await vi.advanceTimersByTimeAsync(6_000);
expect(children).toHaveLength(1);
expect(mocks.killProcessTree).toHaveBeenCalledTimes(1);
pendingStart.resolve({ code: 0, stdout: "", stderr: "" });
await restarting;
await vi.advanceTimersByTimeAsync(6_000);
expect(children).toHaveLength(2);
expect(mocks.spawn.mock.calls[1]?.[1]).toContain("new@example.com");
} finally {
pendingStart.resolve({ code: 0, stdout: "", stderr: "" });
await restarting;
const stopping = stopGmailWatcher();
await vi.advanceTimersByTimeAsync(8_000);
await stopping;
for (const child of children) {
child.stdout.destroy();
child.stderr.destroy();
}
vi.useRealTimers();
}
});
it("preserves the shutdown grace period when the watcher exits before its descendants", async () => {
vi.useFakeTimers();
mocks.runCommandWithTimeout.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
const child = createMockWatcherChild();
mocks.spawn.mockReturnValueOnce(child);
mocks.killProcessTree.mockImplementation(() => {
queueMicrotask(() => {
child.emit("exit", 0, null);
child.emit("close", 0, null);
});
});
await startGmailWatcher(createGmailConfig());
expect(mocks.spawn).toHaveBeenCalledTimes(1);
let stopped = false;
const stopping = stopGmailWatcher().then(() => {
stopped = true;
});
await vi.advanceTimersByTimeAsync(3_000);
expect(stopped).toBe(false);
await vi.advanceTimersByTimeAsync(25);
await stopping;
expect(mocks.killProcessTree).toHaveBeenCalledTimes(1);
expect(mocks.killProcessTree).toHaveBeenCalledWith(
child.pid,
expect.objectContaining({ graceMs: 3_000 }),
);
// proc.kill should not be called — tree termination replaces the direct kill.
expect(child.kill).not.toHaveBeenCalled();
});
it.each(["darwin", "win32"] as const)(
"restarts initial and replacement children with inherited pipes on %s",
async (platform) => {
vi.useFakeTimers();
await withMockedPlatform(platform, async () => {
const children = await startMockWatcher();
// Descendants keep both pipes open after exit, even if tree cleanup fails.
mocks.killProcessTree.mockImplementation(() => {});
try {
for (let index = 0; index < 2; index++) {
const child = expectDefined(children[index], "watcher child");
let closedStreams = 0;
for (const stream of [child.stdout, child.stderr]) {
stream.once("close", () => {
if (++closedStreams === 2) {
child.emit("close", null, "SIGKILL");
}
});
}
child.emit("exit", null, "SIGKILL");
await vi.advanceTimersByTimeAsync(6_100);
expect(children).toHaveLength(index + 2);
expect(child.stdout.destroyed).toBe(true);
expect(child.stderr.destroyed).toBe(true);
if (platform === "win32") {
expect(mocks.killProcessTree).not.toHaveBeenCalled();
} else {
expect(mocks.killProcessTree).toHaveBeenCalledTimes(index + 1);
expect(mocks.killProcessTree).toHaveBeenLastCalledWith(child.pid, {
force: true,
detached: true,
});
}
}
await vi.advanceTimersByTimeAsync(6_000);
expect(children).toHaveLength(3);
} finally {
const stopping = stopGmailWatcher();
await vi.advanceTimersByTimeAsync(8_000);
await stopping;
for (const child of children) {
child.stdout.destroy();
child.stderr.destroy();
}
vi.useRealTimers();
}
});
},
);
it("does not taskkill an exited Windows child while inherited pipes drain", async () => {
vi.useFakeTimers();
await withMockedPlatform("win32", async () => {
const children = await startMockWatcher();
const child = expectDefined(children[0], "watcher child");
Object.assign(child, { exitCode: 1, signalCode: null });
child.emit("exit", 1, null);
const stopping = stopGmailWatcher();
try {
await vi.advanceTimersByTimeAsync(8_000);
await stopping;
expect(mocks.killProcessTree).not.toHaveBeenCalled();
expect(child.stdout.destroyed).toBe(true);
expect(child.stderr.destroyed).toBe(true);
expect(children).toHaveLength(1);
} finally {
child.stdout.destroy();
child.stderr.destroy();
vi.useRealTimers();
}
});
});
it("swallows stdout and stderr stream errors without crashing", async () => {
mocks.runCommandWithTimeout.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
mocks.spawn.mockImplementation(() => {
const child = createMockWatcherChild(false);
queueMicrotask(() => {
child.stdout.emit("error", new Error("stdout read failed"));
child.stderr.emit("error", new Error("stderr read failed"));
});
return child;
});
await expect(startGmailWatcher(createGmailConfig())).resolves.toEqual({ started: true });
});
it.each([
{ name: "failed spawn", spawned: false, expectedChildren: 1 },
{ name: "error from a running child", spawned: true, expectedChildren: 2 },
])("handles $name without losing restart policy", async ({ spawned, expectedChildren }) => {
vi.useFakeTimers();
try {
const children = await startMockWatcher(spawned);
const child = expectDefined(children[0], "watcher child");
child.emit("error", new Error(spawned ? "gog stream error" : "spawn gog ENOENT"));
child.emit("close", spawned ? 1 : -2, null);
await vi.advanceTimersByTimeAsync(6000);
expect(children).toHaveLength(expectedChildren);
} finally {
vi.useRealTimers();
}
});
it.each([
{
name: "split address-in-use marker",
chunks: ["address alre", "ady in use\n"],
expectedChildren: 1,
},
{
name: "final bind fragment after exit",
chunks: ["address alre", "ady in use\n"],
exitAfterChunk: 0,
expectedChildren: 1,
},
{
name: "marker completed before tail truncation",
chunks: ["address alre", `ady in use ${"x".repeat(800)}`],
expectedChildren: 1,
},
{
name: "non-bind stderr",
chunks: ["some erro", "r message\n"],
expectedChildren: 2,
},
])("classifies $name", async ({ chunks, exitAfterChunk, expectedChildren }) => {
vi.useFakeTimers();
try {
const children = await startMockWatcher();
const child = expectDefined(children[0], "watcher child");
for (const [index, chunk] of chunks.entries()) {
child.stderr.emit("data", Buffer.from(chunk));
if (exitAfterChunk === index) {
child.emit("exit", 1, null);
}
}
child.emit("close", 1, null);
await vi.advanceTimersByTimeAsync(6000);
expect(children).toHaveLength(expectedChildren);
} finally {
vi.useRealTimers();
}
});
});