@plune-ai/cli 0.6.0 → 0.8.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,467 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/reporter-core/index.ts
31
+ var reporter_core_exports = {};
32
+ __export(reporter_core_exports, {
33
+ DEFAULT_FALLBACK_PATH: () => DEFAULT_FALLBACK_PATH,
34
+ RESULT_KEY_MAX: () => RESULT_KEY_MAX,
35
+ UNSUPPORTED_VARS: () => UNSUPPORTED_VARS,
36
+ appendBatch: () => appendBatch,
37
+ createClient: () => createClient,
38
+ readEnv: () => readEnv,
39
+ resultKey: () => resultKey,
40
+ startRun: () => startRun
41
+ });
42
+ module.exports = __toCommonJS(reporter_core_exports);
43
+
44
+ // src/cli/api-url.ts
45
+ var DEFAULT_API_URL = "https://beta-api.plune.ai";
46
+ function resolveApiUrl(explicit) {
47
+ const raw = explicit ?? process.env["PLUNE_API_URL"] ?? DEFAULT_API_URL;
48
+ return raw.replace(/\/+$/, "");
49
+ }
50
+
51
+ // src/cli/credentials.ts
52
+ var import_node_fs = require("fs");
53
+ var import_node_os = require("os");
54
+ var import_node_path = require("path");
55
+ function credentialsFile() {
56
+ const base = process.env["XDG_CONFIG_HOME"]?.trim() || (0, import_node_path.join)((0, import_node_os.homedir)(), ".config");
57
+ return (0, import_node_path.join)(base, "plune", "credentials.json");
58
+ }
59
+ function loadToken() {
60
+ try {
61
+ const parsed = JSON.parse((0, import_node_fs.readFileSync)(credentialsFile(), "utf-8"));
62
+ return typeof parsed.token === "string" && parsed.token.length > 0 ? parsed.token : null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ // src/reporter-core/client.ts
69
+ var RETRY_DELAYS_MS = [300, 900];
70
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
71
+ async function detailOf(res) {
72
+ try {
73
+ const body = await res.json();
74
+ return typeof body.error === "string" ? body.error : "";
75
+ } catch {
76
+ return "";
77
+ }
78
+ }
79
+ function retryAfterMs(res) {
80
+ const raw = res.headers.get("retry-after");
81
+ if (raw === null) return null;
82
+ const seconds = Number(raw.trim());
83
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : null;
84
+ }
85
+ function classify(status) {
86
+ if (status === 401 || status === 403) return "auth";
87
+ if (status === 409) return "conflict";
88
+ if (status === 400) return "invalid";
89
+ if (status === 429 || status >= 500) return "unavailable";
90
+ return "other";
91
+ }
92
+ function createClient(opts) {
93
+ const doFetch = opts.fetchImpl ?? fetch;
94
+ const wait = opts.wait ?? sleep;
95
+ const base = opts.apiUrl.replace(/\/+$/, "");
96
+ const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
97
+ async function post(path2, body) {
98
+ let last = {
99
+ kind: "unavailable",
100
+ status: null,
101
+ detail: "no attempt was made"
102
+ };
103
+ for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
104
+ let res;
105
+ try {
106
+ res = await doFetch(`${base}${path2}`, {
107
+ method: "POST",
108
+ headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
109
+ body: JSON.stringify(body)
110
+ });
111
+ } catch (err) {
112
+ last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
113
+ if (attempt < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[attempt]);
114
+ continue;
115
+ }
116
+ if (res.ok) return { ok: true, status: res.status, body: await res.json() };
117
+ const detail = scrub(await detailOf(res));
118
+ const kind = classify(res.status);
119
+ last = { kind, status: res.status, detail };
120
+ if (kind !== "unavailable") break;
121
+ if (attempt < RETRY_DELAYS_MS.length) {
122
+ await wait(retryAfterMs(res) ?? RETRY_DELAYS_MS[attempt]);
123
+ }
124
+ }
125
+ return { ok: false, ...last };
126
+ }
127
+ return { post };
128
+ }
129
+
130
+ // src/reporter-core/env.ts
131
+ var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
132
+ function value(env, name) {
133
+ const raw = env[name]?.trim();
134
+ return raw === void 0 || raw === "" ? void 0 : raw;
135
+ }
136
+ function flag(env, name) {
137
+ const raw = value(env, name)?.toLowerCase();
138
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
139
+ }
140
+ function count(env, name) {
141
+ const raw = value(env, name);
142
+ if (raw === void 0) return void 0;
143
+ const n = Number(raw);
144
+ return Number.isInteger(n) && n > 0 ? n : void 0;
145
+ }
146
+ function list(env, name) {
147
+ const raw = value(env, name);
148
+ if (raw === void 0) return void 0;
149
+ const items = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
150
+ return items.length === 0 ? void 0 : items;
151
+ }
152
+ function readEnv(env = process.env) {
153
+ const externalKey = value(env, "PLUNE_RUN");
154
+ const token = value(env, "PLUNE_TOKEN");
155
+ const apiUrl = value(env, "PLUNE_API_URL");
156
+ const fallbackPath = value(env, "PLUNE_FALLBACK");
157
+ const batchSize = count(env, "PLUNE_BATCH_SIZE");
158
+ const title = value(env, "PLUNE_RUN_TITLE");
159
+ const environment = value(env, "PLUNE_ENV");
160
+ const labels = list(env, "PLUNE_LABELS");
161
+ const offerDiscovered = flag(env, "PLUNE_CREATE");
162
+ return {
163
+ config: {
164
+ ...externalKey !== void 0 ? { externalKey } : {},
165
+ ...token !== void 0 ? { token } : {},
166
+ ...apiUrl !== void 0 ? { apiUrl } : {},
167
+ ...fallbackPath !== void 0 ? { fallbackPath } : {},
168
+ ...batchSize !== void 0 ? { batchSize } : {},
169
+ ...title !== void 0 ? { title } : {},
170
+ ...environment !== void 0 ? { environment } : {},
171
+ ...labels !== void 0 ? { labels } : {},
172
+ // Only when set: `false` here would outrank a config file that asked for it, and an unset
173
+ // variable is not an instruction.
174
+ ...offerDiscovered ? { offerDiscovered } : {}
175
+ },
176
+ // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
177
+ // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
178
+ // way this process must not be the one to call it finished.
179
+ keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED"),
180
+ ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0)
181
+ };
182
+ }
183
+
184
+ // src/reporter-core/fallback.ts
185
+ var fs = __toESM(require("fs"), 1);
186
+ var path = __toESM(require("path"), 1);
187
+ var DEFAULT_FALLBACK_PATH = path.join(".plune", "pending-results.jsonl");
188
+ function appendBatch(file, batch) {
189
+ fs.mkdirSync(path.dirname(file), { recursive: true });
190
+ const line = {
191
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
192
+ runId: batch.runId,
193
+ externalKey: batch.externalKey,
194
+ results: batch.results
195
+ };
196
+ fs.appendFileSync(file, `${JSON.stringify(line)}
197
+ `, "utf8");
198
+ }
199
+
200
+ // src/reporter-core/session.ts
201
+ var RESOLVE_CHUNK = 500;
202
+ var BATCH_MAX = 500;
203
+ var DISCOVER_MAX = 500;
204
+ var BATCH_DEFAULT = 100;
205
+ var EXPECTED_MAX = 1e4;
206
+ function chunk(items, size) {
207
+ const out = [];
208
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
209
+ return out;
210
+ }
211
+ function warnIfDropped(sent, run, log) {
212
+ const names = Object.keys(sent);
213
+ if (names.length === 0) return;
214
+ if (names.some((name) => run[name] != null)) return;
215
+ log(
216
+ `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
217
+ );
218
+ }
219
+ async function startRun(passed, expected) {
220
+ const env = readEnv();
221
+ const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
222
+ const cfg = { ...env.config, ...stated };
223
+ const log = cfg.log ?? ((line) => void process.stderr.write(`${line}
224
+ `));
225
+ const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
226
+ const externalKey = cfg.externalKey ?? null;
227
+ const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
228
+ const offerDiscovered = cfg.offerDiscovered ?? false;
229
+ const token = cfg.token ?? loadToken() ?? "";
230
+ const client = createClient({
231
+ apiUrl: resolveApiUrl(cfg.apiUrl),
232
+ token,
233
+ ...cfg.fetchImpl !== void 0 ? { fetchImpl: cfg.fetchImpl } : {}
234
+ });
235
+ const stats = {
236
+ accepted: 0,
237
+ duplicate: 0,
238
+ conflict: 0,
239
+ rejected: 0,
240
+ unresolved: 0,
241
+ offered: 0,
242
+ deferred: 0
243
+ };
244
+ const resolved = /* @__PURE__ */ new Map();
245
+ let buffer = [];
246
+ const discoveries = /* @__PURE__ */ new Map();
247
+ let runId = null;
248
+ let joined = false;
249
+ let offline = token === "";
250
+ let done = false;
251
+ if (offline) {
252
+ log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
253
+ }
254
+ if (env.ignored.length > 0) {
255
+ log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
256
+ }
257
+ function defer(results) {
258
+ appendBatch(fallbackPath, { runId, externalKey, results });
259
+ stats.deferred += results.length;
260
+ }
261
+ async function resolveKeys(values) {
262
+ const unknown = values.filter((k) => !resolved.has(k.value));
263
+ if (unknown.length === 0 || offline) return;
264
+ for (const part of chunk(unknown, RESOLVE_CHUNK)) {
265
+ const out = await client.post(
266
+ "/v1/test-cases/resolve",
267
+ { keys: part }
268
+ );
269
+ if (!out.ok) {
270
+ log(`plune: could not look up test cases (${out.detail || out.kind}).`);
271
+ return;
272
+ }
273
+ for (const row of out.body.results) resolved.set(row.key.value, row.testCaseId);
274
+ }
275
+ }
276
+ if (!offline) {
277
+ if (expected !== void 0) await resolveKeys(expected.flat());
278
+ const declared = expected?.slice(0, EXPECTED_MAX) ?? [];
279
+ const configuration = declared.length === 0 ? void 0 : {
280
+ expected: declared.flatMap((keys) => {
281
+ const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
282
+ if (typeof hit === "string") return [{ testCaseId: hit }];
283
+ return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
284
+ })
285
+ };
286
+ if (expected !== void 0 && expected.length > EXPECTED_MAX) {
287
+ log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
288
+ }
289
+ const described = {
290
+ ...cfg.title !== void 0 ? { title: cfg.title } : {},
291
+ ...cfg.environment !== void 0 ? { environment: cfg.environment } : {},
292
+ ...cfg.labels !== void 0 ? { labels: cfg.labels } : {}
293
+ };
294
+ const start = await client.post("/v1/runs", {
295
+ schemaVersion: 2,
296
+ kind: cfg.kind ?? "automated",
297
+ ...externalKey !== null ? { externalKey } : {},
298
+ ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
299
+ ...configuration !== void 0 ? { configuration } : {},
300
+ ...described
301
+ });
302
+ if (start.ok) {
303
+ runId = start.body.run.id;
304
+ joined = start.status === 200;
305
+ warnIfDropped(described, start.body.run, log);
306
+ } else {
307
+ log(`plune: could not start the run (${start.detail || start.kind}). Results will be written to ${fallbackPath}.`);
308
+ }
309
+ }
310
+ function matchFor(result) {
311
+ if (result.testCaseId !== void 0) return { kind: "found", testCaseId: result.testCaseId };
312
+ let unanswered = false;
313
+ for (const key of result.keys) {
314
+ const hit = resolved.get(key.value);
315
+ if (typeof hit === "string") return { kind: "found", testCaseId: hit };
316
+ if (hit === void 0) unanswered = true;
317
+ }
318
+ return unanswered ? { kind: "unanswered" } : { kind: "unmatched" };
319
+ }
320
+ function remember(result) {
321
+ if (!offerDiscovered) return;
322
+ if (result.title === void 0 || result.specRef === void 0) return;
323
+ if (result.keys.length === 0) return;
324
+ discoveries.set(result.keys[0].value, {
325
+ keys: result.keys,
326
+ title: result.title,
327
+ source: result.source,
328
+ specRef: result.specRef,
329
+ rawStatus: result.rawStatus
330
+ });
331
+ }
332
+ async function offer() {
333
+ if (discoveries.size === 0 || offline || runId === null) return;
334
+ for (const batch of chunk([...discoveries.values()], DISCOVER_MAX)) {
335
+ const out = await client.post("/v1/review-items/discovered", {
336
+ discovered: batch,
337
+ runId
338
+ });
339
+ if (!out.ok) {
340
+ log(`plune: could not offer ${batch.length} unknown tests for review (${out.detail || out.kind}).`);
341
+ return;
342
+ }
343
+ stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
344
+ }
345
+ }
346
+ async function flush() {
347
+ if (buffer.length === 0) return;
348
+ const pending = buffer;
349
+ buffer = [];
350
+ if (offline || runId === null) {
351
+ defer(pending);
352
+ return;
353
+ }
354
+ await resolveKeys(pending.flatMap((p) => p.keys));
355
+ const submissions = [];
356
+ const unanswered = [];
357
+ for (const result of pending) {
358
+ const match = matchFor(result);
359
+ if (match.kind === "found") {
360
+ const { keys: _keys, ...rest } = result;
361
+ submissions.push({ ...rest, testCaseId: match.testCaseId });
362
+ } else if (match.kind === "unmatched") {
363
+ stats.unresolved += 1;
364
+ remember(result);
365
+ } else {
366
+ unanswered.push(result);
367
+ }
368
+ }
369
+ if (unanswered.length > 0) defer(unanswered);
370
+ if (submissions.length === 0) return;
371
+ const out = await client.post(
372
+ `/v1/runs/${runId}/results`,
373
+ { results: submissions }
374
+ );
375
+ if (out.ok) {
376
+ stats.accepted += out.body.counts.accepted ?? 0;
377
+ stats.duplicate += out.body.counts.duplicate ?? 0;
378
+ stats.conflict += out.body.counts.conflict ?? 0;
379
+ stats.rejected += out.body.counts.rejected ?? 0;
380
+ return;
381
+ }
382
+ if (out.kind === "auth") {
383
+ log('plune: the API token was refused \u2014 run "plune login" with a fresh one.');
384
+ offline = true;
385
+ } else if (out.kind === "conflict") {
386
+ log(`plune: this run is closed \u2014 ${out.detail}`);
387
+ } else {
388
+ log(`plune: could not send results (${out.detail || out.kind}).`);
389
+ }
390
+ defer(submissions);
391
+ }
392
+ function summarise() {
393
+ const parts = [`${stats.accepted} accepted`];
394
+ if (stats.duplicate > 0) parts.push(`${stats.duplicate} already reported`);
395
+ if (stats.conflict > 0) parts.push(`${stats.conflict} conflicting`);
396
+ if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
397
+ if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
398
+ if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
399
+ if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
400
+ log(`plune: ${parts.join(" \xB7 ")}`);
401
+ }
402
+ return {
403
+ get runId() {
404
+ return runId;
405
+ },
406
+ get joined() {
407
+ return joined;
408
+ },
409
+ get stats() {
410
+ return stats;
411
+ },
412
+ async add(result) {
413
+ buffer.push(result);
414
+ if (buffer.length >= batchSize) await flush();
415
+ },
416
+ flush,
417
+ async finish(reason) {
418
+ if (done) return;
419
+ done = true;
420
+ await flush();
421
+ await offer();
422
+ if (runId !== null && !offline) {
423
+ const out = await client.post(`/v1/runs/${runId}/events`, {
424
+ event: "finish",
425
+ ...reason !== void 0 ? { reason } : {}
426
+ });
427
+ if (!out.ok) log(`plune: could not close run ${runId} (${out.detail || out.kind}).`);
428
+ }
429
+ summarise();
430
+ },
431
+ async leaveOpen() {
432
+ if (done) return;
433
+ done = true;
434
+ await flush();
435
+ await offer();
436
+ if (runId !== null) {
437
+ log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
438
+ }
439
+ summarise();
440
+ }
441
+ };
442
+ }
443
+
444
+ // src/util/hash.ts
445
+ var import_node_crypto = require("crypto");
446
+ function sha256(input) {
447
+ return (0, import_node_crypto.createHash)("sha256").update(input, "utf8").digest("hex");
448
+ }
449
+
450
+ // src/reporter-core/result-key.ts
451
+ var RESULT_KEY_MAX = 200;
452
+ var HASH_CHARS = 32;
453
+ function resultKey(testId, retry) {
454
+ const plain = `${testId}#${retry}`;
455
+ return plain.length <= RESULT_KEY_MAX ? plain : `${sha256(testId).slice(0, HASH_CHARS)}#${retry}`;
456
+ }
457
+ // Annotate the CommonJS export names for ESM import in node:
458
+ 0 && (module.exports = {
459
+ DEFAULT_FALLBACK_PATH,
460
+ RESULT_KEY_MAX,
461
+ UNSUPPORTED_VARS,
462
+ appendBatch,
463
+ createClient,
464
+ readEnv,
465
+ resultKey,
466
+ startRun
467
+ });