@indigoai-us/hq-cloud 5.19.0 → 5.20.0
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.
- package/.github/workflows/ci.yml +8 -4
- package/.github/workflows/publish.yml +9 -3
- package/dist/bin/sync-runner.d.ts +9 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +58 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/client-info.d.ts +44 -0
- package/dist/client-info.d.ts.map +1 -0
- package/dist/client-info.js +112 -0
- package/dist/client-info.js.map +1 -0
- package/dist/client-info.test.d.ts +11 -0
- package/dist/client-info.test.d.ts.map +1 -0
- package/dist/client-info.test.js +168 -0
- package/dist/client-info.test.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +10 -2
- package/dist/context.js.map +1 -1
- package/dist/entity-resolver.d.ts +48 -0
- package/dist/entity-resolver.d.ts.map +1 -0
- package/dist/entity-resolver.js +122 -0
- package/dist/entity-resolver.js.map +1 -0
- package/dist/entity-resolver.test.d.ts +10 -0
- package/dist/entity-resolver.test.d.ts.map +1 -0
- package/dist/entity-resolver.test.js +236 -0
- package/dist/entity-resolver.test.js.map +1 -0
- package/dist/index.d.ts +20 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas/signal-types.d.ts +16 -0
- package/dist/schemas/signal-types.d.ts.map +1 -0
- package/dist/schemas/signal-types.js +30 -0
- package/dist/schemas/signal-types.js.map +1 -0
- package/dist/schemas/signal-types.test.d.ts +2 -0
- package/dist/schemas/signal-types.test.d.ts.map +1 -0
- package/dist/schemas/signal-types.test.js +65 -0
- package/dist/schemas/signal-types.test.js.map +1 -0
- package/dist/schemas/source-channels.d.ts +15 -0
- package/dist/schemas/source-channels.d.ts.map +1 -0
- package/dist/schemas/source-channels.js +28 -0
- package/dist/schemas/source-channels.js.map +1 -0
- package/dist/schemas/source-channels.test.d.ts +2 -0
- package/dist/schemas/source-channels.test.d.ts.map +1 -0
- package/dist/schemas/source-channels.test.js +65 -0
- package/dist/schemas/source-channels.test.js.map +1 -0
- package/dist/signals/get.d.ts +13 -0
- package/dist/signals/get.d.ts.map +1 -0
- package/dist/signals/get.js +74 -0
- package/dist/signals/get.js.map +1 -0
- package/dist/signals/get.test.d.ts +5 -0
- package/dist/signals/get.test.d.ts.map +1 -0
- package/dist/signals/get.test.js +170 -0
- package/dist/signals/get.test.js.map +1 -0
- package/dist/signals/internals.d.ts +16 -0
- package/dist/signals/internals.d.ts.map +1 -0
- package/dist/signals/internals.js +39 -0
- package/dist/signals/internals.js.map +1 -0
- package/dist/signals/list.d.ts +10 -0
- package/dist/signals/list.d.ts.map +1 -0
- package/dist/signals/list.js +76 -0
- package/dist/signals/list.js.map +1 -0
- package/dist/signals/list.test.d.ts +9 -0
- package/dist/signals/list.test.d.ts.map +1 -0
- package/dist/signals/list.test.js +227 -0
- package/dist/signals/list.test.js.map +1 -0
- package/dist/signals/parse.d.ts +8 -0
- package/dist/signals/parse.d.ts.map +1 -0
- package/dist/signals/parse.js +8 -0
- package/dist/signals/parse.js.map +1 -0
- package/dist/signals/types.d.ts +69 -0
- package/dist/signals/types.d.ts.map +1 -0
- package/dist/signals/types.js +10 -0
- package/dist/signals/types.js.map +1 -0
- package/dist/sources/get.d.ts +11 -0
- package/dist/sources/get.d.ts.map +1 -0
- package/dist/sources/get.js +67 -0
- package/dist/sources/get.js.map +1 -0
- package/dist/sources/get.test.d.ts +5 -0
- package/dist/sources/get.test.d.ts.map +1 -0
- package/dist/sources/get.test.js +132 -0
- package/dist/sources/get.test.js.map +1 -0
- package/dist/sources/internals.d.ts +16 -0
- package/dist/sources/internals.d.ts.map +1 -0
- package/dist/sources/internals.js +39 -0
- package/dist/sources/internals.js.map +1 -0
- package/dist/sources/list.d.ts +10 -0
- package/dist/sources/list.d.ts.map +1 -0
- package/dist/sources/list.js +76 -0
- package/dist/sources/list.js.map +1 -0
- package/dist/sources/list.test.d.ts +8 -0
- package/dist/sources/list.test.d.ts.map +1 -0
- package/dist/sources/list.test.js +198 -0
- package/dist/sources/list.test.js.map +1 -0
- package/dist/sources/parse.d.ts +18 -0
- package/dist/sources/parse.d.ts.map +1 -0
- package/dist/sources/parse.js +35 -0
- package/dist/sources/parse.js.map +1 -0
- package/dist/sources/types.d.ts +62 -0
- package/dist/sources/types.d.ts.map +1 -0
- package/dist/sources/types.js +8 -0
- package/dist/sources/types.js.map +1 -0
- package/dist/telemetry.d.ts +87 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +349 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/telemetry.test.d.ts +11 -0
- package/dist/telemetry.test.d.ts.map +1 -0
- package/dist/telemetry.test.js +309 -0
- package/dist/telemetry.test.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vault-client.d.ts +60 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +41 -0
- package/dist/vault-client.js.map +1 -1
- package/package.json +5 -3
- package/src/bin/sync-runner.ts +73 -0
- package/src/client-info.test.ts +214 -0
- package/src/client-info.ts +121 -0
- package/src/context.ts +10 -2
- package/src/entity-resolver.test.ts +307 -0
- package/src/entity-resolver.ts +173 -0
- package/src/index.ts +91 -0
- package/src/schemas/signal-types.test.ts +82 -0
- package/src/schemas/signal-types.ts +38 -0
- package/src/schemas/source-channels.test.ts +82 -0
- package/src/schemas/source-channels.ts +36 -0
- package/src/signals/get.test.ts +204 -0
- package/src/signals/get.ts +79 -0
- package/src/signals/internals.ts +46 -0
- package/src/signals/list.test.ts +283 -0
- package/src/signals/list.ts +92 -0
- package/src/signals/parse.ts +8 -0
- package/src/signals/types.ts +74 -0
- package/src/sources/get.test.ts +166 -0
- package/src/sources/get.ts +75 -0
- package/src/sources/internals.ts +46 -0
- package/src/sources/list.test.ts +247 -0
- package/src/sources/list.ts +95 -0
- package/src/sources/parse.ts +43 -0
- package/src/sources/types.ts +67 -0
- package/src/telemetry.test.ts +394 -0
- package/src/telemetry.ts +436 -0
- package/src/types.ts +23 -0
- package/src/vault-client.ts +91 -1
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the usage telemetry collector. Mirrors the Rust test matrix at
|
|
3
|
+
* `hq-workspace/apps/hq-sync/src-tauri/src/commands/telemetry.rs::tests` so a
|
|
4
|
+
* behavioral regression in either implementation surfaces in both suites.
|
|
5
|
+
*
|
|
6
|
+
* The vault client is stubbed structurally rather than via fetch-mock so the
|
|
7
|
+
* tests stay deterministic without spinning up wiremock — the production
|
|
8
|
+
* boundary is `TelemetryClientSurface`, not the HTTP layer.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as os from "os";
|
|
14
|
+
import * as path from "path";
|
|
15
|
+
import {
|
|
16
|
+
collectAndSendTelemetry,
|
|
17
|
+
sanitizeRow,
|
|
18
|
+
type CollectTelemetryOptions,
|
|
19
|
+
type TelemetryClientSurface,
|
|
20
|
+
} from "./telemetry.js";
|
|
21
|
+
import type {
|
|
22
|
+
TelemetryOptInResponse,
|
|
23
|
+
UsageBatch,
|
|
24
|
+
UsageIngestResult,
|
|
25
|
+
} from "./vault-client.js";
|
|
26
|
+
|
|
27
|
+
// ── Test stubs ────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
interface StubClient extends TelemetryClientSurface {
|
|
30
|
+
/** Every UsageBatch handed to `postUsage`, in arrival order. */
|
|
31
|
+
posts: UsageBatch[];
|
|
32
|
+
/** Number of times `getTelemetryOptIn` was called. */
|
|
33
|
+
optInCalls: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeClient(opts: {
|
|
37
|
+
optInResponse?: TelemetryOptInResponse | Error;
|
|
38
|
+
postResponse?: UsageIngestResult | Error;
|
|
39
|
+
} = {}): StubClient {
|
|
40
|
+
const optInResponse = opts.optInResponse ?? { enabled: true, updatedAt: null };
|
|
41
|
+
const postResponse = opts.postResponse ?? { ok: true, written: 0, skipped: [] };
|
|
42
|
+
|
|
43
|
+
const posts: UsageBatch[] = [];
|
|
44
|
+
const stub: StubClient = {
|
|
45
|
+
posts,
|
|
46
|
+
optInCalls: 0,
|
|
47
|
+
async getTelemetryOptIn() {
|
|
48
|
+
this.optInCalls++;
|
|
49
|
+
if (optInResponse instanceof Error) throw optInResponse;
|
|
50
|
+
return optInResponse;
|
|
51
|
+
},
|
|
52
|
+
async postUsage(batch: UsageBatch) {
|
|
53
|
+
posts.push(batch);
|
|
54
|
+
if (postResponse instanceof Error) throw postResponse;
|
|
55
|
+
return postResponse;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
return stub;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface TestEnv {
|
|
62
|
+
root: string;
|
|
63
|
+
claudeProjects: string;
|
|
64
|
+
cursorPath: string;
|
|
65
|
+
menubarPath: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function setupEnv(): TestEnv {
|
|
69
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-telemetry-test-"));
|
|
70
|
+
const claudeProjects = path.join(root, ".claude", "projects");
|
|
71
|
+
fs.mkdirSync(claudeProjects, { recursive: true });
|
|
72
|
+
fs.mkdirSync(path.join(root, ".hq"), { recursive: true });
|
|
73
|
+
return {
|
|
74
|
+
root,
|
|
75
|
+
claudeProjects,
|
|
76
|
+
cursorPath: path.join(root, ".hq", "telemetry-cursor.json"),
|
|
77
|
+
menubarPath: path.join(root, ".hq", "menubar.json"),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function teardownEnv(env: TestEnv): void {
|
|
82
|
+
fs.rmSync(env.root, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function writeJsonl(env: TestEnv, sub: string, name: string, lines: string[]): string {
|
|
86
|
+
const dir = path.join(env.claudeProjects, sub);
|
|
87
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
88
|
+
const p = path.join(dir, name);
|
|
89
|
+
fs.writeFileSync(p, lines.map((l) => `${l}\n`).join(""));
|
|
90
|
+
return p;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readCursor(env: TestEnv): { version: string; files: Record<string, { offset: number; mtime: number }> } {
|
|
94
|
+
return JSON.parse(fs.readFileSync(env.cursorPath, "utf-8"));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function makeOpts(env: TestEnv, client: TelemetryClientSurface): CollectTelemetryOptions {
|
|
98
|
+
return {
|
|
99
|
+
client,
|
|
100
|
+
machineId: "test-machine",
|
|
101
|
+
installerVersion: "test-version",
|
|
102
|
+
claudeProjectsRoot: env.claudeProjects,
|
|
103
|
+
cursorPath: env.cursorPath,
|
|
104
|
+
menubarPath: env.menubarPath,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Representative Claude Code session rows. Mirror the Rust fixtures exactly so
|
|
109
|
+
// drift between implementations is detectable.
|
|
110
|
+
const USER_ROW = JSON.stringify({
|
|
111
|
+
type: "user",
|
|
112
|
+
timestamp: "2026-04-25T10:00:00Z",
|
|
113
|
+
sessionId: "s1",
|
|
114
|
+
uuid: "u1",
|
|
115
|
+
parentUuid: null,
|
|
116
|
+
userType: "human",
|
|
117
|
+
entrypoint: "cli",
|
|
118
|
+
cwd: "/Users/x/proj",
|
|
119
|
+
gitBranch: "main",
|
|
120
|
+
version: "1.0",
|
|
121
|
+
message: { role: "user", content: [{ type: "text", text: "hello world" }], id: "msg_1" },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const ASST_ROW = JSON.stringify({
|
|
125
|
+
type: "assistant",
|
|
126
|
+
timestamp: "2026-04-25T10:00:01Z",
|
|
127
|
+
sessionId: "s1",
|
|
128
|
+
uuid: "u2",
|
|
129
|
+
parentUuid: "u1",
|
|
130
|
+
message: {
|
|
131
|
+
role: "assistant",
|
|
132
|
+
model: "claude-opus",
|
|
133
|
+
content: [{ type: "text", text: "hi" }, { type: "thinking", thinking: "hmm" }],
|
|
134
|
+
stop_sequence: "</end>",
|
|
135
|
+
usage: { input_tokens: 42, output_tokens: 7, cache_read_input_tokens: 100 },
|
|
136
|
+
id: "msg_2",
|
|
137
|
+
},
|
|
138
|
+
toolUseIds: ["t1"],
|
|
139
|
+
toolResults: [{ id: "t1", output: "x" }],
|
|
140
|
+
requestId: "req_1",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ── sanitizeRow ───────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
describe("sanitizeRow", () => {
|
|
146
|
+
it("returns null for non-objects", () => {
|
|
147
|
+
expect(sanitizeRow(null)).toBeNull();
|
|
148
|
+
expect(sanitizeRow("string")).toBeNull();
|
|
149
|
+
expect(sanitizeRow(42)).toBeNull();
|
|
150
|
+
expect(sanitizeRow([])).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("keeps only top-level allowlist fields", () => {
|
|
154
|
+
const row = JSON.parse(USER_ROW);
|
|
155
|
+
const out = sanitizeRow(row);
|
|
156
|
+
expect(out).not.toBeNull();
|
|
157
|
+
const keys = new Set(Object.keys(out!));
|
|
158
|
+
// KEEP: sessionId, timestamp, uuid, cwd, gitBranch, userType
|
|
159
|
+
for (const expected of ["sessionId", "timestamp", "uuid", "cwd", "gitBranch", "userType"]) {
|
|
160
|
+
expect(keys.has(expected)).toBe(true);
|
|
161
|
+
}
|
|
162
|
+
// Sensitive / unknown fields must be absent:
|
|
163
|
+
for (const removed of ["type", "parentUuid", "entrypoint", "version", "message", "content", "thinking", "text", "toolUseIds", "toolResults"]) {
|
|
164
|
+
expect(keys.has(removed)).toBe(false);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("promotes message.model and flattens message.usage to camelCase", () => {
|
|
169
|
+
const row = JSON.parse(ASST_ROW);
|
|
170
|
+
const out = sanitizeRow(row);
|
|
171
|
+
expect(out).not.toBeNull();
|
|
172
|
+
expect(out!.model).toBe("claude-opus");
|
|
173
|
+
expect(out!.inputTokens).toBe(42);
|
|
174
|
+
expect(out!.outputTokens).toBe(7);
|
|
175
|
+
expect(out!.cacheReadInputTokens).toBe(100);
|
|
176
|
+
// cacheCreationInputTokens not present in fixture; must be absent (not 0/null).
|
|
177
|
+
expect("cacheCreationInputTokens" in out!).toBe(false);
|
|
178
|
+
// `message` must not survive — its prompt/thinking/tools are exactly what
|
|
179
|
+
// we're trying to keep off the wire.
|
|
180
|
+
expect("message" in out!).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("rejects every field outside the server KEEP allowlist", () => {
|
|
184
|
+
const out = sanitizeRow(JSON.parse(ASST_ROW))!;
|
|
185
|
+
const allowed = new Set([
|
|
186
|
+
"sessionId", "timestamp", "uuid", "cwd", "gitBranch", "userType",
|
|
187
|
+
"model", "inputTokens", "outputTokens",
|
|
188
|
+
"cacheCreationInputTokens", "cacheReadInputTokens",
|
|
189
|
+
]);
|
|
190
|
+
for (const key of Object.keys(out)) {
|
|
191
|
+
expect(allowed.has(key), `field "${key}" must be in server allowlist`).toBe(true);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ── collectAndSendTelemetry ───────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
describe("collectAndSendTelemetry", () => {
|
|
199
|
+
let env: TestEnv;
|
|
200
|
+
beforeEach(() => { env = setupEnv(); });
|
|
201
|
+
afterEach(() => { teardownEnv(env); });
|
|
202
|
+
|
|
203
|
+
it("(a) opt-in=false → sends nothing and does not write cursor", async () => {
|
|
204
|
+
const client = makeClient({ optInResponse: { enabled: false, updatedAt: null } });
|
|
205
|
+
writeJsonl(env, "proj", "s.jsonl", [USER_ROW, ASST_ROW]);
|
|
206
|
+
|
|
207
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
208
|
+
|
|
209
|
+
expect(result.enabled).toBe(false);
|
|
210
|
+
expect(result.optInSource).toBe("server");
|
|
211
|
+
expect(client.posts).toHaveLength(0);
|
|
212
|
+
expect(fs.existsSync(env.cursorPath)).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("(b) missing cursor → reads all rows from offset 0, advances to EOF", async () => {
|
|
216
|
+
const client = makeClient();
|
|
217
|
+
const jsonlPath = writeJsonl(env, "proj", "s.jsonl", [USER_ROW, ASST_ROW]);
|
|
218
|
+
const fileSize = fs.statSync(jsonlPath).size;
|
|
219
|
+
|
|
220
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
221
|
+
|
|
222
|
+
expect(result.enabled).toBe(true);
|
|
223
|
+
expect(result.eventsSent).toBe(2);
|
|
224
|
+
expect(client.posts).toHaveLength(1);
|
|
225
|
+
expect(client.posts[0].events).toHaveLength(2);
|
|
226
|
+
expect(client.posts[0].machineId).toBe("test-machine");
|
|
227
|
+
expect(client.posts[0].installerVersion).toBe("test-version");
|
|
228
|
+
|
|
229
|
+
const cursor = readCursor(env);
|
|
230
|
+
expect(cursor.files[jsonlPath].offset).toBe(fileSize);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("(c) sanitizer strips REMOVE fields — only allowlisted fields reach wire", async () => {
|
|
234
|
+
const client = makeClient();
|
|
235
|
+
writeJsonl(env, "proj", "full.jsonl", [USER_ROW, ASST_ROW]);
|
|
236
|
+
|
|
237
|
+
await collectAndSendTelemetry(makeOpts(env, client));
|
|
238
|
+
expect(client.posts).toHaveLength(1);
|
|
239
|
+
|
|
240
|
+
const allowed = new Set([
|
|
241
|
+
"sessionId", "timestamp", "uuid", "cwd", "gitBranch", "userType",
|
|
242
|
+
"model", "inputTokens", "outputTokens",
|
|
243
|
+
"cacheCreationInputTokens", "cacheReadInputTokens",
|
|
244
|
+
]);
|
|
245
|
+
for (const ev of client.posts[0].events) {
|
|
246
|
+
for (const key of Object.keys(ev)) {
|
|
247
|
+
expect(allowed.has(key), `field "${key}" must be in allowlist`).toBe(true);
|
|
248
|
+
}
|
|
249
|
+
expect("message" in ev).toBe(false);
|
|
250
|
+
for (const banned of ["content", "thinking", "text", "toolUseIds", "toolResults"]) {
|
|
251
|
+
expect(banned in ev).toBe(false);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("(d) 1 MiB cap forces ≥2 batches when rows are large", async () => {
|
|
257
|
+
const client = makeClient();
|
|
258
|
+
// ~25 KiB per row × 50 rows ≈ 1.25 MiB → ≥2 batches.
|
|
259
|
+
const longBranch = "x".repeat(25_000);
|
|
260
|
+
const lines: string[] = [];
|
|
261
|
+
for (let i = 0; i < 50; i++) {
|
|
262
|
+
lines.push(JSON.stringify({
|
|
263
|
+
type: "user",
|
|
264
|
+
timestamp: `2026-04-25T10:00:${String(i % 60).padStart(2, "0")}Z`,
|
|
265
|
+
sessionId: "s1",
|
|
266
|
+
uuid: `u${i}`,
|
|
267
|
+
cwd: "/Users/x",
|
|
268
|
+
gitBranch: longBranch,
|
|
269
|
+
userType: "human",
|
|
270
|
+
message: { role: "user", content: [{ type: "text", text: "hi" }], id: "m" },
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
273
|
+
writeJsonl(env, "proj", "large.jsonl", lines);
|
|
274
|
+
|
|
275
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
276
|
+
|
|
277
|
+
expect(result.batchesSent).toBeGreaterThanOrEqual(2);
|
|
278
|
+
expect(result.eventsSent).toBe(50);
|
|
279
|
+
|
|
280
|
+
// Final batch must be < 1 MiB on the wire.
|
|
281
|
+
const lastWire = Buffer.byteLength(JSON.stringify(client.posts.at(-1)), "utf-8");
|
|
282
|
+
expect(lastWire).toBeLessThan(1_000_000);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("(e) POST 500 → cursor NOT advanced", async () => {
|
|
286
|
+
const client = makeClient({ postResponse: new Error("server 500") });
|
|
287
|
+
writeJsonl(env, "proj", "s.jsonl", [USER_ROW, ASST_ROW, USER_ROW]);
|
|
288
|
+
|
|
289
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
290
|
+
|
|
291
|
+
expect(result.eventsSent).toBe(0);
|
|
292
|
+
expect(result.batchesSent).toBe(0);
|
|
293
|
+
expect(client.posts).toHaveLength(1); // we still tried
|
|
294
|
+
|
|
295
|
+
// Cursor file is written (with rotation/empty resets), but the file's
|
|
296
|
+
// entry must NOT carry a non-zero offset.
|
|
297
|
+
if (fs.existsSync(env.cursorPath)) {
|
|
298
|
+
const cursor = readCursor(env);
|
|
299
|
+
const allOffsets = Object.values(cursor.files).map((e) => e.offset);
|
|
300
|
+
expect(allOffsets.every((off) => off === 0)).toBe(true);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("(f) atomic cursor write — no .tmp leftover", async () => {
|
|
305
|
+
const client = makeClient();
|
|
306
|
+
writeJsonl(env, "proj", "s.jsonl", [USER_ROW]);
|
|
307
|
+
|
|
308
|
+
await collectAndSendTelemetry(makeOpts(env, client));
|
|
309
|
+
|
|
310
|
+
expect(fs.existsSync(env.cursorPath)).toBe(true);
|
|
311
|
+
expect(fs.existsSync(`${env.cursorPath}.tmp`)).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("(g) new file between runs starts at offset 0", async () => {
|
|
315
|
+
const client = makeClient();
|
|
316
|
+
writeJsonl(env, "proj-a", "a.jsonl", [USER_ROW]);
|
|
317
|
+
|
|
318
|
+
await collectAndSendTelemetry(makeOpts(env, client));
|
|
319
|
+
const postsAfterRun1 = client.posts.length;
|
|
320
|
+
expect(postsAfterRun1).toBeGreaterThanOrEqual(1);
|
|
321
|
+
|
|
322
|
+
// Add a new fixture between runs.
|
|
323
|
+
const pathB = writeJsonl(env, "proj-b", "b.jsonl", [ASST_ROW]);
|
|
324
|
+
await collectAndSendTelemetry(makeOpts(env, client));
|
|
325
|
+
|
|
326
|
+
const cursor = readCursor(env);
|
|
327
|
+
const sizeB = fs.statSync(pathB).size;
|
|
328
|
+
expect(cursor.files[pathB].offset).toBe(sizeB);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("(h) truncated/rotated file resets cursor to 0", async () => {
|
|
332
|
+
const client = makeClient();
|
|
333
|
+
const pathA = writeJsonl(env, "proj", "a.jsonl", [USER_ROW, ASST_ROW, USER_ROW]);
|
|
334
|
+
const originalSize = fs.statSync(pathA).size;
|
|
335
|
+
|
|
336
|
+
await collectAndSendTelemetry(makeOpts(env, client));
|
|
337
|
+
let cursor = readCursor(env);
|
|
338
|
+
expect(cursor.files[pathA].offset).toBe(originalSize);
|
|
339
|
+
|
|
340
|
+
// Truncate to 0 → next run must detect rotation and reset.
|
|
341
|
+
fs.truncateSync(pathA, 0);
|
|
342
|
+
|
|
343
|
+
await collectAndSendTelemetry(makeOpts(env, client));
|
|
344
|
+
|
|
345
|
+
cursor = readCursor(env);
|
|
346
|
+
expect(cursor.files[pathA].offset).toBe(0);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("(i) opt-in GET fails → falls back to menubar.json (telemetryEnabled=true → runs)", async () => {
|
|
350
|
+
const client = makeClient({ optInResponse: new Error("server 500") });
|
|
351
|
+
fs.writeFileSync(env.menubarPath, JSON.stringify({ telemetryEnabled: true }));
|
|
352
|
+
writeJsonl(env, "proj", "s.jsonl", [USER_ROW]);
|
|
353
|
+
|
|
354
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
355
|
+
|
|
356
|
+
expect(result.enabled).toBe(true);
|
|
357
|
+
expect(result.optInSource).toBe("menubar-fallback");
|
|
358
|
+
expect(client.posts).toHaveLength(1);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("(i') opt-in GET fails AND menubar.json says false → skips", async () => {
|
|
362
|
+
const client = makeClient({ optInResponse: new Error("server 500") });
|
|
363
|
+
fs.writeFileSync(env.menubarPath, JSON.stringify({ telemetryEnabled: false }));
|
|
364
|
+
writeJsonl(env, "proj", "s.jsonl", [USER_ROW]);
|
|
365
|
+
|
|
366
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
367
|
+
|
|
368
|
+
expect(result.enabled).toBe(false);
|
|
369
|
+
expect(result.optInSource).toBe("menubar-fallback");
|
|
370
|
+
expect(client.posts).toHaveLength(0);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("(j) missing claude/projects directory → no crash, no posts", async () => {
|
|
374
|
+
fs.rmSync(env.claudeProjects, { recursive: true });
|
|
375
|
+
const client = makeClient();
|
|
376
|
+
|
|
377
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
378
|
+
|
|
379
|
+
expect(result.enabled).toBe(true);
|
|
380
|
+
expect(result.filesScanned).toBe(0);
|
|
381
|
+
expect(client.posts).toHaveLength(0);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("(k) second run with no new content sends nothing", async () => {
|
|
385
|
+
const client = makeClient();
|
|
386
|
+
writeJsonl(env, "proj", "s.jsonl", [USER_ROW, ASST_ROW]);
|
|
387
|
+
|
|
388
|
+
await collectAndSendTelemetry(makeOpts(env, client));
|
|
389
|
+
expect(client.posts).toHaveLength(1);
|
|
390
|
+
|
|
391
|
+
await collectAndSendTelemetry(makeOpts(env, client));
|
|
392
|
+
expect(client.posts).toHaveLength(1); // no second POST
|
|
393
|
+
});
|
|
394
|
+
});
|