File size: 13,671 Bytes
4440aec | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 | import { type FSWatcher, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_USAGE_BAR_TEMPLATE } from "./default-template.js";
import { loadUsageBarTemplate } from "./template.js";
import { clearUsageBarTemplateCacheForTest } from "./template.test-support.js";
const warnSpy = vi.hoisted(() => vi.fn());
vi.mock("../../logging/subsystem.js", () => ({
createSubsystemLogger: () => ({ warn: warnSpy }),
}));
const capturedWatchers = vi.hoisted(() => [] as Array<ReturnType<typeof import("node:fs").watch>>);
const capturedWatchChanges = vi.hoisted(() => [] as Array<() => void>);
const watcherOperations = vi.hoisted(() => [] as Array<["watch", unknown] | ["close", FSWatcher]>);
const closedWatchers = vi.hoisted(() => new Set<FSWatcher>());
vi.mock("node:fs", async (importOriginal) => {
const orig = await importOriginal<typeof import("node:fs")>();
const origWatch = orig.watch;
return {
...orig,
watch: ((path: unknown, opts: unknown, cb: unknown) => {
watcherOperations.push(["watch", path]);
const w = origWatch(path as never, opts as never, cb as never);
const close = w.close.bind(w);
w.close = () => {
close();
watcherOperations.push(["close", w]);
};
w.once("close", () => {
closedWatchers.add(w);
});
capturedWatchers.push(w);
capturedWatchChanges.push(() => {
(cb as (eventType: string, filename: null) => void)("change", null);
});
return w;
}) as typeof orig.watch,
};
});
const tplA = { segments: [{ text: "A" }] };
const tplB = { output: { default: [{ text: "B" }] } };
const cleanups: Array<() => void> = [];
afterEach(async () => {
try {
clearUsageBarTemplateCacheForTest();
} finally {
// Also close evicted watchers when a failed assertion exposes missing owner cleanup.
for (const watcher of capturedWatchers) {
watcher.close();
}
// Normal close events run on nextTick; settle them before removing watched files.
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
warnSpy.mockClear();
capturedWatchers.splice(0);
capturedWatchChanges.splice(0);
watcherOperations.splice(0);
closedWatchers.clear();
for (const fn of cleanups.splice(0)) {
fn();
}
}
});
function tmpDir(): string {
const d = mkdtempSync(join(tmpdir(), "usage-template-"));
cleanups.push(() => rmSync(d, { recursive: true, force: true }));
return d;
}
function tmpFile(name: string, contents: string): string {
const d = tmpDir();
const path = join(d, name);
writeFileSync(path, contents);
return path;
}
describe("loadUsageBarTemplate", () => {
it("returns the built-in template when unset", () => {
expect(loadUsageBarTemplate(undefined)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
});
it("returns an inline template object when usable", () => {
expect(loadUsageBarTemplate(tplA)).toBe(tplA);
});
it("falls back to the built-in template for an unusable inline object", () => {
expect(loadUsageBarTemplate({ nope: true })).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0]).toMatchObject([
"configured usage template could not be used; using built-in footer",
{ source: "inline", reason: "unsupported-shape" },
]);
});
it("falls back quietly for an empty inline template", () => {
expect(loadUsageBarTemplate({ output: {} })).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(loadUsageBarTemplate({ output: { default: [] } })).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(warnSpy).not.toHaveBeenCalled();
});
it("loads and parses a template file", () => {
const path = tmpFile("t.json", JSON.stringify(tplA));
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
});
it("falls back to the built-in template for invalid JSON", () => {
const path = tmpFile("bad.json", "{ not json");
expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0]).toMatchObject([
"configured usage template could not be used; using built-in footer",
{ source: "file", reason: "invalid-json", path },
]);
});
it("falls back to the built-in template for an empty template file", () => {
const path = tmpFile("empty.json", JSON.stringify({ output: { default: [] } }));
expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(warnSpy).not.toHaveBeenCalled();
});
it("reloads a path after an initial miss", () => {
const dir = tmpDir();
const missing = join(dir, "missing.json");
expect(loadUsageBarTemplate(missing)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(warnSpy).not.toHaveBeenCalled();
writeFileSync(missing, JSON.stringify(tplB));
expect(loadUsageBarTemplate(missing)).toMatchObject(tplB);
});
it("reloads a path after invalid JSON is fixed", () => {
const path = tmpFile("bad.json", "{ not json");
expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(warnSpy).toHaveBeenCalledTimes(1);
writeFileSync(path, JSON.stringify(tplB));
expect(loadUsageBarTemplate(path)).toMatchObject(tplB);
});
it("serves the cached template without re-reading the file", () => {
const path = tmpFile("t.json", JSON.stringify(tplA));
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
writeFileSync(path, JSON.stringify(tplB));
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
clearUsageBarTemplateCacheForTest();
expect(loadUsageBarTemplate(path)).toMatchObject(tplB);
});
it("bounds invalid-template warnings by least-recently-used path", () => {
const dir = tmpDir();
const paths = Array.from({ length: 257 }, (_, index) => {
const path = join(dir, `bad-${index}.json`);
writeFileSync(path, "{ not json");
return path;
});
for (const path of paths.slice(0, 256)) {
expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
}
expect(warnSpy).toHaveBeenCalledTimes(256);
// Refresh the oldest warning before overflow so the next key becomes the LRU victim.
expect(loadUsageBarTemplate(paths[0])).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(warnSpy).toHaveBeenCalledTimes(256);
expect(loadUsageBarTemplate(paths[256])).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(warnSpy).toHaveBeenCalledTimes(257);
expect(loadUsageBarTemplate(paths[0])).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(warnSpy).toHaveBeenCalledTimes(257);
expect(loadUsageBarTemplate(paths[1])).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(warnSpy).toHaveBeenCalledTimes(258);
expect(warnSpy).toHaveBeenLastCalledWith(
"configured usage template could not be used; using built-in footer",
{ source: "file", reason: "invalid-json", path: paths[1] },
);
});
describe("cache eviction", () => {
it("evicts the oldest entry and closes its watcher when inserting a new key over the limit", async () => {
const dir = tmpDir();
const paths: string[] = [];
// Create 65 template files — one more than MAX_CACHED_TEMPLATE_FILES (64).
for (let i = 0; i < 65; i++) {
const path = join(dir, `tpl-${i}.json`);
writeFileSync(path, JSON.stringify({ segments: [{ text: `v1-${i}` }] }));
paths.push(path);
}
// Load the first 64 files to fill the cache.
for (let i = 0; i < 64; i++) {
expect(loadUsageBarTemplate(paths[i])).toMatchObject({
segments: [{ text: `v1-${i}` }],
});
}
expect(capturedWatchers).toHaveLength(64);
expect(watcherOperations).toHaveLength(64);
expect(closedWatchers.size).toBe(0);
watcherOperations.splice(0);
// Insert the 65th file — should evict the oldest (paths[0]) and close
// its watcher before allocating a watcher for paths[64].
expect(loadUsageBarTemplate(paths[64])).toMatchObject({
segments: [{ text: "v1-64" }],
});
expect(capturedWatchers).toHaveLength(65);
expect(watcherOperations.map(([kind]) => kind)).toEqual(["close", "watch"]);
expect(watcherOperations[0]?.[1]).toBe(capturedWatchers[0]);
expect(watcherOperations[1]?.[1]).toBe(paths[64]);
watcherOperations.splice(0);
// Check every survivor before re-inserting the evicted path causes another eviction.
for (let i = 1; i < 64; i++) {
expect(loadUsageBarTemplate(paths[i])).toMatchObject({
segments: [{ text: `v1-${i}` }],
});
}
expect(capturedWatchers).toHaveLength(65);
expect(watcherOperations).toEqual([]);
// Re-reading changed content proves cache eviction; lifecycle assertions prove closure.
writeFileSync(
expectDefined(paths[0], "paths[0] test invariant"),
JSON.stringify({ segments: [{ text: "v2-0" }] }),
);
expect(loadUsageBarTemplate(paths[0])).toMatchObject({
segments: [{ text: "v2-0" }],
});
expect(capturedWatchers).toHaveLength(66);
expect(watcherOperations.map(([kind]) => kind)).toEqual(["close", "watch"]);
expect(watcherOperations[0]?.[1]).toBe(capturedWatchers[1]);
expect(watcherOperations[1]?.[1]).toBe(paths[0]);
watcherOperations.splice(0);
clearUsageBarTemplateCacheForTest();
expect(watcherOperations).toHaveLength(64);
for (const watcher of capturedWatchers.slice(2)) {
expect(
watcherOperations.some(([kind, closed]) => kind === "close" && closed === watcher),
).toBe(true);
}
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(closedWatchers.size).toBe(capturedWatchers.length);
for (const watcher of capturedWatchers) {
expect(closedWatchers.has(watcher)).toBe(true);
}
});
it("recovers after watcher error by clearing the dead watcher reference", () => {
const path = tmpFile("t.json", JSON.stringify(tplA));
// Load valid template → creates a watcher in the cache.
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
expect(capturedWatchers.length).toBe(1);
// Write invalid JSON to trigger the change handler, which sets
// entry.template = undefined.
writeFileSync(path, "{ not json");
capturedWatchChanges[0]?.();
// The template is now invalid — served as DEFAULT.
expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
// Simulate a transient watcher error.
// Without the fix: entry.watcher stays truthy → permanent DEFAULT.
// With the fix: entry.watcher is cleared → recovery on next access.
capturedWatchers[0]?.emit("error", new Error("simulated watcher error"));
// Write valid content to disk.
writeFileSync(path, JSON.stringify(tplB));
expect(loadUsageBarTemplate(path)).toMatchObject(tplB);
});
it("reloads a still-valid template after watcher error", async () => {
const path = tmpFile("t.json", JSON.stringify(tplA));
// Load valid template → creates a watcher in the cache.
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
expect(capturedWatchers.length).toBe(1);
// Simulate a transient watcher error while the template is still valid.
capturedWatchers[0]?.emit("error", new Error("simulated watcher error"));
// Without the fix (entry.template cleared): the stale tplA is still
// served from cache because entry.template is truthy. File edits
// are never observed because the dead watcher doesn't fire and
// loadUsageBarTemplate never calls cacheTemplateFile() again.
// With the fix: entry.template is also cleared, so the next load
// re-reads from disk and creates a fresh watcher.
writeFileSync(path, JSON.stringify(tplB));
expect(loadUsageBarTemplate(path)).toMatchObject(tplB);
});
it("does not evict when retrying the same key after a prior miss", () => {
const dir = tmpDir();
// Fill the cache with 63 valid files plus 1 invalid file = 64 entries.
const validPaths: string[] = [];
for (let i = 0; i < 63; i++) {
const path = join(dir, `good-${i}.json`);
writeFileSync(path, JSON.stringify({ segments: [{ text: `v1-${i}` }] }));
validPaths.push(path);
}
const invalidPath = join(dir, "bad.json");
writeFileSync(invalidPath, "{ not json");
// Load 63 valid + 1 invalid = 64 entries in cache (cache full).
for (const p of validPaths) {
expect(loadUsageBarTemplate(p)).toMatchObject({
segments: [{ text: expect.any(String) }],
});
}
expect(loadUsageBarTemplate(invalidPath)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
// Fix the invalid file and retry. This is the SAME key, so it must NOT
// evict the oldest valid entry (validPaths[0]).
writeFileSync(invalidPath, JSON.stringify(tplB));
expect(loadUsageBarTemplate(invalidPath)).toMatchObject(tplB);
// validPaths[0] should still be cached — not evicted by the retry.
writeFileSync(
expectDefined(validPaths[0], "validPaths[0] test invariant"),
JSON.stringify({ segments: [{ text: "changed" }] }),
);
expect(loadUsageBarTemplate(validPaths[0])).toMatchObject({
segments: [{ text: `v1-0` }],
});
});
});
});
|