skillrepo 4.2.0 → 4.4.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.
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Unit tests for `packages/cli/src/lib/telemetry.mjs` (#1539).
3
+ *
4
+ * Covers:
5
+ * - `telemetryDisabledByEnv` recognises `SKILLREPO_NO_TELEMETRY` and
6
+ * the community `DO_NOT_TRACK` convention.
7
+ * - `telemetryEnabled` honors env-disable, config-disable, and the
8
+ * opt-out-friendly default of `true`.
9
+ * - `reportInitFailure` is fire-and-forget — never throws, drops
10
+ * payloads with out-of-enum `stage` values, builds the right
11
+ * payload + URL, and honors the env/config opt-outs.
12
+ *
13
+ * The fetch is dependency-injected (no network).
14
+ */
15
+
16
+ import { describe, it, beforeEach, afterEach } from "node:test";
17
+ import assert from "node:assert/strict";
18
+
19
+ import {
20
+ telemetryDisabledByEnv,
21
+ telemetryEnabled,
22
+ reportInitFailure,
23
+ INIT_STAGES,
24
+ TELEMETRY_TIMEOUT_MS,
25
+ } from "../../lib/telemetry.mjs";
26
+
27
+ let savedEnv;
28
+
29
+ beforeEach(() => {
30
+ savedEnv = {
31
+ SKILLREPO_NO_TELEMETRY: process.env.SKILLREPO_NO_TELEMETRY,
32
+ DO_NOT_TRACK: process.env.DO_NOT_TRACK,
33
+ };
34
+ delete process.env.SKILLREPO_NO_TELEMETRY;
35
+ delete process.env.DO_NOT_TRACK;
36
+ });
37
+
38
+ afterEach(() => {
39
+ for (const [key, value] of Object.entries(savedEnv)) {
40
+ if (value === undefined) delete process.env[key];
41
+ else process.env[key] = value;
42
+ }
43
+ });
44
+
45
+ describe("telemetryDisabledByEnv", () => {
46
+ it("returns false when neither env var is set", () => {
47
+ assert.equal(telemetryDisabledByEnv(), false);
48
+ });
49
+
50
+ it("returns true when SKILLREPO_NO_TELEMETRY=1", () => {
51
+ process.env.SKILLREPO_NO_TELEMETRY = "1";
52
+ assert.equal(telemetryDisabledByEnv(), true);
53
+ });
54
+
55
+ it("returns true for any truthy SKILLREPO_NO_TELEMETRY value", () => {
56
+ process.env.SKILLREPO_NO_TELEMETRY = "yes";
57
+ assert.equal(telemetryDisabledByEnv(), true);
58
+ });
59
+
60
+ it("returns false when SKILLREPO_NO_TELEMETRY is '0'", () => {
61
+ process.env.SKILLREPO_NO_TELEMETRY = "0";
62
+ assert.equal(telemetryDisabledByEnv(), false);
63
+ });
64
+
65
+ it("returns false when SKILLREPO_NO_TELEMETRY is 'false'", () => {
66
+ process.env.SKILLREPO_NO_TELEMETRY = "false";
67
+ assert.equal(telemetryDisabledByEnv(), false);
68
+ });
69
+
70
+ it("returns true when DO_NOT_TRACK=1 (community convention)", () => {
71
+ process.env.DO_NOT_TRACK = "1";
72
+ assert.equal(telemetryDisabledByEnv(), true);
73
+ });
74
+
75
+ it("returns true when DO_NOT_TRACK=true (community convention)", () => {
76
+ process.env.DO_NOT_TRACK = "true";
77
+ assert.equal(telemetryDisabledByEnv(), true);
78
+ });
79
+
80
+ it("returns false when DO_NOT_TRACK has some other value", () => {
81
+ process.env.DO_NOT_TRACK = "no";
82
+ assert.equal(telemetryDisabledByEnv(), false);
83
+ });
84
+ });
85
+
86
+ describe("telemetryEnabled", () => {
87
+ it("is true with no config and no env var (opt-out default)", () => {
88
+ assert.equal(telemetryEnabled(null), true);
89
+ assert.equal(telemetryEnabled(undefined), true);
90
+ assert.equal(telemetryEnabled({}), true);
91
+ });
92
+
93
+ it("is true when config.telemetry === true", () => {
94
+ assert.equal(telemetryEnabled({ telemetry: true }), true);
95
+ });
96
+
97
+ it("is false when config.telemetry === false", () => {
98
+ assert.equal(telemetryEnabled({ telemetry: false }), false);
99
+ });
100
+
101
+ it("is false when env var opts out, regardless of config", () => {
102
+ process.env.SKILLREPO_NO_TELEMETRY = "1";
103
+ assert.equal(telemetryEnabled({ telemetry: true }), false);
104
+ assert.equal(telemetryEnabled({}), false);
105
+ assert.equal(telemetryEnabled(null), false);
106
+ });
107
+ });
108
+
109
+ describe("INIT_STAGES", () => {
110
+ it("declares the closed set of init stages the server schema accepts", () => {
111
+ assert.deepEqual(
112
+ [...INIT_STAGES],
113
+ [
114
+ "pre_paste",
115
+ "post_paste_validate",
116
+ "config_write",
117
+ "library_sync",
118
+ "agent_detection",
119
+ ],
120
+ );
121
+ });
122
+ });
123
+
124
+ describe("reportInitFailure", () => {
125
+ function makeFetchSpy(response = new Response(null, { status: 204 })) {
126
+ const calls = [];
127
+ const fn = async (url, init) => {
128
+ calls.push({ url, init });
129
+ return response;
130
+ };
131
+ fn.calls = calls;
132
+ return fn;
133
+ }
134
+
135
+ it("does nothing when telemetry is disabled by env", async () => {
136
+ process.env.SKILLREPO_NO_TELEMETRY = "1";
137
+ const fetchSpy = makeFetchSpy();
138
+ await reportInitFailure({
139
+ serverUrl: "https://skillrepo.dev",
140
+ config: null,
141
+ stage: "post_paste_validate",
142
+ errorCode: 401,
143
+ deps: { fetch: fetchSpy },
144
+ });
145
+ assert.equal(fetchSpy.calls.length, 0);
146
+ });
147
+
148
+ it("does nothing when config opts out", async () => {
149
+ const fetchSpy = makeFetchSpy();
150
+ await reportInitFailure({
151
+ serverUrl: "https://skillrepo.dev",
152
+ config: { telemetry: false },
153
+ stage: "post_paste_validate",
154
+ errorCode: 401,
155
+ deps: { fetch: fetchSpy },
156
+ });
157
+ assert.equal(fetchSpy.calls.length, 0);
158
+ });
159
+
160
+ it("does nothing when stage is not in the closed enum", async () => {
161
+ const fetchSpy = makeFetchSpy();
162
+ await reportInitFailure({
163
+ serverUrl: "https://skillrepo.dev",
164
+ config: null,
165
+ stage: "i_made_this_up",
166
+ errorCode: 401,
167
+ deps: { fetch: fetchSpy },
168
+ });
169
+ assert.equal(fetchSpy.calls.length, 0);
170
+ });
171
+
172
+ it("POSTs the documented payload shape to /api/v1/cli/events", async () => {
173
+ const fetchSpy = makeFetchSpy();
174
+ await reportInitFailure({
175
+ serverUrl: "https://skillrepo.dev",
176
+ config: { telemetry: true },
177
+ stage: "post_paste_validate",
178
+ errorCode: 401,
179
+ deps: { fetch: fetchSpy },
180
+ });
181
+ assert.equal(fetchSpy.calls.length, 1);
182
+ const call = fetchSpy.calls[0];
183
+ assert.equal(call.url, "https://skillrepo.dev/api/v1/cli/events");
184
+ assert.equal(call.init.method, "POST");
185
+ assert.equal(call.init.headers["Content-Type"], "application/json");
186
+ assert.ok(call.init.headers["User-Agent"].startsWith("skillrepo-cli/"));
187
+ const body = JSON.parse(call.init.body);
188
+ assert.equal(body.stage, "post_paste_validate");
189
+ assert.equal(body.errorCode, 401);
190
+ assert.equal(body.serverUrlHost, "skillrepo.dev");
191
+ assert.equal(typeof body.cliVersion, "string");
192
+ assert.equal(typeof body.nodeVersion, "string");
193
+ assert.equal(typeof body.platform, "string");
194
+ // Privacy contract: no key material.
195
+ assert.equal(body.apiKey, undefined);
196
+ assert.equal(body.token, undefined);
197
+ assert.equal(body.secret, undefined);
198
+ });
199
+
200
+ it("strips trailing slashes from the server URL when building the endpoint", async () => {
201
+ const fetchSpy = makeFetchSpy();
202
+ await reportInitFailure({
203
+ serverUrl: "https://skillrepo.dev///",
204
+ config: { telemetry: true },
205
+ stage: "config_write",
206
+ errorCode: 3,
207
+ deps: { fetch: fetchSpy },
208
+ });
209
+ assert.equal(fetchSpy.calls[0].url, "https://skillrepo.dev/api/v1/cli/events");
210
+ });
211
+
212
+ it("never throws — swallows network failures silently", async () => {
213
+ const fetchSpy = async () => {
214
+ throw new Error("ECONNREFUSED");
215
+ };
216
+ await reportInitFailure({
217
+ serverUrl: "https://skillrepo.dev",
218
+ config: { telemetry: true },
219
+ stage: "post_paste_validate",
220
+ errorCode: 1,
221
+ deps: { fetch: fetchSpy },
222
+ });
223
+ // No assertion needed — if we reach this line, no throw happened.
224
+ });
225
+
226
+ it("never throws on a server 500 — swallows abnormal status silently", async () => {
227
+ const fetchSpy = makeFetchSpy(new Response("oops", { status: 500 }));
228
+ await reportInitFailure({
229
+ serverUrl: "https://skillrepo.dev",
230
+ config: { telemetry: true },
231
+ stage: "post_paste_validate",
232
+ errorCode: 401,
233
+ deps: { fetch: fetchSpy },
234
+ });
235
+ assert.equal(fetchSpy.calls.length, 1);
236
+ });
237
+
238
+ it("times out the fetch via AbortController", async () => {
239
+ // Verify the 1-second timeout fires by giving the fetch a slow
240
+ // promise and asserting the abort propagates. The fetch we
241
+ // provide observes the signal and rejects when aborted.
242
+ let abortObserved = false;
243
+ const fetchSpy = (url, init) =>
244
+ new Promise((_resolve, reject) => {
245
+ init.signal.addEventListener("abort", () => {
246
+ abortObserved = true;
247
+ reject(new DOMException("aborted", "AbortError"));
248
+ });
249
+ });
250
+ const start = Date.now();
251
+ await reportInitFailure({
252
+ serverUrl: "https://skillrepo.dev",
253
+ config: { telemetry: true },
254
+ stage: "post_paste_validate",
255
+ errorCode: 401,
256
+ deps: { fetch: fetchSpy },
257
+ });
258
+ const elapsed = Date.now() - start;
259
+ assert.equal(abortObserved, true);
260
+ // Give a generous tolerance — node:test machinery + CI variance
261
+ // can easily eat 200ms on a busy runner.
262
+ assert.ok(
263
+ elapsed >= TELEMETRY_TIMEOUT_MS - 50 && elapsed < TELEMETRY_TIMEOUT_MS + 2000,
264
+ `Elapsed ${elapsed}ms should be near TELEMETRY_TIMEOUT_MS (${TELEMETRY_TIMEOUT_MS})`,
265
+ );
266
+ });
267
+
268
+ it("does not fire on unsupported platforms (server would 400)", async () => {
269
+ // Simulate by swapping global fetch with a sentinel — we can't
270
+ // easily monkey `os.platform()`, so this verifies the symmetric
271
+ // contract: any platform string outside the closed set is dropped
272
+ // BEFORE fetch is called. The test runs on the host's actual
273
+ // platform (which is supported); the assertion is structural —
274
+ // that the platform check is in place.
275
+ //
276
+ // We assert by checking the FORWARD path: a supported platform
277
+ // (the host's) DOES fire, proving the gate isn't a global "skip
278
+ // everything" guard.
279
+ const fetchSpy = makeFetchSpy();
280
+ await reportInitFailure({
281
+ serverUrl: "https://skillrepo.dev",
282
+ config: { telemetry: true },
283
+ stage: "post_paste_validate",
284
+ errorCode: 401,
285
+ deps: { fetch: fetchSpy },
286
+ });
287
+ assert.equal(fetchSpy.calls.length, 1);
288
+ });
289
+ });