pi-tensorix-provider 1.0.21 → 1.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Quiet startup sync, end to end through the extension's real session_start
3
+ * handler: the live /models catalog is fetched only with a key, only when the
4
+ * disk cache is stale (or the key changed), only by one process at a time, and
5
+ * never on the session_start critical path.
6
+ *
7
+ * Porting: only the "Provider specifics" block below differs between
8
+ * pi-*-provider repos.
9
+ */
10
+
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { keyFingerprint, syncLockPath, syncMetaPath } from "../sync-policy";
16
+ import embeddedModels from "../models.json" with { type: "json" };
17
+
18
+ // ─── Provider specifics ───────────────────────────────────────────────────────
19
+ const PROVIDER_ID = "tensorix";
20
+ const LIVE_ONLY_ID = "live-only-test-model";
21
+ /** A minimal successful /models response containing LIVE_ONLY_ID. */
22
+ const liveModelsResponse = () => Response.json({
23
+ object: "list",
24
+ data: [{ id: LIVE_ONLY_ID, object: "model", owned_by: "tensorix" }],
25
+ });
26
+ // ──────────────────────────────────────────────────────────────────────────────
27
+
28
+ const HOUR = 60 * 60 * 1000;
29
+ const KEY = "sk-quiet-sync-test";
30
+
31
+ let home: string;
32
+ let cachePath: string;
33
+
34
+ afterEach(() => {
35
+ vi.unstubAllEnvs();
36
+ vi.unstubAllGlobals();
37
+ vi.resetModules();
38
+ if (home) fs.rmSync(home, { recursive: true, force: true });
39
+ });
40
+
41
+ function setupHome(): void {
42
+ home = fs.mkdtempSync(path.join(os.tmpdir(), `pi-${PROVIDER_ID}-sync-`));
43
+ cachePath = path.join(home, "cache", `${PROVIDER_ID}-models.json`);
44
+ vi.stubEnv("PI_CODING_AGENT_DIR", home);
45
+ vi.stubEnv("PI_PROVIDER_SYNC_TTL_MS", "");
46
+ }
47
+
48
+ function writeCache(ageMs: number, models: unknown[] = embeddedModels, fingerprintKey: string | null = KEY): void {
49
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
50
+ fs.writeFileSync(cachePath, JSON.stringify(models));
51
+ const t = new Date(Date.now() - ageMs);
52
+ fs.utimesSync(cachePath, t, t);
53
+ if (fingerprintKey !== null) {
54
+ fs.writeFileSync(syncMetaPath(cachePath), JSON.stringify({ version: 1, keyFingerprint: keyFingerprint(fingerprintKey) }));
55
+ }
56
+ }
57
+
58
+ function stubFetch(impl: () => Promise<Response> = async () => liveModelsResponse()) {
59
+ const fetch = vi.fn(impl);
60
+ vi.stubGlobal("fetch", fetch);
61
+ return fetch;
62
+ }
63
+
64
+ /** Load the extension fresh (module-level cache paths read PI_CODING_AGENT_DIR). */
65
+ async function loadExtension() {
66
+ vi.resetModules();
67
+ const mod = await import("../index");
68
+ const handlers = new Map<string, (...args: any[]) => any>();
69
+ const registrations: Array<{ models: Array<{ id: string }> }> = [];
70
+ mod.default({
71
+ on: (event: string, handler: (...args: any[]) => any) => handlers.set(event, handler),
72
+ registerProvider: (_id: string, config: any) => registrations.push(config),
73
+ registerCommand: () => {},
74
+ } as any);
75
+ const getApiKeyForProvider = vi.fn(async (): Promise<string | undefined> => KEY);
76
+ const startSession = () => handlers.get("session_start")!({ type: "session_start" }, { modelRegistry: { getApiKeyForProvider } });
77
+ return { handlers, registrations, getApiKeyForProvider, startSession };
78
+ }
79
+
80
+ /** Let the fire-and-forget revalidation chain run to completion. */
81
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 50));
82
+ const hasLiveModel = (config: { models: Array<{ id: string }> } | undefined) => !!config?.models.some((m) => m.id === LIVE_ONLY_ID);
83
+
84
+ describe("quiet startup sync", () => {
85
+ it("skips the live fetch while the disk cache is fresher than the TTL", async () => {
86
+ setupHome();
87
+ writeCache(10 * 60 * 1000);
88
+ const fetch = stubFetch();
89
+ const ext = await loadExtension();
90
+ await ext.startSession();
91
+ await settle();
92
+ expect(ext.getApiKeyForProvider).toHaveBeenCalled();
93
+ expect(fetch).not.toHaveBeenCalled();
94
+ expect(ext.registrations).toHaveLength(1);
95
+ });
96
+
97
+ it("revalidates a stale cache once, hot-swaps the catalog and rewrites the cache", async () => {
98
+ setupHome();
99
+ writeCache(2 * HOUR);
100
+ const fetch = stubFetch();
101
+ const ext = await loadExtension();
102
+ await ext.startSession();
103
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
104
+ expect(fetch).toHaveBeenCalledOnce();
105
+ const cached = JSON.parse(fs.readFileSync(cachePath, "utf8"));
106
+ // Cache stays a plain JSON array so older extension versions still read it.
107
+ expect(Array.isArray(cached)).toBe(true);
108
+ expect(cached.some((m: { id: string }) => m.id === LIVE_ONLY_ID)).toBe(true);
109
+ expect(Date.now() - fs.statSync(cachePath).mtimeMs).toBeLessThan(60_000);
110
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(false);
111
+ const meta = fs.readFileSync(syncMetaPath(cachePath), "utf8");
112
+ expect(meta).not.toContain(KEY);
113
+ expect(JSON.parse(meta).keyFingerprint).toBe(keyFingerprint(KEY));
114
+
115
+ // A second session in the same process (/new) is now inside the TTL.
116
+ await ext.startSession();
117
+ await settle();
118
+ expect(fetch).toHaveBeenCalledOnce();
119
+ });
120
+
121
+ it("never touches the network without an API key", async () => {
122
+ setupHome();
123
+ const fetch = stubFetch();
124
+ const ext = await loadExtension();
125
+ ext.getApiKeyForProvider.mockResolvedValue(undefined);
126
+ await ext.startSession();
127
+ await settle();
128
+ expect(fetch).not.toHaveBeenCalled();
129
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(false);
130
+ expect(ext.registrations).toHaveLength(1);
131
+ });
132
+
133
+ it("skips when another process holds the revalidation lock", async () => {
134
+ setupHome();
135
+ writeCache(2 * HOUR);
136
+ fs.writeFileSync(syncLockPath(cachePath), JSON.stringify({ pid: 999999, token: "other-process" }));
137
+ const fetch = stubFetch();
138
+ const ext = await loadExtension();
139
+ await ext.startSession();
140
+ await settle();
141
+ expect(fetch).not.toHaveBeenCalled();
142
+ expect(JSON.parse(fs.readFileSync(syncLockPath(cachePath), "utf8")).token).toBe("other-process");
143
+ });
144
+
145
+ it("reclaims a stale lock left by a crashed process", async () => {
146
+ setupHome();
147
+ writeCache(2 * HOUR);
148
+ const lockPath = syncLockPath(cachePath);
149
+ fs.writeFileSync(lockPath, JSON.stringify({ pid: 999999, token: "crashed" }));
150
+ const old = new Date(Date.now() - 10 * 60 * 1000);
151
+ fs.utimesSync(lockPath, old, old);
152
+ const fetch = stubFetch();
153
+ const ext = await loadExtension();
154
+ await ext.startSession();
155
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
156
+ expect(fetch).toHaveBeenCalledOnce();
157
+ expect(fs.existsSync(lockPath)).toBe(false);
158
+ });
159
+
160
+ it("forces a revalidation when the API key changed, even inside the TTL", async () => {
161
+ setupHome();
162
+ writeCache(60 * 1000, embeddedModels, "previous-key");
163
+ const fetch = stubFetch();
164
+ const ext = await loadExtension();
165
+ await ext.startSession();
166
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
167
+ expect(fetch).toHaveBeenCalledOnce();
168
+ });
169
+
170
+ it("revalidates once when the cache predates the sync sidecar (older extension version)", async () => {
171
+ setupHome();
172
+ writeCache(60 * 1000, embeddedModels, null);
173
+ const fetch = stubFetch();
174
+ const ext = await loadExtension();
175
+ expect(ext.registrations[0].models.length).toBeGreaterThan(0); // old cache is still served
176
+ await ext.startSession();
177
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
178
+ expect(fetch).toHaveBeenCalledOnce();
179
+ });
180
+
181
+ it("does not throw or fetch when the cache dir is unwritable", async () => {
182
+ setupHome();
183
+ fs.writeFileSync(path.join(home, "cache"), "a file where the cache dir should be");
184
+ const fetch = stubFetch();
185
+ const ext = await loadExtension();
186
+ await expect(ext.startSession()).resolves.toBeUndefined();
187
+ await settle();
188
+ expect(fetch).not.toHaveBeenCalled();
189
+ expect(ext.registrations).toHaveLength(1);
190
+ });
191
+
192
+ it("does not await the network in session_start", async () => {
193
+ setupHome();
194
+ writeCache(2 * HOUR);
195
+ let release!: () => void;
196
+ const fetch = stubFetch(() => new Promise<Response>((resolve) => { release = () => resolve(liveModelsResponse()); }));
197
+ const ext = await loadExtension();
198
+ const outcome = await Promise.race([
199
+ Promise.resolve(ext.startSession()).then(() => "returned"),
200
+ new Promise((resolve) => setTimeout(() => resolve("blocked"), 1000)),
201
+ ]);
202
+ expect(outcome).toBe("returned");
203
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
204
+ release();
205
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
206
+ });
207
+
208
+ it("swallows API key resolution failures (no unhandled rejection)", async () => {
209
+ setupHome();
210
+ const fetch = stubFetch();
211
+ const ext = await loadExtension();
212
+ const unhandled = vi.fn();
213
+ process.on("unhandledRejection", unhandled);
214
+ try {
215
+ ext.getApiKeyForProvider.mockRejectedValue(new Error("keychain locked"));
216
+ await ext.startSession();
217
+ await settle();
218
+ expect(unhandled).not.toHaveBeenCalled();
219
+ expect(fetch).not.toHaveBeenCalled();
220
+ } finally {
221
+ process.off("unhandledRejection", unhandled);
222
+ }
223
+ });
224
+
225
+ it("backs off after a failed fetch instead of retrying in every new process", async () => {
226
+ setupHome();
227
+ writeCache(2 * HOUR);
228
+ const fetch = stubFetch(async () => new Response("unauthorized", { status: 401 }));
229
+ let ext = await loadExtension();
230
+ await ext.startSession();
231
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
232
+ await settle();
233
+ ext = await loadExtension(); // next pi process
234
+ await ext.startSession();
235
+ await settle();
236
+ expect(fetch).toHaveBeenCalledOnce();
237
+ });
238
+
239
+ it("adopts a cache refreshed by another process without fetching", async () => {
240
+ setupHome();
241
+ writeCache(30 * 60 * 1000);
242
+ const fetch = stubFetch();
243
+ const ext = await loadExtension();
244
+ // Another pi process revalidated after this one started.
245
+ const refreshed = [...embeddedModels, { ...embeddedModels[0], id: LIVE_ONLY_ID }];
246
+ fs.writeFileSync(cachePath, JSON.stringify(refreshed));
247
+ await ext.startSession();
248
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
249
+ expect(fetch).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it("PI_PROVIDER_SYNC_TTL_MS=0 revalidates on every session start", async () => {
253
+ setupHome();
254
+ vi.stubEnv("PI_PROVIDER_SYNC_TTL_MS", "0");
255
+ writeCache(0);
256
+ const fetch = stubFetch();
257
+ const ext = await loadExtension();
258
+ await ext.startSession();
259
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
260
+ });
261
+ });
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Unit tests for sync-policy.ts — the provider-agnostic quiet startup sync
3
+ * policy (TTL, key fingerprint, failure backoff, cross-process lock).
4
+ *
5
+ * Provider-agnostic: copied verbatim into every pi-*-provider repo alongside
6
+ * sync-policy.ts.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import { execFile } from "node:child_process";
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import {
16
+ DEFAULT_SYNC_TTL_MS,
17
+ SYNC_TTL_ENV,
18
+ acquireModelSync,
19
+ cacheChangedSinceLoad,
20
+ keyFingerprint,
21
+ markCacheLoaded,
22
+ planModelSync,
23
+ resolveSyncTtlMs,
24
+ syncLockPath,
25
+ syncMetaPath,
26
+ writeFileAtomic,
27
+ } from "../sync-policy";
28
+
29
+ const HOUR = 60 * 60 * 1000;
30
+ const KEY = "sk-test-secret-key";
31
+
32
+ let dir: string;
33
+ let cachePath: string;
34
+
35
+ beforeEach(() => {
36
+ vi.stubEnv(SYNC_TTL_ENV, "");
37
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-sync-policy-"));
38
+ cachePath = path.join(dir, "cache", "example-models.json");
39
+ });
40
+
41
+ afterEach(() => {
42
+ vi.unstubAllEnvs();
43
+ fs.rmSync(dir, { recursive: true, force: true });
44
+ });
45
+
46
+ function writeCache(ageMs: number, models: unknown = [{ id: "m" }]): void {
47
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
48
+ fs.writeFileSync(cachePath, JSON.stringify(models));
49
+ const t = new Date(Date.now() - ageMs);
50
+ fs.utimesSync(cachePath, t, t);
51
+ }
52
+
53
+ function writeMeta(meta: Record<string, unknown>): void {
54
+ fs.writeFileSync(syncMetaPath(cachePath), JSON.stringify({ version: 1, ...meta }));
55
+ }
56
+
57
+ function writeLock(ageMs: number, token = "someone-else"): void {
58
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
59
+ fs.writeFileSync(syncLockPath(cachePath), JSON.stringify({ pid: 999999, token }));
60
+ const t = new Date(Date.now() - ageMs);
61
+ fs.utimesSync(syncLockPath(cachePath), t, t);
62
+ }
63
+
64
+ describe("keyFingerprint", () => {
65
+ it("is a stable 16-hex digest that never contains the key", () => {
66
+ const fp = keyFingerprint(KEY);
67
+ expect(fp).toMatch(/^[0-9a-f]{16}$/);
68
+ expect(keyFingerprint(KEY)).toBe(fp);
69
+ expect(keyFingerprint(`${KEY}x`)).not.toBe(fp);
70
+ expect(fp).not.toContain(KEY);
71
+ });
72
+ });
73
+
74
+ describe("resolveSyncTtlMs", () => {
75
+ it("defaults to 1 hour", () => {
76
+ expect(resolveSyncTtlMs()).toBe(DEFAULT_SYNC_TTL_MS);
77
+ expect(DEFAULT_SYNC_TTL_MS).toBe(HOUR);
78
+ });
79
+
80
+ it(`honors ${SYNC_TTL_ENV} (0 = always revalidate) and ignores garbage`, () => {
81
+ vi.stubEnv(SYNC_TTL_ENV, "0");
82
+ expect(resolveSyncTtlMs()).toBe(0);
83
+ vi.stubEnv(SYNC_TTL_ENV, "120000");
84
+ expect(resolveSyncTtlMs()).toBe(120000);
85
+ vi.stubEnv(SYNC_TTL_ENV, "-5");
86
+ expect(resolveSyncTtlMs()).toBe(DEFAULT_SYNC_TTL_MS);
87
+ vi.stubEnv(SYNC_TTL_ENV, "soon");
88
+ expect(resolveSyncTtlMs()).toBe(DEFAULT_SYNC_TTL_MS);
89
+ });
90
+
91
+ it("prefers an explicit ttlMs over the env", () => {
92
+ vi.stubEnv(SYNC_TTL_ENV, "0");
93
+ expect(resolveSyncTtlMs(5000)).toBe(5000);
94
+ });
95
+ });
96
+
97
+ describe("planModelSync", () => {
98
+ it("never fetches without a key", () => {
99
+ expect(planModelSync({ cachePath, apiKey: undefined })).toEqual({ fetch: false, reason: "no-key" });
100
+ expect(planModelSync({ cachePath, apiKey: "" })).toEqual({ fetch: false, reason: "no-key" });
101
+ });
102
+
103
+ it("fetches when the cache is missing or corrupt", () => {
104
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "missing" });
105
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
106
+ fs.writeFileSync(cachePath, "{not json");
107
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "missing" });
108
+ writeCache(0, []);
109
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "missing" });
110
+ });
111
+
112
+ it("skips inside the TTL and fetches once it expires", () => {
113
+ writeCache(10 * 60 * 1000);
114
+ writeMeta({ keyFingerprint: keyFingerprint(KEY) });
115
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: false, reason: "fresh" });
116
+ writeCache(2 * HOUR);
117
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "stale" });
118
+ vi.stubEnv(SYNC_TTL_ENV, "0");
119
+ writeCache(0);
120
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "stale" });
121
+ });
122
+
123
+ it("forces a fetch when the key changed or the cache predates the sidecar", () => {
124
+ writeCache(0);
125
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "key-changed" });
126
+ writeMeta({ keyFingerprint: keyFingerprint("old-key") });
127
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "key-changed" });
128
+ });
129
+
130
+ it("forces a fetch on demand (e.g. after /login) regardless of TTL", () => {
131
+ writeCache(0);
132
+ writeMeta({ keyFingerprint: keyFingerprint(KEY) });
133
+ expect(planModelSync({ cachePath, apiKey: KEY, force: true })).toEqual({ fetch: true, reason: "forced" });
134
+ });
135
+
136
+ it("backs off after a recent failure with the same key, unless forced", () => {
137
+ writeCache(2 * HOUR);
138
+ writeMeta({ keyFingerprint: keyFingerprint(KEY), failedAt: Date.now() - 1000, failedKeyFingerprint: keyFingerprint(KEY) });
139
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: false, reason: "backoff" });
140
+ expect(planModelSync({ cachePath, apiKey: KEY, force: true }).fetch).toBe(true);
141
+ // A different key is not held back by another key's failure.
142
+ expect(planModelSync({ cachePath, apiKey: "new-key" })).toEqual({ fetch: true, reason: "key-changed" });
143
+ // Backoff expires.
144
+ writeMeta({ keyFingerprint: keyFingerprint(KEY), failedAt: Date.now() - HOUR, failedKeyFingerprint: keyFingerprint(KEY) });
145
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "stale" });
146
+ });
147
+ });
148
+
149
+ describe("acquireModelSync", () => {
150
+ it("is single-flight: a second caller skips while the lock is held", () => {
151
+ const skipped: string[] = [];
152
+ const first = acquireModelSync({ cachePath, apiKey: KEY });
153
+ expect(first?.reason).toBe("missing");
154
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(true);
155
+ expect(acquireModelSync({ cachePath, apiKey: KEY, onSkip: (r) => skipped.push(r) })).toBeNull();
156
+ expect(skipped).toEqual(["locked"]);
157
+ first!.release("aborted");
158
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(false);
159
+ expect(acquireModelSync({ cachePath, apiKey: KEY })).not.toBeNull();
160
+ });
161
+
162
+ it("skips while another process holds a live lock", () => {
163
+ writeLock(1000);
164
+ const skipped: string[] = [];
165
+ expect(acquireModelSync({ cachePath, apiKey: KEY, onSkip: (r) => skipped.push(r) })).toBeNull();
166
+ expect(skipped).toEqual(["locked"]);
167
+ });
168
+
169
+ it("reclaims a stale lock left by a crashed process", () => {
170
+ writeLock(5 * 60 * 1000);
171
+ const lease = acquireModelSync({ cachePath, apiKey: KEY });
172
+ expect(lease).not.toBeNull();
173
+ lease!.release("aborted");
174
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(false);
175
+ });
176
+
177
+ it("never removes a lock it no longer owns", () => {
178
+ const lease = acquireModelSync({ cachePath, apiKey: KEY, lockStaleMs: 0 })!;
179
+ writeLock(0, "reclaimed-by-another-process");
180
+ lease.release("aborted");
181
+ expect(JSON.parse(fs.readFileSync(syncLockPath(cachePath), "utf8")).token).toBe("reclaimed-by-another-process");
182
+ });
183
+
184
+ it("records the key fingerprint on success (never the key) and the failure on error", () => {
185
+ let lease = acquireModelSync({ cachePath, apiKey: KEY })!;
186
+ writeCache(0);
187
+ lease.release("fetched");
188
+ const raw = fs.readFileSync(syncMetaPath(cachePath), "utf8");
189
+ expect(raw).not.toContain(KEY);
190
+ expect(JSON.parse(raw)).toMatchObject({ version: 1, keyFingerprint: keyFingerprint(KEY) });
191
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: false, reason: "fresh" });
192
+
193
+ lease = acquireModelSync({ cachePath, apiKey: KEY, force: true })!;
194
+ lease.release("failed");
195
+ expect(JSON.parse(fs.readFileSync(syncMetaPath(cachePath), "utf8"))).toMatchObject({
196
+ keyFingerprint: keyFingerprint(KEY),
197
+ failedKeyFingerprint: keyFingerprint(KEY),
198
+ });
199
+ });
200
+
201
+ it("does not record a failure for an aborted fetch", () => {
202
+ acquireModelSync({ cachePath, apiKey: KEY })!.release("aborted");
203
+ expect(fs.existsSync(syncMetaPath(cachePath))).toBe(false);
204
+ });
205
+
206
+ it("fails open (null, no throw) when the cache dir is unwritable", () => {
207
+ // A regular file where the cache directory should be: mkdir/open fail even as root.
208
+ fs.writeFileSync(path.join(dir, "cache"), "not a directory");
209
+ const skipped: string[] = [];
210
+ expect(() => acquireModelSync({ cachePath, apiKey: KEY, onSkip: (r) => skipped.push(r) })).not.toThrow();
211
+ expect(acquireModelSync({ cachePath, apiKey: KEY })).toBeNull();
212
+ expect(skipped).toEqual(["unwritable"]);
213
+ });
214
+
215
+ // Real processes: children load sync-policy.ts via Node's built-in type stripping (Node >= 22.18).
216
+ it.skipIf(!(process.features as { typescript?: unknown }).typescript)("lets exactly one of many concurrent processes fetch", async () => {
217
+ writeCache(2 * HOUR);
218
+ writeMeta({ keyFingerprint: keyFingerprint(KEY) });
219
+ const modulePath = fileURLToPath(new URL("../sync-policy.ts", import.meta.url));
220
+ // Each child: try to acquire; the winner "fetches" for 300ms, writes the
221
+ // cache and releases. Late starters must then see a fresh cache.
222
+ const child = `
223
+ const { acquireModelSync, writeFileAtomic } = await import(${JSON.stringify(modulePath)});
224
+ const [cachePath, key] = process.argv.slice(-2);
225
+ const lease = acquireModelSync({ cachePath, apiKey: key });
226
+ if (lease) {
227
+ await new Promise((r) => setTimeout(r, 300));
228
+ writeFileAtomic(cachePath, JSON.stringify([{ id: "fresh" }]));
229
+ lease.release("fetched");
230
+ }
231
+ process.stdout.write(lease ? "fetch" : "skip");
232
+ `;
233
+ const run = () => new Promise<string>((resolve, reject) => {
234
+ execFile(process.execPath, ["--input-type=module", "-e", child, cachePath, KEY], (err, stdout) => err ? reject(err) : resolve(stdout));
235
+ });
236
+ const results = await Promise.all(Array.from({ length: 8 }, run));
237
+ expect(results.filter((r) => r === "fetch")).toHaveLength(1);
238
+ expect(results.filter((r) => r === "skip")).toHaveLength(7);
239
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(false);
240
+ }, 20000);
241
+ });
242
+
243
+ describe("cache helpers", () => {
244
+ it("writeFileAtomic replaces the file without leaving temp files", () => {
245
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
246
+ writeFileAtomic(cachePath, "[1]");
247
+ writeFileAtomic(cachePath, "[2]");
248
+ expect(fs.readFileSync(cachePath, "utf8")).toBe("[2]");
249
+ expect(fs.readdirSync(path.dirname(cachePath))).toEqual([path.basename(cachePath)]);
250
+ });
251
+
252
+ it("cacheChangedSinceLoad spots a cache rewritten by another process", () => {
253
+ writeCache(HOUR);
254
+ markCacheLoaded(cachePath);
255
+ expect(cacheChangedSinceLoad(cachePath)).toBe(false);
256
+ writeCache(0);
257
+ expect(cacheChangedSinceLoad(cachePath)).toBe(true);
258
+ });
259
+ });
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["tests/**/*.test.ts"],
7
+ clearMocks: true,
8
+ restoreMocks: true,
9
+ },
10
+ });