pi-hypercharm-provider 1.3.39 → 1.3.41

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,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
+ });
package/tsconfig.json CHANGED
@@ -7,6 +7,7 @@
7
7
  "types": ["node"],
8
8
  "esModuleInterop": true,
9
9
  "strict": false,
10
+ "strictNullChecks": true,
10
11
  "skipLibCheck": true,
11
12
  "noEmit": true,
12
13
  "resolveJsonModule": true
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ // The other tests/*.test.ts files are node:test suites run by `npm test`
7
+ // through jiti; vitest owns only the quiet startup sync suites.
8
+ include: ["tests/sync-policy.test.ts", "tests/quiet-startup-sync.test.ts"],
9
+ clearMocks: true,
10
+ restoreMocks: true,
11
+ },
12
+ });