Skip to content

Commit ea391c6

Browse files
committed
test: stabilize cron and pairing shard hangs
1 parent 0bdba47 commit ea391c6

2 files changed

Lines changed: 25 additions & 183 deletions

File tree

src/cron/service.runs-one-shot-main-job-disables-it.test.ts

Lines changed: 11 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -1,190 +1,22 @@
1-
import path from "node:path";
2-
import { beforeEach, describe, expect, it, vi } from "vitest";
1+
import { describe, expect, it, vi } from "vitest";
32
import {
43
HEARTBEAT_SKIP_CRON_IN_PROGRESS,
54
HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT,
65
type HeartbeatRunResult,
76
} from "../infra/heartbeat-wake.js";
87
import type { CronEvent, CronServiceDeps } from "./service.js";
98
import { CronService } from "./service.js";
10-
import { createDeferred, createNoopLogger, installCronTestHooks } from "./service.test-harness.js";
9+
import {
10+
createCronStoreHarness,
11+
createDeferred,
12+
createNoopLogger,
13+
installCronTestHooks,
14+
} from "./service.test-harness.js";
1115

1216
const noopLogger = createNoopLogger();
1317
installCronTestHooks({ logger: noopLogger });
14-
15-
type FakeFsEntry =
16-
| { kind: "file"; content: string; mtimeMs: number }
17-
| { kind: "dir"; mtimeMs: number };
18-
19-
const fsState = vi.hoisted(() => ({
20-
entries: new Map<string, FakeFsEntry>(),
21-
nowMs: 0,
22-
fixtureCount: 0,
23-
}));
24-
25-
const abs = (p: string) => path.resolve(p);
26-
const fixturesRoot = abs(path.join("__openclaw_vitest__", "cron", "runs-one-shot"));
27-
const isFixturePath = (p: string) => {
28-
const resolved = abs(p);
29-
const rootPrefix = `${fixturesRoot}${path.sep}`;
30-
return resolved === fixturesRoot || resolved.startsWith(rootPrefix);
31-
};
32-
33-
function bumpMtimeMs() {
34-
fsState.nowMs += 1;
35-
return fsState.nowMs;
36-
}
37-
38-
function ensureDir(dirPath: string) {
39-
let current = abs(dirPath);
40-
while (true) {
41-
if (!fsState.entries.has(current)) {
42-
fsState.entries.set(current, { kind: "dir", mtimeMs: bumpMtimeMs() });
43-
}
44-
const parent = path.dirname(current);
45-
if (parent === current) {
46-
break;
47-
}
48-
current = parent;
49-
}
50-
}
51-
52-
function setFile(filePath: string, content: string) {
53-
const resolved = abs(filePath);
54-
ensureDir(path.dirname(resolved));
55-
fsState.entries.set(resolved, { kind: "file", content, mtimeMs: bumpMtimeMs() });
56-
}
57-
58-
async function makeStorePath() {
59-
const dir = path.join(fixturesRoot, `case-${fsState.fixtureCount++}`);
60-
ensureDir(dir);
61-
const storePath = path.join(dir, "cron", "jobs.json");
62-
ensureDir(path.dirname(storePath));
63-
return { storePath, cleanup: async () => {} };
64-
}
65-
66-
vi.mock("node:fs", async () => {
67-
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
68-
const pathMod = await import("node:path");
69-
const absInMock = (p: string) => pathMod.resolve(p);
70-
const isFixtureInMock = (p: string) => {
71-
const resolved = absInMock(p);
72-
const rootPrefix = `${absInMock(fixturesRoot)}${pathMod.sep}`;
73-
return resolved === absInMock(fixturesRoot) || resolved.startsWith(rootPrefix);
74-
};
75-
76-
const mkErr = (code: string, message: string) => Object.assign(new Error(message), { code });
77-
78-
const promises = {
79-
...actual.promises,
80-
mkdir: async (p: string) => {
81-
if (!isFixtureInMock(p)) {
82-
return await actual.promises.mkdir(p, { recursive: true });
83-
}
84-
ensureDir(p);
85-
return undefined;
86-
},
87-
readFile: async (p: string) => {
88-
if (!isFixtureInMock(p)) {
89-
return await actual.promises.readFile(p, "utf-8");
90-
}
91-
const entry = fsState.entries.get(absInMock(p));
92-
if (!entry || entry.kind !== "file") {
93-
throw mkErr("ENOENT", `ENOENT: no such file or directory, open '${p}'`);
94-
}
95-
return entry.content;
96-
},
97-
writeFile: async (p: string, data: string | Uint8Array) => {
98-
if (!isFixtureInMock(p)) {
99-
return await actual.promises.writeFile(p, data, "utf-8");
100-
}
101-
const content = typeof data === "string" ? data : Buffer.from(data).toString("utf-8");
102-
setFile(p, content);
103-
},
104-
rename: async (from: string, to: string) => {
105-
if (!isFixtureInMock(from) || !isFixtureInMock(to)) {
106-
return await actual.promises.rename(from, to);
107-
}
108-
const fromAbs = absInMock(from);
109-
const toAbs = absInMock(to);
110-
const entry = fsState.entries.get(fromAbs);
111-
if (!entry || entry.kind !== "file") {
112-
throw mkErr("ENOENT", `ENOENT: no such file or directory, rename '${from}' -> '${to}'`);
113-
}
114-
ensureDir(pathMod.dirname(toAbs));
115-
fsState.entries.delete(fromAbs);
116-
fsState.entries.set(toAbs, { ...entry, mtimeMs: bumpMtimeMs() });
117-
},
118-
copyFile: async (from: string, to: string) => {
119-
if (!isFixtureInMock(from) || !isFixtureInMock(to)) {
120-
return await actual.promises.copyFile(from, to);
121-
}
122-
const entry = fsState.entries.get(absInMock(from));
123-
if (!entry || entry.kind !== "file") {
124-
throw mkErr("ENOENT", `ENOENT: no such file or directory, copyfile '${from}' -> '${to}'`);
125-
}
126-
setFile(to, entry.content);
127-
},
128-
stat: async (p: string) => {
129-
if (!isFixtureInMock(p)) {
130-
return await actual.promises.stat(p);
131-
}
132-
const entry = fsState.entries.get(absInMock(p));
133-
if (!entry) {
134-
throw mkErr("ENOENT", `ENOENT: no such file or directory, stat '${p}'`);
135-
}
136-
return {
137-
mtimeMs: entry.mtimeMs,
138-
isDirectory: () => entry.kind === "dir",
139-
isFile: () => entry.kind === "file",
140-
};
141-
},
142-
access: async (p: string) => {
143-
if (!isFixtureInMock(p)) {
144-
return await actual.promises.access(p);
145-
}
146-
const entry = fsState.entries.get(absInMock(p));
147-
if (!entry) {
148-
throw mkErr("ENOENT", `ENOENT: no such file or directory, access '${p}'`);
149-
}
150-
},
151-
unlink: async (p: string) => {
152-
if (!isFixtureInMock(p)) {
153-
return await actual.promises.unlink(p);
154-
}
155-
fsState.entries.delete(absInMock(p));
156-
},
157-
} as unknown as typeof actual.promises;
158-
159-
const wrapped = { ...actual, promises };
160-
return { ...wrapped, default: wrapped };
161-
});
162-
163-
vi.mock("node:fs/promises", async () => {
164-
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
165-
const wrapped = {
166-
...actual,
167-
mkdir: async (p: string, _opts?: unknown) => {
168-
if (!isFixturePath(p)) {
169-
return await actual.mkdir(p, { recursive: true });
170-
}
171-
ensureDir(p);
172-
return undefined;
173-
},
174-
writeFile: async (p: string, data: string, _enc?: unknown) => {
175-
if (!isFixturePath(p)) {
176-
return await actual.writeFile(p, data, "utf-8");
177-
}
178-
setFile(p, data);
179-
},
180-
};
181-
return { ...wrapped, default: wrapped };
182-
});
183-
184-
beforeEach(() => {
185-
fsState.entries.clear();
186-
fsState.nowMs = 0;
187-
ensureDir(fixturesRoot);
18+
const { makeStorePath } = createCronStoreHarness({
19+
prefix: "openclaw-cron-runs-one-shot-",
18820
});
18921

19022
function createCronEventHarness() {
@@ -229,7 +61,6 @@ type CronHarnessOptions = {
22961
};
23062

23163
async function createCronHarness(options: CronHarnessOptions = {}) {
232-
ensureDir(fixturesRoot);
23364
const store = await makeStorePath();
23465
const enqueueSystemEvent = vi.fn();
23566
const requestHeartbeat = vi.fn();
@@ -377,6 +208,7 @@ function expectMainSystemEventPosted(enqueueSystemEvent: unknown, text: string)
377208
}
378209

379210
async function stopCronAndCleanup(cron: CronService, store: { cleanup: () => Promise<void> }) {
211+
await cron.status();
380212
cron.stop();
381213
await store.cleanup();
382214
}
@@ -678,7 +510,6 @@ describe("CronService", () => {
678510
});
679511

680512
it("rejects unsupported session/payload combinations", async () => {
681-
ensureDir(fixturesRoot);
682513
const store = await makeStorePath();
683514

684515
const cron = createStartedCronService(
@@ -712,7 +543,6 @@ describe("CronService", () => {
712543
}),
713544
).rejects.toThrow(/isolated.*cron jobs require/);
714545

715-
cron.stop();
716-
await store.cleanup();
546+
await stopCronAndCleanup(cron, store);
717547
});
718548
});

src/infra/device-pairing.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,20 @@ describe("device pairing tokens", () => {
211211
},
212212
baseDir,
213213
);
214-
const originalTs = first.request.ts;
215-
await new Promise((resolve) => setTimeout(resolve, 20));
214+
const originalTs = first.request.ts - 1_000;
215+
const paths = resolvePairingPaths(baseDir, "devices");
216+
const pendingById = JSON.parse(await readFile(paths.pendingPath, "utf8")) as Record<
217+
string,
218+
{ ts: number }
219+
>;
220+
const pending = pendingById[first.request.requestId];
221+
expect(pending).toBeDefined();
222+
if (!pending) {
223+
throw new Error("expected pending pairing request");
224+
}
225+
pending.ts = originalTs;
226+
await writeFile(paths.pendingPath, JSON.stringify(pendingById, null, 2));
227+
216228
const second = await requestDevicePairing(
217229
{
218230
deviceId: "device-1",

0 commit comments

Comments
 (0)