* Tests: harden flake hotspots and consolidate provider-auth suites * Tests: restore env vars by deleting missing snapshot values * Tests: use real newline in memory summary filter case * Tests(memory): use fake timers for qmd timeout coverage * Changelog: add tests hardening entry for #11598
562 lines
18 KiB
TypeScript
562 lines
18 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
type MockChild = EventEmitter & {
|
|
stdout: EventEmitter;
|
|
stderr: EventEmitter;
|
|
kill: (signal?: NodeJS.Signals) => void;
|
|
closeWith: (code?: number | null) => void;
|
|
};
|
|
|
|
function createMockChild(params?: { autoClose?: boolean; closeDelayMs?: number }): MockChild {
|
|
const stdout = new EventEmitter();
|
|
const stderr = new EventEmitter();
|
|
const child = new EventEmitter() as MockChild;
|
|
child.stdout = stdout;
|
|
child.stderr = stderr;
|
|
child.closeWith = (code = 0) => {
|
|
child.emit("close", code);
|
|
};
|
|
child.kill = () => {
|
|
// Let timeout rejection win in tests that simulate hung QMD commands.
|
|
};
|
|
if (params?.autoClose !== false) {
|
|
const delayMs = params?.closeDelayMs ?? 0;
|
|
if (delayMs <= 0) {
|
|
queueMicrotask(() => {
|
|
child.emit("close", 0);
|
|
});
|
|
} else {
|
|
setTimeout(() => {
|
|
child.emit("close", 0);
|
|
}, delayMs);
|
|
}
|
|
}
|
|
return child;
|
|
}
|
|
|
|
vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
|
|
|
|
import { spawn as mockedSpawn } from "node:child_process";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { resolveMemoryBackendConfig } from "./backend-config.js";
|
|
import { QmdMemoryManager } from "./qmd-manager.js";
|
|
|
|
const spawnMock = mockedSpawn as unknown as vi.Mock;
|
|
|
|
describe("QmdMemoryManager", () => {
|
|
let tmpRoot: string;
|
|
let workspaceDir: string;
|
|
let stateDir: string;
|
|
let cfg: OpenClawConfig;
|
|
const agentId = "main";
|
|
|
|
beforeEach(async () => {
|
|
spawnMock.mockReset();
|
|
spawnMock.mockImplementation(() => createMockChild());
|
|
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-manager-test-"));
|
|
workspaceDir = path.join(tmpRoot, "workspace");
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
stateDir = path.join(tmpRoot, "state");
|
|
await fs.mkdir(stateDir, { recursive: true });
|
|
process.env.OPENCLAW_STATE_DIR = stateDir;
|
|
cfg = {
|
|
agents: {
|
|
list: [{ id: agentId, default: true, workspace: workspaceDir }],
|
|
},
|
|
memory: {
|
|
backend: "qmd",
|
|
qmd: {
|
|
includeDefaultMemory: false,
|
|
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
|
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.useRealTimers();
|
|
delete process.env.OPENCLAW_STATE_DIR;
|
|
await fs.rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("debounces back-to-back sync calls", async () => {
|
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
|
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
|
|
expect(manager).toBeTruthy();
|
|
if (!manager) {
|
|
throw new Error("manager missing");
|
|
}
|
|
|
|
const baselineCalls = spawnMock.mock.calls.length;
|
|
|
|
await manager.sync({ reason: "manual" });
|
|
expect(spawnMock.mock.calls.length).toBe(baselineCalls + 2);
|
|
|
|
await manager.sync({ reason: "manual-again" });
|
|
expect(spawnMock.mock.calls.length).toBe(baselineCalls + 2);
|
|
|
|
(manager as unknown as { lastUpdateAt: number | null }).lastUpdateAt =
|
|
Date.now() - (resolved.qmd?.update.debounceMs ?? 0) - 10;
|
|
|
|
await manager.sync({ reason: "after-wait" });
|
|
// By default we refresh embeddings less frequently than index updates.
|
|
expect(spawnMock.mock.calls.length).toBe(baselineCalls + 3);
|
|
|
|
await manager.close();
|
|
});
|
|
|
|
it("runs boot update in background by default", async () => {
|
|
cfg = {
|
|
...cfg,
|
|
memory: {
|
|
backend: "qmd",
|
|
qmd: {
|
|
includeDefaultMemory: false,
|
|
update: { interval: "0s", debounceMs: 60_000, onBoot: true },
|
|
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
let releaseUpdate: (() => void) | null = null;
|
|
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
|
if (args[0] === "update") {
|
|
const child = createMockChild({ autoClose: false });
|
|
releaseUpdate = () => child.closeWith(0);
|
|
return child;
|
|
}
|
|
return createMockChild();
|
|
});
|
|
|
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
|
const createPromise = QmdMemoryManager.create({ cfg, agentId, resolved });
|
|
const race = await Promise.race([
|
|
createPromise.then(() => "created" as const),
|
|
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 80)),
|
|
]);
|
|
expect(race).toBe("created");
|
|
|
|
if (!releaseUpdate) {
|
|
throw new Error("update child missing");
|
|
}
|
|
releaseUpdate();
|
|
const manager = await createPromise;
|
|
await manager?.close();
|
|
});
|
|
|
|
it("can be configured to block startup on boot update", async () => {
|
|
cfg = {
|
|
...cfg,
|
|
memory: {
|
|
backend: "qmd",
|
|
qmd: {
|
|
includeDefaultMemory: false,
|
|
update: {
|
|
interval: "0s",
|
|
debounceMs: 60_000,
|
|
onBoot: true,
|
|
waitForBootSync: true,
|
|
},
|
|
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
let releaseUpdate: (() => void) | null = null;
|
|
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
|
if (args[0] === "update") {
|
|
const child = createMockChild({ autoClose: false });
|
|
releaseUpdate = () => child.closeWith(0);
|
|
return child;
|
|
}
|
|
return createMockChild();
|
|
});
|
|
|
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
|
const createPromise = QmdMemoryManager.create({ cfg, agentId, resolved });
|
|
const race = await Promise.race([
|
|
createPromise.then(() => "created" as const),
|
|
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 80)),
|
|
]);
|
|
expect(race).toBe("timeout");
|
|
|
|
if (!releaseUpdate) {
|
|
throw new Error("update child missing");
|
|
}
|
|
releaseUpdate();
|
|
const manager = await createPromise;
|
|
await manager?.close();
|
|
});
|
|
|
|
it("times out collection bootstrap commands", async () => {
|
|
cfg = {
|
|
...cfg,
|
|
memory: {
|
|
backend: "qmd",
|
|
qmd: {
|
|
includeDefaultMemory: false,
|
|
update: {
|
|
interval: "0s",
|
|
debounceMs: 60_000,
|
|
onBoot: false,
|
|
commandTimeoutMs: 15,
|
|
},
|
|
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
|
if (args[0] === "collection" && args[1] === "list") {
|
|
return createMockChild({ autoClose: false });
|
|
}
|
|
return createMockChild();
|
|
});
|
|
|
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
|
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
|
|
expect(manager).toBeTruthy();
|
|
await manager?.close();
|
|
});
|
|
|
|
it("times out qmd update during sync when configured", async () => {
|
|
vi.useFakeTimers();
|
|
cfg = {
|
|
...cfg,
|
|
memory: {
|
|
backend: "qmd",
|
|
qmd: {
|
|
includeDefaultMemory: false,
|
|
update: {
|
|
interval: "0s",
|
|
debounceMs: 0,
|
|
onBoot: false,
|
|
updateTimeoutMs: 20,
|
|
},
|
|
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
|
if (args[0] === "update") {
|
|
return createMockChild({ autoClose: false });
|
|
}
|
|
return createMockChild();
|
|
});
|
|
|
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
|
const createPromise = QmdMemoryManager.create({ cfg, agentId, resolved });
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
const manager = await createPromise;
|
|
expect(manager).toBeTruthy();
|
|
if (!manager) {
|
|
throw new Error("manager missing");
|
|
}
|
|
const syncPromise = manager.sync({ reason: "manual" });
|
|
const rejected = expect(syncPromise).rejects.toThrow("qmd update timed out after 20ms");
|
|
await vi.advanceTimersByTimeAsync(20);
|
|
await rejected;
|
|
await manager.close();
|
|
});
|
|
|
|
it("queues a forced sync behind an in-flight update", async () => {
|
|
cfg = {
|
|
...cfg,
|
|
memory: {
|
|
backend: "qmd",
|
|
qmd: {
|
|
includeDefaultMemory: false,
|
|
update: {
|
|
interval: "0s",
|
|
debounceMs: 0,
|
|
onBoot: false,
|
|
updateTimeoutMs: 1_000,
|
|
},
|
|
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
let updateCalls = 0;
|
|
let releaseFirstUpdate: (() => void) | null = null;
|
|
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
|
if (args[0] === "update") {
|
|
updateCalls += 1;
|
|
if (updateCalls === 1) {
|
|
const first = createMockChild({ autoClose: false });
|
|
releaseFirstUpdate = () => first.closeWith(0);
|
|
return first;
|
|
}
|
|
return createMockChild();
|
|
}
|
|
return createMockChild();
|
|
});
|
|
|
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
|
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
|
|
expect(manager).toBeTruthy();
|
|
if (!manager) {
|
|
throw new Error("manager missing");
|
|
}
|
|
|
|
const inFlight = manager.sync({ reason: "interval" });
|
|
const forced = manager.sync({ reason: "manual", force: true });
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
expect(updateCalls).toBe(1);
|
|
if (!releaseFirstUpdate) {
|
|
throw new Error("first update release missing");
|
|
}
|
|
releaseFirstUpdate();
|
|
|
|
await Promise.all([inFlight, forced]);
|
|
expect(updateCalls).toBe(2);
|
|
await manager.close();
|
|
});
|
|
|
|
it("honors multiple forced sync requests while forced queue is active", async () => {
|
|
cfg = {
|
|
...cfg,
|
|
memory: {
|
|
backend: "qmd",
|
|
qmd: {
|
|
includeDefaultMemory: false,
|
|
update: {
|
|
interval: "0s",
|
|
debounceMs: 0,
|
|
onBoot: false,
|
|
updateTimeoutMs: 1_000,
|
|
},
|
|
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
let updateCalls = 0;
|
|
let releaseFirstUpdate: (() => void) | null = null;
|
|
let releaseSecondUpdate: (() => void) | null = null;
|
|
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
|
if (args[0] === "update") {
|
|
updateCalls += 1;
|
|
if (updateCalls === 1) {
|
|
const first = createMockChild({ autoClose: false });
|
|
releaseFirstUpdate = () => first.closeWith(0);
|
|
return first;
|
|
}
|
|
if (updateCalls === 2) {
|
|
const second = createMockChild({ autoClose: false });
|
|
releaseSecondUpdate = () => second.closeWith(0);
|
|
return second;
|
|
}
|
|
return createMockChild();
|
|
}
|
|
return createMockChild();
|
|
});
|
|
|
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
|
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
|
|
expect(manager).toBeTruthy();
|
|
if (!manager) {
|
|
throw new Error("manager missing");
|
|
}
|
|
|
|
const inFlight = manager.sync({ reason: "interval" });
|
|
const forcedOne = manager.sync({ reason: "manual", force: true });
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
expect(updateCalls).toBe(1);
|
|
if (!releaseFirstUpdate) {
|
|
throw new Error("first update release missing");
|
|
}
|
|
releaseFirstUpdate();
|
|
|
|
await waitForCondition(() => updateCalls >= 2, 200);
|
|
const forcedTwo = manager.sync({ reason: "manual-again", force: true });
|
|
|
|
if (!releaseSecondUpdate) {
|
|
throw new Error("second update release missing");
|
|
}
|
|
releaseSecondUpdate();
|
|
|
|
await Promise.all([inFlight, forcedOne, forcedTwo]);
|
|
expect(updateCalls).toBe(3);
|
|
await manager.close();
|
|
});
|
|
|
|
it("logs and continues when qmd embed times out", async () => {
|
|
vi.useFakeTimers();
|
|
cfg = {
|
|
...cfg,
|
|
memory: {
|
|
backend: "qmd",
|
|
qmd: {
|
|
includeDefaultMemory: false,
|
|
update: {
|
|
interval: "0s",
|
|
debounceMs: 0,
|
|
onBoot: false,
|
|
embedTimeoutMs: 20,
|
|
},
|
|
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
|
if (args[0] === "embed") {
|
|
return createMockChild({ autoClose: false });
|
|
}
|
|
return createMockChild();
|
|
});
|
|
|
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
|
const createPromise = QmdMemoryManager.create({ cfg, agentId, resolved });
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
const manager = await createPromise;
|
|
expect(manager).toBeTruthy();
|
|
if (!manager) {
|
|
throw new Error("manager missing");
|
|
}
|
|
const syncPromise = manager.sync({ reason: "manual" });
|
|
const resolvedSync = expect(syncPromise).resolves.toBeUndefined();
|
|
await vi.advanceTimersByTimeAsync(20);
|
|
await resolvedSync;
|
|
await manager.close();
|
|
});
|
|
|
|
it("scopes by channel for agent-prefixed session keys", async () => {
|
|
cfg = {
|
|
...cfg,
|
|
memory: {
|
|
backend: "qmd",
|
|
qmd: {
|
|
includeDefaultMemory: false,
|
|
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
|
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
|
scope: {
|
|
default: "deny",
|
|
rules: [{ action: "allow", match: { channel: "slack" } }],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
|
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
|
|
expect(manager).toBeTruthy();
|
|
if (!manager) {
|
|
throw new Error("manager missing");
|
|
}
|
|
|
|
const isAllowed = (key?: string) =>
|
|
(manager as unknown as { isScopeAllowed: (key?: string) => boolean }).isScopeAllowed(key);
|
|
expect(isAllowed("agent:main:slack:channel:c123")).toBe(true);
|
|
expect(isAllowed("agent:main:discord:channel:c123")).toBe(false);
|
|
|
|
await manager.close();
|
|
});
|
|
|
|
it("blocks non-markdown or symlink reads for qmd paths", async () => {
|
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
|
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
|
|
expect(manager).toBeTruthy();
|
|
if (!manager) {
|
|
throw new Error("manager missing");
|
|
}
|
|
|
|
const textPath = path.join(workspaceDir, "secret.txt");
|
|
await fs.writeFile(textPath, "nope", "utf-8");
|
|
await expect(manager.readFile({ relPath: "qmd/workspace/secret.txt" })).rejects.toThrow(
|
|
"path required",
|
|
);
|
|
|
|
const target = path.join(workspaceDir, "target.md");
|
|
await fs.writeFile(target, "ok", "utf-8");
|
|
const link = path.join(workspaceDir, "link.md");
|
|
await fs.symlink(target, link);
|
|
await expect(manager.readFile({ relPath: "qmd/workspace/link.md" })).rejects.toThrow(
|
|
"path required",
|
|
);
|
|
|
|
await manager.close();
|
|
});
|
|
|
|
it("throws when sqlite index is busy", async () => {
|
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
|
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
|
|
expect(manager).toBeTruthy();
|
|
if (!manager) {
|
|
throw new Error("manager missing");
|
|
}
|
|
const inner = manager as unknown as {
|
|
db: { prepare: () => { get: () => never }; close: () => void } | null;
|
|
resolveDocLocation: (docid?: string) => Promise<unknown>;
|
|
};
|
|
inner.db = {
|
|
prepare: () => ({
|
|
get: () => {
|
|
throw new Error("SQLITE_BUSY: database is locked");
|
|
},
|
|
}),
|
|
close: () => {},
|
|
};
|
|
await expect(inner.resolveDocLocation("abc123")).rejects.toThrow(
|
|
"qmd index busy while reading results",
|
|
);
|
|
await manager.close();
|
|
});
|
|
|
|
it("fails search when sqlite index is busy so caller can fallback", async () => {
|
|
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
|
if (args[0] === "query") {
|
|
const child = createMockChild({ autoClose: false });
|
|
setTimeout(() => {
|
|
child.stdout.emit(
|
|
"data",
|
|
JSON.stringify([{ docid: "abc123", score: 1, snippet: "@@ -1,1\nremember this" }]),
|
|
);
|
|
child.closeWith(0);
|
|
}, 0);
|
|
return child;
|
|
}
|
|
return createMockChild();
|
|
});
|
|
|
|
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
|
|
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
|
|
expect(manager).toBeTruthy();
|
|
if (!manager) {
|
|
throw new Error("manager missing");
|
|
}
|
|
const inner = manager as unknown as {
|
|
db: { prepare: () => { get: () => never }; close: () => void } | null;
|
|
};
|
|
inner.db = {
|
|
prepare: () => ({
|
|
get: () => {
|
|
throw new Error("SQLITE_BUSY: database is locked");
|
|
},
|
|
}),
|
|
close: () => {},
|
|
};
|
|
await expect(
|
|
manager.search("busy lookup", { sessionKey: "agent:main:slack:dm:u123" }),
|
|
).rejects.toThrow("qmd index busy while reading results");
|
|
await manager.close();
|
|
});
|
|
});
|
|
|
|
async function waitForCondition(check: () => boolean, timeoutMs: number): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (check()) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
throw new Error("condition was not met in time");
|
|
}
|