@plune-ai/cli 0.9.1 → 0.11.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/README.md +13 -0
- package/dist/cli.cjs +134 -46
- package/dist/reporter-core.cjs +74 -17
- package/dist/reporter-core.d.cts +18 -1
- package/dist/reporter-core.d.ts +18 -1
- package/dist/reporter-core.js +74 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -104,6 +104,11 @@ provider API key is read from the environment based on `provider.type`:
|
|
|
104
104
|
| `plune logout` | Remove the saved token. |
|
|
105
105
|
| `plune sync` | Upload the latest local run to the platform. Flags: `--file <path>` to send a specific run JSON. |
|
|
106
106
|
| `plune run import <file>` | Turn a **JUnit XML** or **Playwright JSON** report into a run in Plune. Needs no provider key — nothing is generated. Flags: `--format junit\|playwright-json` (detected from the file when omitted), `--key <externalKey>` to land several reports in one run (with `PLUNE_SHARED_RUN=1` — see below), `--create` to offer unmatched tests to the review queue. |
|
|
107
|
+
| `plune run start` | Open a platform run — or join the one already carrying `--key` — and print its id. Flags: `--key <externalKey>` (generated when absent), `--json` for one machine-readable line. |
|
|
108
|
+
| `plune run finish <id>` | Close a platform run. This is what the reporter tells you to do for a run it had to leave open. Flags: `--terminate` to record it as cut short, `--reason <text>`. |
|
|
109
|
+
| `plune run exec -- <command>` | Open a run, run the command inside it, close the run — and exit with whatever the command returned. Sets `PLUNE_SHARED_RUN` for you, so anything reporting inside joins that run. Flags: `--key <externalKey>`. |
|
|
110
|
+
| `plune run delete <id>` | Delete a run and everything it produced — its results and the review-queue entries it raised. Approved test cases and the audit log stay. **Recoverable for six months** (ask Plune to put it back), then gone for good. No prompt: it is your data, and this command belongs in scripts. |
|
|
111
|
+
| `plune run report` | Replay `.plune/pending-results.jsonl` — send what the reporter could not. Flags: `--file <path>`. |
|
|
107
112
|
| `plune ingest [dir]` | Record a [Cairn](https://github.com/plune-ai/cairn) run in Plune. Omit `[dir]` for the newest run under `./runs`, or name the directory holding `report.json`. Generated cases arrive as **review proposals** — nothing is created until a person approves it. |
|
|
108
113
|
|
|
109
114
|
Global flags: `-c, --config <path>` · `-v, --verbose` · `--no-color`.
|
|
@@ -153,6 +158,14 @@ job closes the run instead of the explicit `finish`.
|
|
|
153
158
|
`plune run exec` sets it for you — anything reporting inside it, this command included, joins
|
|
154
159
|
without closing.
|
|
155
160
|
|
|
161
|
+
### What the run is called
|
|
162
|
+
|
|
163
|
+
A run nobody named is called after the directory, the minute it started and where it ran —
|
|
164
|
+
`plune · 2026-09-15 15:26 · ci` — so a row in the list reads without being opened. The clock is the
|
|
165
|
+
reporting machine's own; `ci` comes from the `CI` variable every hosted runner sets. To call it
|
|
166
|
+
something else, set `PLUNE_RUN_TITLE`; `PLUNE_ENV` and `PLUNE_LABELS` mark where it ran and how, and
|
|
167
|
+
those stay marks rather than becoming part of the name.
|
|
168
|
+
|
|
156
169
|
## Optional: keep a history
|
|
157
170
|
|
|
158
171
|
Everything above works with no account, no network, and no token — that does not change. `run`,
|
package/dist/cli.cjs
CHANGED
|
@@ -2210,7 +2210,7 @@ function createClient(opts) {
|
|
|
2210
2210
|
const wait = opts.wait ?? sleep;
|
|
2211
2211
|
const base = opts.apiUrl.replace(/\/+$/, "");
|
|
2212
2212
|
const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
|
|
2213
|
-
async function
|
|
2213
|
+
async function send(method, path13, body) {
|
|
2214
2214
|
let last = {
|
|
2215
2215
|
kind: "unavailable",
|
|
2216
2216
|
status: null,
|
|
@@ -2219,17 +2219,23 @@ function createClient(opts) {
|
|
|
2219
2219
|
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
|
|
2220
2220
|
let res;
|
|
2221
2221
|
try {
|
|
2222
|
-
res = await doFetch(`${base}${
|
|
2223
|
-
method
|
|
2222
|
+
res = await doFetch(`${base}${path13}`, {
|
|
2223
|
+
method,
|
|
2224
2224
|
headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
|
|
2225
|
-
|
|
2225
|
+
// A DELETE carries none. Sending `"undefined"` as a body is what a naive
|
|
2226
|
+
// `JSON.stringify(body)` would do, and some proxies answer that with a 400 nobody can
|
|
2227
|
+
// explain.
|
|
2228
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
2226
2229
|
});
|
|
2227
2230
|
} catch (err) {
|
|
2228
2231
|
last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
|
|
2229
2232
|
if (attempt < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[attempt]);
|
|
2230
2233
|
continue;
|
|
2231
2234
|
}
|
|
2232
|
-
if (res.ok)
|
|
2235
|
+
if (res.ok) {
|
|
2236
|
+
if (res.status === 204) return { ok: true, status: res.status, body: void 0 };
|
|
2237
|
+
return { ok: true, status: res.status, body: await res.json() };
|
|
2238
|
+
}
|
|
2233
2239
|
const detail = scrub(await detailOf(res));
|
|
2234
2240
|
const kind = classify3(res.status);
|
|
2235
2241
|
last = { kind, status: res.status, detail };
|
|
@@ -2240,7 +2246,10 @@ function createClient(opts) {
|
|
|
2240
2246
|
}
|
|
2241
2247
|
return { ok: false, ...last };
|
|
2242
2248
|
}
|
|
2243
|
-
return {
|
|
2249
|
+
return {
|
|
2250
|
+
post: (path13, body) => send("POST", path13, body),
|
|
2251
|
+
del: (path13) => send("DELETE", path13)
|
|
2252
|
+
};
|
|
2244
2253
|
}
|
|
2245
2254
|
var RETRY_DELAYS_MS, sleep;
|
|
2246
2255
|
var init_client = __esm({
|
|
@@ -2304,18 +2313,25 @@ function readEnv(env = process.env) {
|
|
|
2304
2313
|
ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0)
|
|
2305
2314
|
};
|
|
2306
2315
|
}
|
|
2307
|
-
|
|
2316
|
+
function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cwd = process.cwd()) {
|
|
2317
|
+
const two = (n) => String(n).padStart(2, "0");
|
|
2318
|
+
const stamp = `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())} ${two(now.getHours())}:${two(now.getMinutes())}`;
|
|
2319
|
+
const where = flag(env, "CI") || flag(env, "GITHUB_ACTIONS") ? "ci" : "local";
|
|
2320
|
+
return `${path6.basename(cwd)} \xB7 ${stamp} \xB7 ${where}`;
|
|
2321
|
+
}
|
|
2322
|
+
var path6, UNSUPPORTED_VARS;
|
|
2308
2323
|
var init_env2 = __esm({
|
|
2309
2324
|
"src/reporter-core/env.ts"() {
|
|
2310
2325
|
"use strict";
|
|
2311
2326
|
init_cjs_shims();
|
|
2327
|
+
path6 = __toESM(require("path"), 1);
|
|
2312
2328
|
UNSUPPORTED_VARS = ["PLUNE_GROUP"];
|
|
2313
2329
|
}
|
|
2314
2330
|
});
|
|
2315
2331
|
|
|
2316
2332
|
// src/reporter-core/fallback.ts
|
|
2317
2333
|
function appendBatch(file, batch) {
|
|
2318
|
-
fs6.mkdirSync(
|
|
2334
|
+
fs6.mkdirSync(path7.dirname(file), { recursive: true });
|
|
2319
2335
|
const line = {
|
|
2320
2336
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2321
2337
|
runId: batch.runId,
|
|
@@ -2325,18 +2341,28 @@ function appendBatch(file, batch) {
|
|
|
2325
2341
|
fs6.appendFileSync(file, `${JSON.stringify(line)}
|
|
2326
2342
|
`, "utf8");
|
|
2327
2343
|
}
|
|
2328
|
-
var fs6,
|
|
2344
|
+
var fs6, path7, DEFAULT_FALLBACK_PATH;
|
|
2329
2345
|
var init_fallback = __esm({
|
|
2330
2346
|
"src/reporter-core/fallback.ts"() {
|
|
2331
2347
|
"use strict";
|
|
2332
2348
|
init_cjs_shims();
|
|
2333
2349
|
fs6 = __toESM(require("fs"), 1);
|
|
2334
|
-
|
|
2335
|
-
DEFAULT_FALLBACK_PATH =
|
|
2350
|
+
path7 = __toESM(require("path"), 1);
|
|
2351
|
+
DEFAULT_FALLBACK_PATH = path7.join(".plune", "pending-results.jsonl");
|
|
2336
2352
|
}
|
|
2337
2353
|
});
|
|
2338
2354
|
|
|
2339
2355
|
// src/reporter-core/session.ts
|
|
2356
|
+
function boundKey(key) {
|
|
2357
|
+
if (key.value.length <= KEY_MAX) return key;
|
|
2358
|
+
const digest = (0, import_node_crypto2.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
|
|
2359
|
+
let head = key.value.slice(0, KEY_MAX - digest.length - 1);
|
|
2360
|
+
if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
|
|
2361
|
+
return { ...key, value: `${head}#${digest}` };
|
|
2362
|
+
}
|
|
2363
|
+
function boundTitle(title) {
|
|
2364
|
+
return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
|
|
2365
|
+
}
|
|
2340
2366
|
function chunk(items, size) {
|
|
2341
2367
|
const out = [];
|
|
2342
2368
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
@@ -2350,7 +2376,7 @@ function warnIfDropped(sent, run, log) {
|
|
|
2350
2376
|
`plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
|
|
2351
2377
|
);
|
|
2352
2378
|
}
|
|
2353
|
-
async function startRun(passed,
|
|
2379
|
+
async function startRun(passed, declared) {
|
|
2354
2380
|
const env = readEnv();
|
|
2355
2381
|
const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
|
|
2356
2382
|
const cfg = { ...env.config, ...stated };
|
|
@@ -2373,6 +2399,7 @@ async function startRun(passed, expected) {
|
|
|
2373
2399
|
rejected: 0,
|
|
2374
2400
|
unresolved: 0,
|
|
2375
2401
|
offered: 0,
|
|
2402
|
+
created: 0,
|
|
2376
2403
|
unoffered: 0,
|
|
2377
2404
|
deferred: 0
|
|
2378
2405
|
};
|
|
@@ -2383,6 +2410,22 @@ async function startRun(passed, expected) {
|
|
|
2383
2410
|
let joined = false;
|
|
2384
2411
|
let offline = token === "";
|
|
2385
2412
|
let done = false;
|
|
2413
|
+
const shortened = /* @__PURE__ */ new Set();
|
|
2414
|
+
function bound(keys) {
|
|
2415
|
+
return keys.map((key) => {
|
|
2416
|
+
const fit = boundKey(key);
|
|
2417
|
+
if (fit !== key && !shortened.has(key.value)) {
|
|
2418
|
+
shortened.add(key.value);
|
|
2419
|
+
if (shortened.size === 1) {
|
|
2420
|
+
log(
|
|
2421
|
+
`plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 "${key.value.slice(0, 80)}\u2026". Give the test a shorter name; until then this is its identity.`
|
|
2422
|
+
);
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
return fit;
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
2428
|
+
const expected = declared?.map(bound);
|
|
2386
2429
|
if (offline) {
|
|
2387
2430
|
log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
|
|
2388
2431
|
}
|
|
@@ -2410,9 +2453,9 @@ async function startRun(passed, expected) {
|
|
|
2410
2453
|
}
|
|
2411
2454
|
if (!offline) {
|
|
2412
2455
|
if (expected !== void 0) await resolveKeys(expected.flat());
|
|
2413
|
-
const
|
|
2414
|
-
const configuration =
|
|
2415
|
-
expected:
|
|
2456
|
+
const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
|
|
2457
|
+
const configuration = stated2.length === 0 ? void 0 : {
|
|
2458
|
+
expected: stated2.flatMap((keys) => {
|
|
2416
2459
|
const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
|
|
2417
2460
|
if (typeof hit === "string") return [{ testCaseId: hit }];
|
|
2418
2461
|
return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
|
|
@@ -2432,6 +2475,10 @@ async function startRun(passed, expected) {
|
|
|
2432
2475
|
...externalKey !== null ? { externalKey } : {},
|
|
2433
2476
|
...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
|
|
2434
2477
|
...configuration !== void 0 ? { configuration } : {},
|
|
2478
|
+
// Named even when nobody named it — the list has to read without opening a row. Only what
|
|
2479
|
+
// the caller DESCRIBED goes to `warnIfDropped` below: a title this side invented is not one
|
|
2480
|
+
// a person will miss, and a deployment older than the field must not be nagged on every run.
|
|
2481
|
+
title: defaultRunTitle(),
|
|
2435
2482
|
...described
|
|
2436
2483
|
});
|
|
2437
2484
|
if (start.ok) {
|
|
@@ -2456,9 +2503,11 @@ async function startRun(passed, expected) {
|
|
|
2456
2503
|
if (!offerDiscovered) return;
|
|
2457
2504
|
if (result.title === void 0 || result.specRef === void 0) return;
|
|
2458
2505
|
if (result.keys.length === 0) return;
|
|
2506
|
+
const title = boundTitle(result.title);
|
|
2507
|
+
if (title !== result.title) shortened.add(result.title);
|
|
2459
2508
|
discoveries.set(result.keys[0].value, {
|
|
2460
2509
|
keys: result.keys,
|
|
2461
|
-
title
|
|
2510
|
+
title,
|
|
2462
2511
|
source: result.source,
|
|
2463
2512
|
specRef: result.specRef,
|
|
2464
2513
|
rawStatus: result.rawStatus
|
|
@@ -2480,6 +2529,7 @@ async function startRun(passed, expected) {
|
|
|
2480
2529
|
return;
|
|
2481
2530
|
}
|
|
2482
2531
|
stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
|
|
2532
|
+
stats.created += out.body.results.filter((r) => r.outcome === "created").length;
|
|
2483
2533
|
}
|
|
2484
2534
|
}
|
|
2485
2535
|
async function flush() {
|
|
@@ -2545,7 +2595,9 @@ async function startRun(passed, expected) {
|
|
|
2545
2595
|
if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
|
|
2546
2596
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
2547
2597
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
2598
|
+
if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
|
|
2548
2599
|
if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
|
|
2600
|
+
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
2549
2601
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
2550
2602
|
}
|
|
2551
2603
|
return {
|
|
@@ -2559,7 +2611,7 @@ async function startRun(passed, expected) {
|
|
|
2559
2611
|
return stats;
|
|
2560
2612
|
},
|
|
2561
2613
|
async add(result) {
|
|
2562
|
-
buffer.push(result);
|
|
2614
|
+
buffer.push({ ...result, keys: bound(result.keys) });
|
|
2563
2615
|
if (buffer.length >= batchSize) await flush();
|
|
2564
2616
|
},
|
|
2565
2617
|
flush,
|
|
@@ -2589,11 +2641,12 @@ async function startRun(passed, expected) {
|
|
|
2589
2641
|
}
|
|
2590
2642
|
};
|
|
2591
2643
|
}
|
|
2592
|
-
var RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX;
|
|
2644
|
+
var import_node_crypto2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX;
|
|
2593
2645
|
var init_session = __esm({
|
|
2594
2646
|
"src/reporter-core/session.ts"() {
|
|
2595
2647
|
"use strict";
|
|
2596
2648
|
init_cjs_shims();
|
|
2649
|
+
import_node_crypto2 = require("crypto");
|
|
2597
2650
|
init_api_url();
|
|
2598
2651
|
init_credentials();
|
|
2599
2652
|
init_client();
|
|
@@ -2604,6 +2657,8 @@ var init_session = __esm({
|
|
|
2604
2657
|
DISCOVER_MAX = 500;
|
|
2605
2658
|
BATCH_DEFAULT = 100;
|
|
2606
2659
|
EXPECTED_MAX = 1e4;
|
|
2660
|
+
KEY_MAX = 1024;
|
|
2661
|
+
TITLE_MAX = 300;
|
|
2607
2662
|
}
|
|
2608
2663
|
});
|
|
2609
2664
|
|
|
@@ -2612,6 +2667,7 @@ var run_lifecycle_exports = {};
|
|
|
2612
2667
|
__export(run_lifecycle_exports, {
|
|
2613
2668
|
NoTokenError: () => NoTokenError,
|
|
2614
2669
|
RunCommandError: () => RunCommandError,
|
|
2670
|
+
handleRunDelete: () => handleRunDelete,
|
|
2615
2671
|
handleRunExec: () => handleRunExec,
|
|
2616
2672
|
handleRunFinish: () => handleRunFinish,
|
|
2617
2673
|
handleRunReport: () => handleRunReport,
|
|
@@ -2644,7 +2700,9 @@ async function handleRunStart(options = {}) {
|
|
|
2644
2700
|
const out = await client.post("/v1/runs", {
|
|
2645
2701
|
schemaVersion: 2,
|
|
2646
2702
|
kind: options.kind ?? "automated",
|
|
2647
|
-
externalKey
|
|
2703
|
+
externalKey,
|
|
2704
|
+
// The same name the reporter would give it; `PLUNE_RUN_TITLE` replaces it, as everywhere.
|
|
2705
|
+
title: env.config.title ?? defaultRunTitle()
|
|
2648
2706
|
});
|
|
2649
2707
|
if (!out.ok) throw new RunCommandError(`Could not start the run \u2014 ${out.detail || out.kind}.`);
|
|
2650
2708
|
const result = {
|
|
@@ -2675,6 +2733,22 @@ async function handleRunFinish(id, options = {}) {
|
|
|
2675
2733
|
}
|
|
2676
2734
|
write(`Run ${id} ${event === "finish" ? "finished" : "terminated"}.`);
|
|
2677
2735
|
}
|
|
2736
|
+
async function handleRunDelete(id, options = {}) {
|
|
2737
|
+
const { client, write } = connect(options);
|
|
2738
|
+
const out = await client.del(`/v1/runs/${encodeURIComponent(id)}`);
|
|
2739
|
+
if (!out.ok) {
|
|
2740
|
+
throw new RunCommandError(
|
|
2741
|
+
out.status === 404 ? (
|
|
2742
|
+
// Three causes, one answer, and the message names all three because the platform will not:
|
|
2743
|
+
// a 404 that distinguished "not yours" from "not there" would tell a stranger which ids
|
|
2744
|
+
// exist. The person holding the terminal is better served by the list than by a guess.
|
|
2745
|
+
`No run '${id}' \u2014 check the id, whether the token belongs to the same project, or whether it is already deleted.`
|
|
2746
|
+
) : `Could not delete run ${id} \u2014 ${out.detail || out.kind}.`
|
|
2747
|
+
);
|
|
2748
|
+
}
|
|
2749
|
+
write(`Run ${id} deleted, with its results and the review-queue entries it produced.`);
|
|
2750
|
+
write("Recoverable for six months \u2014 ask Plune to put it back.");
|
|
2751
|
+
}
|
|
2678
2752
|
async function handleRunExec(options) {
|
|
2679
2753
|
const [command, ...args] = options.argv;
|
|
2680
2754
|
if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
|
|
@@ -3208,6 +3282,7 @@ async function handleRunImport(options) {
|
|
|
3208
3282
|
rejected: stats.rejected,
|
|
3209
3283
|
unresolved: stats.unresolved,
|
|
3210
3284
|
offered: stats.offered,
|
|
3285
|
+
created: stats.created,
|
|
3211
3286
|
unoffered: stats.unoffered,
|
|
3212
3287
|
deferred: stats.deferred,
|
|
3213
3288
|
unlocatable: results.filter((r) => r.specRef === void 0).length,
|
|
@@ -3236,11 +3311,16 @@ function describe(result, apiUrl) {
|
|
|
3236
3311
|
`${result.deferred} could not be sent and are in the fallback file \u2014 "plune run report" retries them.`
|
|
3237
3312
|
);
|
|
3238
3313
|
}
|
|
3314
|
+
if (result.created > 0) {
|
|
3315
|
+
lines.push(
|
|
3316
|
+
`${result.created} unknown test(s) became cases without review \u2014 this project trusts the source. Change that under Settings \u2192 Trusted sources.`
|
|
3317
|
+
);
|
|
3318
|
+
}
|
|
3239
3319
|
if (result.offered > 0) {
|
|
3240
3320
|
lines.push(`${result.offered} unknown test(s) offered to the review queue.`);
|
|
3241
|
-
} else if (result.unresolved > 0 && !result.offering) {
|
|
3321
|
+
} else if (result.unresolved > 0 && result.created === 0 && !result.offering) {
|
|
3242
3322
|
lines.push("Nothing was offered to the review queue \u2014 pass --create to propose the unmatched tests.");
|
|
3243
|
-
} else if (result.unresolved > 0 && result.unoffered === 0) {
|
|
3323
|
+
} else if (result.unresolved > 0 && result.created === 0 && result.unoffered === 0) {
|
|
3244
3324
|
lines.push("Nothing new to offer \u2014 the unmatched tests are already in the review queue.");
|
|
3245
3325
|
}
|
|
3246
3326
|
if (result.unoffered > 0) {
|
|
@@ -3284,7 +3364,7 @@ function isRunResult(v) {
|
|
|
3284
3364
|
}
|
|
3285
3365
|
function handleReport(opts = {}) {
|
|
3286
3366
|
const cwd = opts.cwd ?? process.cwd();
|
|
3287
|
-
const file =
|
|
3367
|
+
const file = path8.join(cwd, ".plune", "last-run.json");
|
|
3288
3368
|
let raw;
|
|
3289
3369
|
try {
|
|
3290
3370
|
raw = fs9.readFileSync(file, "utf8");
|
|
@@ -3302,13 +3382,13 @@ function handleReport(opts = {}) {
|
|
|
3302
3382
|
}
|
|
3303
3383
|
return parsed;
|
|
3304
3384
|
}
|
|
3305
|
-
var fs9,
|
|
3385
|
+
var fs9, path8, ReportNotFoundError;
|
|
3306
3386
|
var init_report = __esm({
|
|
3307
3387
|
"src/cli/commands/report.ts"() {
|
|
3308
3388
|
"use strict";
|
|
3309
3389
|
init_cjs_shims();
|
|
3310
3390
|
fs9 = __toESM(require("fs"), 1);
|
|
3311
|
-
|
|
3391
|
+
path8 = __toESM(require("path"), 1);
|
|
3312
3392
|
ReportNotFoundError = class extends Error {
|
|
3313
3393
|
code = "NO_SAVED_RUN";
|
|
3314
3394
|
constructor(message) {
|
|
@@ -3532,7 +3612,7 @@ async function runInitWizard(cwd) {
|
|
|
3532
3612
|
process.exit(1);
|
|
3533
3613
|
return;
|
|
3534
3614
|
}
|
|
3535
|
-
const outputPath =
|
|
3615
|
+
const outputPath = path9.join(cwd, "plune.yaml");
|
|
3536
3616
|
if (fs11.existsSync(outputPath)) {
|
|
3537
3617
|
const overwrite = await clack.confirm({
|
|
3538
3618
|
message: "plune.yaml already exists. Overwrite?"
|
|
@@ -3576,14 +3656,14 @@ async function runInitWizard(cwd) {
|
|
|
3576
3656
|
spin.stop("plune.yaml created!");
|
|
3577
3657
|
clack.outro("plune.yaml is ready.");
|
|
3578
3658
|
}
|
|
3579
|
-
var clack, fs11,
|
|
3659
|
+
var clack, fs11, path9, import_yaml2;
|
|
3580
3660
|
var init_wizard = __esm({
|
|
3581
3661
|
"src/config/init/wizard.ts"() {
|
|
3582
3662
|
"use strict";
|
|
3583
3663
|
init_cjs_shims();
|
|
3584
3664
|
clack = __toESM(require("@clack/prompts"), 1);
|
|
3585
3665
|
fs11 = __toESM(require("fs"), 1);
|
|
3586
|
-
|
|
3666
|
+
path9 = __toESM(require("path"), 1);
|
|
3587
3667
|
import_yaml2 = require("yaml");
|
|
3588
3668
|
init_errors();
|
|
3589
3669
|
}
|
|
@@ -3591,21 +3671,21 @@ var init_wizard = __esm({
|
|
|
3591
3671
|
|
|
3592
3672
|
// src/cli/templates/index.ts
|
|
3593
3673
|
function writeTemplateFile(cwd, rel, content, force) {
|
|
3594
|
-
const target =
|
|
3674
|
+
const target = path10.join(cwd, rel);
|
|
3595
3675
|
if (!force && fs12.existsSync(target)) {
|
|
3596
3676
|
return "skipped";
|
|
3597
3677
|
}
|
|
3598
|
-
fs12.mkdirSync(
|
|
3678
|
+
fs12.mkdirSync(path10.dirname(target), { recursive: true });
|
|
3599
3679
|
fs12.writeFileSync(target, content, "utf8");
|
|
3600
3680
|
return "written";
|
|
3601
3681
|
}
|
|
3602
|
-
var fs12,
|
|
3682
|
+
var fs12, path10, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
|
|
3603
3683
|
var init_templates = __esm({
|
|
3604
3684
|
"src/cli/templates/index.ts"() {
|
|
3605
3685
|
"use strict";
|
|
3606
3686
|
init_cjs_shims();
|
|
3607
3687
|
fs12 = __toESM(require("fs"), 1);
|
|
3608
|
-
|
|
3688
|
+
path10 = __toESM(require("path"), 1);
|
|
3609
3689
|
ENV_EXAMPLE_TPL = `# Copy this file to .env and replace the placeholders with your real keys.
|
|
3610
3690
|
# .env is gitignored; .env.example is safe to commit.
|
|
3611
3691
|
# Plune reads whichever key matches your configured provider.
|
|
@@ -3861,7 +3941,7 @@ __export(sync_exports, {
|
|
|
3861
3941
|
reportSyncFailure: () => reportSyncFailure
|
|
3862
3942
|
});
|
|
3863
3943
|
function readRun(cwd, file) {
|
|
3864
|
-
const target = file !== void 0 ?
|
|
3944
|
+
const target = file !== void 0 ? path11.resolve(cwd, file) : path11.join(cwd, ".plune", "last-run.json");
|
|
3865
3945
|
let raw;
|
|
3866
3946
|
try {
|
|
3867
3947
|
raw = fs13.readFileSync(target, "utf8");
|
|
@@ -3928,13 +4008,13 @@ function reportSyncFailure(err, write) {
|
|
|
3928
4008
|
}
|
|
3929
4009
|
return null;
|
|
3930
4010
|
}
|
|
3931
|
-
var fs13,
|
|
4011
|
+
var fs13, path11, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
|
|
3932
4012
|
var init_sync = __esm({
|
|
3933
4013
|
"src/cli/commands/sync.ts"() {
|
|
3934
4014
|
"use strict";
|
|
3935
4015
|
init_cjs_shims();
|
|
3936
4016
|
fs13 = __toESM(require("fs"), 1);
|
|
3937
|
-
|
|
4017
|
+
path11 = __toESM(require("path"), 1);
|
|
3938
4018
|
init_run_result_schema();
|
|
3939
4019
|
init_api_url();
|
|
3940
4020
|
init_credentials();
|
|
@@ -3984,9 +4064,9 @@ async function findLatestCairnRun(runsDir = "runs") {
|
|
|
3984
4064
|
let newest;
|
|
3985
4065
|
for (const entry of entries) {
|
|
3986
4066
|
if (!entry.isDirectory()) continue;
|
|
3987
|
-
const dir =
|
|
4067
|
+
const dir = path12.join(runsDir, entry.name);
|
|
3988
4068
|
try {
|
|
3989
|
-
const report = await fs14.lstat(
|
|
4069
|
+
const report = await fs14.lstat(path12.join(dir, "report.json"));
|
|
3990
4070
|
if (!report.isFile()) continue;
|
|
3991
4071
|
if (newest === void 0 || report.mtimeMs > newest.at) {
|
|
3992
4072
|
newest = { dir, at: report.mtimeMs };
|
|
@@ -4005,7 +4085,7 @@ async function readJson(file) {
|
|
|
4005
4085
|
}
|
|
4006
4086
|
}
|
|
4007
4087
|
async function defaultReadEvidence(dir) {
|
|
4008
|
-
const raw = await readJson(
|
|
4088
|
+
const raw = await readJson(path12.join(dir, "api-evidence.json"));
|
|
4009
4089
|
if (!Array.isArray(raw)) return void 0;
|
|
4010
4090
|
return raw.map((r) => {
|
|
4011
4091
|
const rec = asRecord(r);
|
|
@@ -4047,7 +4127,7 @@ function casesFromApi(report, verdicts) {
|
|
|
4047
4127
|
});
|
|
4048
4128
|
}
|
|
4049
4129
|
async function readCairnRun(dir, deps = {}) {
|
|
4050
|
-
const raw = await readJson(
|
|
4130
|
+
const raw = await readJson(path12.join(dir, "report.json"));
|
|
4051
4131
|
if (raw === void 0) throw new CairnRunNotFoundError(dir);
|
|
4052
4132
|
const report = asRecord(raw);
|
|
4053
4133
|
if (report["schemaVersion"] !== SUPPORTED_ARTIFACT_VERSION) {
|
|
@@ -4077,20 +4157,20 @@ async function readCairnRun(dir, deps = {}) {
|
|
|
4077
4157
|
return {
|
|
4078
4158
|
schemaVersion: INGEST_SCHEMA_VERSION,
|
|
4079
4159
|
run: {
|
|
4080
|
-
runId: str(report["runId"],
|
|
4160
|
+
runId: str(report["runId"], path12.basename(dir)),
|
|
4081
4161
|
mode,
|
|
4082
4162
|
target: str(report["url"], "unknown")
|
|
4083
4163
|
},
|
|
4084
4164
|
cases
|
|
4085
4165
|
};
|
|
4086
4166
|
}
|
|
4087
|
-
var fs14,
|
|
4167
|
+
var fs14, path12, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
|
|
4088
4168
|
var init_cairn_artifact = __esm({
|
|
4089
4169
|
"src/cli/commands/cairn-artifact.ts"() {
|
|
4090
4170
|
"use strict";
|
|
4091
4171
|
init_cjs_shims();
|
|
4092
4172
|
fs14 = __toESM(require("fs/promises"), 1);
|
|
4093
|
-
|
|
4173
|
+
path12 = __toESM(require("path"), 1);
|
|
4094
4174
|
SUPPORTED_ARTIFACT_VERSION = 1;
|
|
4095
4175
|
INGEST_SCHEMA_VERSION = 1;
|
|
4096
4176
|
NoCairnRunsError = class extends Error {
|
|
@@ -4428,6 +4508,14 @@ function createProgram() {
|
|
|
4428
4508
|
await failRunCommand(err, verboseOf(command));
|
|
4429
4509
|
}
|
|
4430
4510
|
});
|
|
4511
|
+
runCommand.command("delete").argument("<id>", "The run id \u2014 the one `plune run import` printed, or the one in the dashboard URL").description("Delete a run and everything it produced \u2014 recoverable for six months").action(async (id, _options, command) => {
|
|
4512
|
+
try {
|
|
4513
|
+
const { handleRunDelete: handleRunDelete2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
|
|
4514
|
+
await handleRunDelete2(id);
|
|
4515
|
+
} catch (err) {
|
|
4516
|
+
await failRunCommand(err, verboseOf(command));
|
|
4517
|
+
}
|
|
4518
|
+
});
|
|
4431
4519
|
runCommand.command("report").description("Send what the reporter could not \u2014 replays .plune/pending-results.jsonl").option("--file <path>", "The fallback file to replay").action(async (options, command) => {
|
|
4432
4520
|
try {
|
|
4433
4521
|
const { handleRunReport: handleRunReport2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
|
|
@@ -4538,10 +4626,10 @@ function createProgram() {
|
|
|
4538
4626
|
const { handleLogin: handleLogin2, reportLoginFailure: reportLoginFailure2 } = await Promise.resolve().then(() => (init_login(), login_exports));
|
|
4539
4627
|
try {
|
|
4540
4628
|
const token = options.token ?? await readTokenFromStdin();
|
|
4541
|
-
const { path:
|
|
4629
|
+
const { path: path13, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
|
|
4542
4630
|
process.stdout.write(
|
|
4543
|
-
verified ? `Logged in. Token checked against the API and saved to ${
|
|
4544
|
-
` : `Token saved to ${
|
|
4631
|
+
verified ? `Logged in. Token checked against the API and saved to ${path13}
|
|
4632
|
+
` : `Token saved to ${path13} WITHOUT being checked (--skip-verify).
|
|
4545
4633
|
`
|
|
4546
4634
|
);
|
|
4547
4635
|
} catch (err) {
|
|
@@ -4558,9 +4646,9 @@ function createProgram() {
|
|
|
4558
4646
|
const verbose = command.optsWithGlobals().verbose === true;
|
|
4559
4647
|
const { handleLogout: handleLogout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
|
|
4560
4648
|
try {
|
|
4561
|
-
const { removed, path:
|
|
4649
|
+
const { removed, path: path13 } = handleLogout2();
|
|
4562
4650
|
process.stdout.write(
|
|
4563
|
-
removed ? `Logged out. Removed ${
|
|
4651
|
+
removed ? `Logged out. Removed ${path13}
|
|
4564
4652
|
` : "Not logged in \u2014 nothing to remove.\n"
|
|
4565
4653
|
);
|
|
4566
4654
|
} catch (err) {
|
package/dist/reporter-core.cjs
CHANGED
|
@@ -41,6 +41,9 @@ __export(reporter_core_exports, {
|
|
|
41
41
|
});
|
|
42
42
|
module.exports = __toCommonJS(reporter_core_exports);
|
|
43
43
|
|
|
44
|
+
// src/reporter-core/session.ts
|
|
45
|
+
var import_node_crypto = require("crypto");
|
|
46
|
+
|
|
44
47
|
// src/cli/api-url.ts
|
|
45
48
|
var DEFAULT_API_URL = "https://beta-api.plune.ai";
|
|
46
49
|
function resolveApiUrl(explicit) {
|
|
@@ -94,7 +97,7 @@ function createClient(opts) {
|
|
|
94
97
|
const wait = opts.wait ?? sleep;
|
|
95
98
|
const base = opts.apiUrl.replace(/\/+$/, "");
|
|
96
99
|
const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
|
|
97
|
-
async function
|
|
100
|
+
async function send(method, path3, body) {
|
|
98
101
|
let last = {
|
|
99
102
|
kind: "unavailable",
|
|
100
103
|
status: null,
|
|
@@ -103,17 +106,23 @@ function createClient(opts) {
|
|
|
103
106
|
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
|
|
104
107
|
let res;
|
|
105
108
|
try {
|
|
106
|
-
res = await doFetch(`${base}${
|
|
107
|
-
method
|
|
109
|
+
res = await doFetch(`${base}${path3}`, {
|
|
110
|
+
method,
|
|
108
111
|
headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
|
|
109
|
-
|
|
112
|
+
// A DELETE carries none. Sending `"undefined"` as a body is what a naive
|
|
113
|
+
// `JSON.stringify(body)` would do, and some proxies answer that with a 400 nobody can
|
|
114
|
+
// explain.
|
|
115
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
110
116
|
});
|
|
111
117
|
} catch (err) {
|
|
112
118
|
last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
|
|
113
119
|
if (attempt < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[attempt]);
|
|
114
120
|
continue;
|
|
115
121
|
}
|
|
116
|
-
if (res.ok)
|
|
122
|
+
if (res.ok) {
|
|
123
|
+
if (res.status === 204) return { ok: true, status: res.status, body: void 0 };
|
|
124
|
+
return { ok: true, status: res.status, body: await res.json() };
|
|
125
|
+
}
|
|
117
126
|
const detail = scrub(await detailOf(res));
|
|
118
127
|
const kind = classify(res.status);
|
|
119
128
|
last = { kind, status: res.status, detail };
|
|
@@ -124,10 +133,14 @@ function createClient(opts) {
|
|
|
124
133
|
}
|
|
125
134
|
return { ok: false, ...last };
|
|
126
135
|
}
|
|
127
|
-
return {
|
|
136
|
+
return {
|
|
137
|
+
post: (path3, body) => send("POST", path3, body),
|
|
138
|
+
del: (path3) => send("DELETE", path3)
|
|
139
|
+
};
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
// src/reporter-core/env.ts
|
|
143
|
+
var path = __toESM(require("path"), 1);
|
|
131
144
|
var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
|
|
132
145
|
function value(env, name) {
|
|
133
146
|
const raw = env[name]?.trim();
|
|
@@ -180,13 +193,19 @@ function readEnv(env = process.env) {
|
|
|
180
193
|
ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0)
|
|
181
194
|
};
|
|
182
195
|
}
|
|
196
|
+
function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cwd = process.cwd()) {
|
|
197
|
+
const two = (n) => String(n).padStart(2, "0");
|
|
198
|
+
const stamp = `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())} ${two(now.getHours())}:${two(now.getMinutes())}`;
|
|
199
|
+
const where = flag(env, "CI") || flag(env, "GITHUB_ACTIONS") ? "ci" : "local";
|
|
200
|
+
return `${path.basename(cwd)} \xB7 ${stamp} \xB7 ${where}`;
|
|
201
|
+
}
|
|
183
202
|
|
|
184
203
|
// src/reporter-core/fallback.ts
|
|
185
204
|
var fs = __toESM(require("fs"), 1);
|
|
186
|
-
var
|
|
187
|
-
var DEFAULT_FALLBACK_PATH =
|
|
205
|
+
var path2 = __toESM(require("path"), 1);
|
|
206
|
+
var DEFAULT_FALLBACK_PATH = path2.join(".plune", "pending-results.jsonl");
|
|
188
207
|
function appendBatch(file, batch) {
|
|
189
|
-
fs.mkdirSync(
|
|
208
|
+
fs.mkdirSync(path2.dirname(file), { recursive: true });
|
|
190
209
|
const line = {
|
|
191
210
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
192
211
|
runId: batch.runId,
|
|
@@ -203,6 +222,18 @@ var BATCH_MAX = 500;
|
|
|
203
222
|
var DISCOVER_MAX = 500;
|
|
204
223
|
var BATCH_DEFAULT = 100;
|
|
205
224
|
var EXPECTED_MAX = 1e4;
|
|
225
|
+
var KEY_MAX = 1024;
|
|
226
|
+
var TITLE_MAX = 300;
|
|
227
|
+
function boundKey(key) {
|
|
228
|
+
if (key.value.length <= KEY_MAX) return key;
|
|
229
|
+
const digest = (0, import_node_crypto.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
|
|
230
|
+
let head = key.value.slice(0, KEY_MAX - digest.length - 1);
|
|
231
|
+
if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
|
|
232
|
+
return { ...key, value: `${head}#${digest}` };
|
|
233
|
+
}
|
|
234
|
+
function boundTitle(title) {
|
|
235
|
+
return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
|
|
236
|
+
}
|
|
206
237
|
function chunk(items, size) {
|
|
207
238
|
const out = [];
|
|
208
239
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
@@ -216,7 +247,7 @@ function warnIfDropped(sent, run, log) {
|
|
|
216
247
|
`plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
|
|
217
248
|
);
|
|
218
249
|
}
|
|
219
|
-
async function startRun(passed,
|
|
250
|
+
async function startRun(passed, declared) {
|
|
220
251
|
const env = readEnv();
|
|
221
252
|
const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
|
|
222
253
|
const cfg = { ...env.config, ...stated };
|
|
@@ -239,6 +270,7 @@ async function startRun(passed, expected) {
|
|
|
239
270
|
rejected: 0,
|
|
240
271
|
unresolved: 0,
|
|
241
272
|
offered: 0,
|
|
273
|
+
created: 0,
|
|
242
274
|
unoffered: 0,
|
|
243
275
|
deferred: 0
|
|
244
276
|
};
|
|
@@ -249,6 +281,22 @@ async function startRun(passed, expected) {
|
|
|
249
281
|
let joined = false;
|
|
250
282
|
let offline = token === "";
|
|
251
283
|
let done = false;
|
|
284
|
+
const shortened = /* @__PURE__ */ new Set();
|
|
285
|
+
function bound(keys) {
|
|
286
|
+
return keys.map((key) => {
|
|
287
|
+
const fit = boundKey(key);
|
|
288
|
+
if (fit !== key && !shortened.has(key.value)) {
|
|
289
|
+
shortened.add(key.value);
|
|
290
|
+
if (shortened.size === 1) {
|
|
291
|
+
log(
|
|
292
|
+
`plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 "${key.value.slice(0, 80)}\u2026". Give the test a shorter name; until then this is its identity.`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return fit;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
const expected = declared?.map(bound);
|
|
252
300
|
if (offline) {
|
|
253
301
|
log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
|
|
254
302
|
}
|
|
@@ -276,9 +324,9 @@ async function startRun(passed, expected) {
|
|
|
276
324
|
}
|
|
277
325
|
if (!offline) {
|
|
278
326
|
if (expected !== void 0) await resolveKeys(expected.flat());
|
|
279
|
-
const
|
|
280
|
-
const configuration =
|
|
281
|
-
expected:
|
|
327
|
+
const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
|
|
328
|
+
const configuration = stated2.length === 0 ? void 0 : {
|
|
329
|
+
expected: stated2.flatMap((keys) => {
|
|
282
330
|
const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
|
|
283
331
|
if (typeof hit === "string") return [{ testCaseId: hit }];
|
|
284
332
|
return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
|
|
@@ -298,6 +346,10 @@ async function startRun(passed, expected) {
|
|
|
298
346
|
...externalKey !== null ? { externalKey } : {},
|
|
299
347
|
...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
|
|
300
348
|
...configuration !== void 0 ? { configuration } : {},
|
|
349
|
+
// Named even when nobody named it — the list has to read without opening a row. Only what
|
|
350
|
+
// the caller DESCRIBED goes to `warnIfDropped` below: a title this side invented is not one
|
|
351
|
+
// a person will miss, and a deployment older than the field must not be nagged on every run.
|
|
352
|
+
title: defaultRunTitle(),
|
|
301
353
|
...described
|
|
302
354
|
});
|
|
303
355
|
if (start.ok) {
|
|
@@ -322,9 +374,11 @@ async function startRun(passed, expected) {
|
|
|
322
374
|
if (!offerDiscovered) return;
|
|
323
375
|
if (result.title === void 0 || result.specRef === void 0) return;
|
|
324
376
|
if (result.keys.length === 0) return;
|
|
377
|
+
const title = boundTitle(result.title);
|
|
378
|
+
if (title !== result.title) shortened.add(result.title);
|
|
325
379
|
discoveries.set(result.keys[0].value, {
|
|
326
380
|
keys: result.keys,
|
|
327
|
-
title
|
|
381
|
+
title,
|
|
328
382
|
source: result.source,
|
|
329
383
|
specRef: result.specRef,
|
|
330
384
|
rawStatus: result.rawStatus
|
|
@@ -346,6 +400,7 @@ async function startRun(passed, expected) {
|
|
|
346
400
|
return;
|
|
347
401
|
}
|
|
348
402
|
stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
|
|
403
|
+
stats.created += out.body.results.filter((r) => r.outcome === "created").length;
|
|
349
404
|
}
|
|
350
405
|
}
|
|
351
406
|
async function flush() {
|
|
@@ -411,7 +466,9 @@ async function startRun(passed, expected) {
|
|
|
411
466
|
if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
|
|
412
467
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
413
468
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
469
|
+
if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
|
|
414
470
|
if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
|
|
471
|
+
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
415
472
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
416
473
|
}
|
|
417
474
|
return {
|
|
@@ -425,7 +482,7 @@ async function startRun(passed, expected) {
|
|
|
425
482
|
return stats;
|
|
426
483
|
},
|
|
427
484
|
async add(result) {
|
|
428
|
-
buffer.push(result);
|
|
485
|
+
buffer.push({ ...result, keys: bound(result.keys) });
|
|
429
486
|
if (buffer.length >= batchSize) await flush();
|
|
430
487
|
},
|
|
431
488
|
flush,
|
|
@@ -457,9 +514,9 @@ async function startRun(passed, expected) {
|
|
|
457
514
|
}
|
|
458
515
|
|
|
459
516
|
// src/util/hash.ts
|
|
460
|
-
var
|
|
517
|
+
var import_node_crypto2 = require("crypto");
|
|
461
518
|
function sha256(input) {
|
|
462
|
-
return (0,
|
|
519
|
+
return (0, import_node_crypto2.createHash)("sha256").update(input, "utf8").digest("hex");
|
|
463
520
|
}
|
|
464
521
|
|
|
465
522
|
// src/reporter-core/result-key.ts
|
package/dist/reporter-core.d.cts
CHANGED
|
@@ -139,6 +139,16 @@ interface RunStats extends SubmitCounts {
|
|
|
139
139
|
/** Unresolved tests offered to the review queue under `PLUNE_CREATE=1` (D14). Counted separately
|
|
140
140
|
* from `unresolved` because they are the same tests seen twice, not a second population. */
|
|
141
141
|
offered: number;
|
|
142
|
+
/**
|
|
143
|
+
* Offered tests the platform turned into cases outright, because the project trusts this source
|
|
144
|
+
* (platform ADR 0030).
|
|
145
|
+
*
|
|
146
|
+
* Its own number rather than part of `offered`: these are the opposite of work to do. `offered`
|
|
147
|
+
* means "somebody has to look at this"; `created` means "nobody has to, and here is what appeared
|
|
148
|
+
* in your project". Folding them together would hide the larger of the two facts under a word
|
|
149
|
+
* that asks for attention nobody owes.
|
|
150
|
+
*/
|
|
151
|
+
created: number;
|
|
142
152
|
/**
|
|
143
153
|
* Unmatched tests that were NOT offered, because the queue would not take them (#627).
|
|
144
154
|
*
|
|
@@ -205,7 +215,7 @@ interface RunSession {
|
|
|
205
215
|
/** Send what is left and deliberately leave the run open — the shard case (AC-03). */
|
|
206
216
|
leaveOpen(): Promise<void>;
|
|
207
217
|
}
|
|
208
|
-
declare function startRun(passed: ReporterConfig,
|
|
218
|
+
declare function startRun(passed: ReporterConfig, declared?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
|
|
209
219
|
|
|
210
220
|
/**
|
|
211
221
|
* The ceiling the platform's contract puts on `resultKey` (`resultSubmissionSchema`).
|
|
@@ -341,6 +351,13 @@ interface ClientOptions {
|
|
|
341
351
|
}
|
|
342
352
|
interface PlatformClient {
|
|
343
353
|
post<T>(path: string, body: unknown): Promise<ClientOutcome<T>>;
|
|
354
|
+
/**
|
|
355
|
+
* A DELETE, through the same retries, the same classification and the same token scrubbing as a
|
|
356
|
+
* POST. It is here rather than as a bare `fetch` in the one command that needs it because all
|
|
357
|
+
* three of those matter at least as much for a delete: an unclassified 404 reads as an outage, and
|
|
358
|
+
* a proxy error page that echoes the request would put a token in a CI log.
|
|
359
|
+
*/
|
|
360
|
+
del<T>(path: string): Promise<ClientOutcome<T>>;
|
|
344
361
|
}
|
|
345
362
|
declare function createClient(opts: ClientOptions): PlatformClient;
|
|
346
363
|
|
package/dist/reporter-core.d.ts
CHANGED
|
@@ -139,6 +139,16 @@ interface RunStats extends SubmitCounts {
|
|
|
139
139
|
/** Unresolved tests offered to the review queue under `PLUNE_CREATE=1` (D14). Counted separately
|
|
140
140
|
* from `unresolved` because they are the same tests seen twice, not a second population. */
|
|
141
141
|
offered: number;
|
|
142
|
+
/**
|
|
143
|
+
* Offered tests the platform turned into cases outright, because the project trusts this source
|
|
144
|
+
* (platform ADR 0030).
|
|
145
|
+
*
|
|
146
|
+
* Its own number rather than part of `offered`: these are the opposite of work to do. `offered`
|
|
147
|
+
* means "somebody has to look at this"; `created` means "nobody has to, and here is what appeared
|
|
148
|
+
* in your project". Folding them together would hide the larger of the two facts under a word
|
|
149
|
+
* that asks for attention nobody owes.
|
|
150
|
+
*/
|
|
151
|
+
created: number;
|
|
142
152
|
/**
|
|
143
153
|
* Unmatched tests that were NOT offered, because the queue would not take them (#627).
|
|
144
154
|
*
|
|
@@ -205,7 +215,7 @@ interface RunSession {
|
|
|
205
215
|
/** Send what is left and deliberately leave the run open — the shard case (AC-03). */
|
|
206
216
|
leaveOpen(): Promise<void>;
|
|
207
217
|
}
|
|
208
|
-
declare function startRun(passed: ReporterConfig,
|
|
218
|
+
declare function startRun(passed: ReporterConfig, declared?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
|
|
209
219
|
|
|
210
220
|
/**
|
|
211
221
|
* The ceiling the platform's contract puts on `resultKey` (`resultSubmissionSchema`).
|
|
@@ -341,6 +351,13 @@ interface ClientOptions {
|
|
|
341
351
|
}
|
|
342
352
|
interface PlatformClient {
|
|
343
353
|
post<T>(path: string, body: unknown): Promise<ClientOutcome<T>>;
|
|
354
|
+
/**
|
|
355
|
+
* A DELETE, through the same retries, the same classification and the same token scrubbing as a
|
|
356
|
+
* POST. It is here rather than as a bare `fetch` in the one command that needs it because all
|
|
357
|
+
* three of those matter at least as much for a delete: an unclassified 404 reads as an outage, and
|
|
358
|
+
* a proxy error page that echoes the request would put a token in a CI log.
|
|
359
|
+
*/
|
|
360
|
+
del<T>(path: string): Promise<ClientOutcome<T>>;
|
|
344
361
|
}
|
|
345
362
|
declare function createClient(opts: ClientOptions): PlatformClient;
|
|
346
363
|
|
package/dist/reporter-core.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// src/reporter-core/session.ts
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
|
|
1
4
|
// src/cli/api-url.ts
|
|
2
5
|
var DEFAULT_API_URL = "https://beta-api.plune.ai";
|
|
3
6
|
function resolveApiUrl(explicit) {
|
|
@@ -51,7 +54,7 @@ function createClient(opts) {
|
|
|
51
54
|
const wait = opts.wait ?? sleep;
|
|
52
55
|
const base = opts.apiUrl.replace(/\/+$/, "");
|
|
53
56
|
const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
|
|
54
|
-
async function
|
|
57
|
+
async function send(method, path3, body) {
|
|
55
58
|
let last = {
|
|
56
59
|
kind: "unavailable",
|
|
57
60
|
status: null,
|
|
@@ -60,17 +63,23 @@ function createClient(opts) {
|
|
|
60
63
|
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
|
|
61
64
|
let res;
|
|
62
65
|
try {
|
|
63
|
-
res = await doFetch(`${base}${
|
|
64
|
-
method
|
|
66
|
+
res = await doFetch(`${base}${path3}`, {
|
|
67
|
+
method,
|
|
65
68
|
headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
|
|
66
|
-
|
|
69
|
+
// A DELETE carries none. Sending `"undefined"` as a body is what a naive
|
|
70
|
+
// `JSON.stringify(body)` would do, and some proxies answer that with a 400 nobody can
|
|
71
|
+
// explain.
|
|
72
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
67
73
|
});
|
|
68
74
|
} catch (err) {
|
|
69
75
|
last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
|
|
70
76
|
if (attempt < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[attempt]);
|
|
71
77
|
continue;
|
|
72
78
|
}
|
|
73
|
-
if (res.ok)
|
|
79
|
+
if (res.ok) {
|
|
80
|
+
if (res.status === 204) return { ok: true, status: res.status, body: void 0 };
|
|
81
|
+
return { ok: true, status: res.status, body: await res.json() };
|
|
82
|
+
}
|
|
74
83
|
const detail = scrub(await detailOf(res));
|
|
75
84
|
const kind = classify(res.status);
|
|
76
85
|
last = { kind, status: res.status, detail };
|
|
@@ -81,10 +90,14 @@ function createClient(opts) {
|
|
|
81
90
|
}
|
|
82
91
|
return { ok: false, ...last };
|
|
83
92
|
}
|
|
84
|
-
return {
|
|
93
|
+
return {
|
|
94
|
+
post: (path3, body) => send("POST", path3, body),
|
|
95
|
+
del: (path3) => send("DELETE", path3)
|
|
96
|
+
};
|
|
85
97
|
}
|
|
86
98
|
|
|
87
99
|
// src/reporter-core/env.ts
|
|
100
|
+
import * as path from "path";
|
|
88
101
|
var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
|
|
89
102
|
function value(env, name) {
|
|
90
103
|
const raw = env[name]?.trim();
|
|
@@ -137,13 +150,19 @@ function readEnv(env = process.env) {
|
|
|
137
150
|
ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0)
|
|
138
151
|
};
|
|
139
152
|
}
|
|
153
|
+
function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cwd = process.cwd()) {
|
|
154
|
+
const two = (n) => String(n).padStart(2, "0");
|
|
155
|
+
const stamp = `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())} ${two(now.getHours())}:${two(now.getMinutes())}`;
|
|
156
|
+
const where = flag(env, "CI") || flag(env, "GITHUB_ACTIONS") ? "ci" : "local";
|
|
157
|
+
return `${path.basename(cwd)} \xB7 ${stamp} \xB7 ${where}`;
|
|
158
|
+
}
|
|
140
159
|
|
|
141
160
|
// src/reporter-core/fallback.ts
|
|
142
161
|
import * as fs from "fs";
|
|
143
|
-
import * as
|
|
144
|
-
var DEFAULT_FALLBACK_PATH =
|
|
162
|
+
import * as path2 from "path";
|
|
163
|
+
var DEFAULT_FALLBACK_PATH = path2.join(".plune", "pending-results.jsonl");
|
|
145
164
|
function appendBatch(file, batch) {
|
|
146
|
-
fs.mkdirSync(
|
|
165
|
+
fs.mkdirSync(path2.dirname(file), { recursive: true });
|
|
147
166
|
const line = {
|
|
148
167
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
149
168
|
runId: batch.runId,
|
|
@@ -160,6 +179,18 @@ var BATCH_MAX = 500;
|
|
|
160
179
|
var DISCOVER_MAX = 500;
|
|
161
180
|
var BATCH_DEFAULT = 100;
|
|
162
181
|
var EXPECTED_MAX = 1e4;
|
|
182
|
+
var KEY_MAX = 1024;
|
|
183
|
+
var TITLE_MAX = 300;
|
|
184
|
+
function boundKey(key) {
|
|
185
|
+
if (key.value.length <= KEY_MAX) return key;
|
|
186
|
+
const digest = createHash("sha256").update(key.value).digest("hex").slice(0, 16);
|
|
187
|
+
let head = key.value.slice(0, KEY_MAX - digest.length - 1);
|
|
188
|
+
if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
|
|
189
|
+
return { ...key, value: `${head}#${digest}` };
|
|
190
|
+
}
|
|
191
|
+
function boundTitle(title) {
|
|
192
|
+
return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
|
|
193
|
+
}
|
|
163
194
|
function chunk(items, size) {
|
|
164
195
|
const out = [];
|
|
165
196
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
@@ -173,7 +204,7 @@ function warnIfDropped(sent, run, log) {
|
|
|
173
204
|
`plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
|
|
174
205
|
);
|
|
175
206
|
}
|
|
176
|
-
async function startRun(passed,
|
|
207
|
+
async function startRun(passed, declared) {
|
|
177
208
|
const env = readEnv();
|
|
178
209
|
const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
|
|
179
210
|
const cfg = { ...env.config, ...stated };
|
|
@@ -196,6 +227,7 @@ async function startRun(passed, expected) {
|
|
|
196
227
|
rejected: 0,
|
|
197
228
|
unresolved: 0,
|
|
198
229
|
offered: 0,
|
|
230
|
+
created: 0,
|
|
199
231
|
unoffered: 0,
|
|
200
232
|
deferred: 0
|
|
201
233
|
};
|
|
@@ -206,6 +238,22 @@ async function startRun(passed, expected) {
|
|
|
206
238
|
let joined = false;
|
|
207
239
|
let offline = token === "";
|
|
208
240
|
let done = false;
|
|
241
|
+
const shortened = /* @__PURE__ */ new Set();
|
|
242
|
+
function bound(keys) {
|
|
243
|
+
return keys.map((key) => {
|
|
244
|
+
const fit = boundKey(key);
|
|
245
|
+
if (fit !== key && !shortened.has(key.value)) {
|
|
246
|
+
shortened.add(key.value);
|
|
247
|
+
if (shortened.size === 1) {
|
|
248
|
+
log(
|
|
249
|
+
`plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 "${key.value.slice(0, 80)}\u2026". Give the test a shorter name; until then this is its identity.`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return fit;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
const expected = declared?.map(bound);
|
|
209
257
|
if (offline) {
|
|
210
258
|
log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
|
|
211
259
|
}
|
|
@@ -233,9 +281,9 @@ async function startRun(passed, expected) {
|
|
|
233
281
|
}
|
|
234
282
|
if (!offline) {
|
|
235
283
|
if (expected !== void 0) await resolveKeys(expected.flat());
|
|
236
|
-
const
|
|
237
|
-
const configuration =
|
|
238
|
-
expected:
|
|
284
|
+
const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
|
|
285
|
+
const configuration = stated2.length === 0 ? void 0 : {
|
|
286
|
+
expected: stated2.flatMap((keys) => {
|
|
239
287
|
const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
|
|
240
288
|
if (typeof hit === "string") return [{ testCaseId: hit }];
|
|
241
289
|
return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
|
|
@@ -255,6 +303,10 @@ async function startRun(passed, expected) {
|
|
|
255
303
|
...externalKey !== null ? { externalKey } : {},
|
|
256
304
|
...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
|
|
257
305
|
...configuration !== void 0 ? { configuration } : {},
|
|
306
|
+
// Named even when nobody named it — the list has to read without opening a row. Only what
|
|
307
|
+
// the caller DESCRIBED goes to `warnIfDropped` below: a title this side invented is not one
|
|
308
|
+
// a person will miss, and a deployment older than the field must not be nagged on every run.
|
|
309
|
+
title: defaultRunTitle(),
|
|
258
310
|
...described
|
|
259
311
|
});
|
|
260
312
|
if (start.ok) {
|
|
@@ -279,9 +331,11 @@ async function startRun(passed, expected) {
|
|
|
279
331
|
if (!offerDiscovered) return;
|
|
280
332
|
if (result.title === void 0 || result.specRef === void 0) return;
|
|
281
333
|
if (result.keys.length === 0) return;
|
|
334
|
+
const title = boundTitle(result.title);
|
|
335
|
+
if (title !== result.title) shortened.add(result.title);
|
|
282
336
|
discoveries.set(result.keys[0].value, {
|
|
283
337
|
keys: result.keys,
|
|
284
|
-
title
|
|
338
|
+
title,
|
|
285
339
|
source: result.source,
|
|
286
340
|
specRef: result.specRef,
|
|
287
341
|
rawStatus: result.rawStatus
|
|
@@ -303,6 +357,7 @@ async function startRun(passed, expected) {
|
|
|
303
357
|
return;
|
|
304
358
|
}
|
|
305
359
|
stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
|
|
360
|
+
stats.created += out.body.results.filter((r) => r.outcome === "created").length;
|
|
306
361
|
}
|
|
307
362
|
}
|
|
308
363
|
async function flush() {
|
|
@@ -368,7 +423,9 @@ async function startRun(passed, expected) {
|
|
|
368
423
|
if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
|
|
369
424
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
370
425
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
426
|
+
if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
|
|
371
427
|
if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
|
|
428
|
+
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
372
429
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
373
430
|
}
|
|
374
431
|
return {
|
|
@@ -382,7 +439,7 @@ async function startRun(passed, expected) {
|
|
|
382
439
|
return stats;
|
|
383
440
|
},
|
|
384
441
|
async add(result) {
|
|
385
|
-
buffer.push(result);
|
|
442
|
+
buffer.push({ ...result, keys: bound(result.keys) });
|
|
386
443
|
if (buffer.length >= batchSize) await flush();
|
|
387
444
|
},
|
|
388
445
|
flush,
|
|
@@ -414,9 +471,9 @@ async function startRun(passed, expected) {
|
|
|
414
471
|
}
|
|
415
472
|
|
|
416
473
|
// src/util/hash.ts
|
|
417
|
-
import { createHash } from "crypto";
|
|
474
|
+
import { createHash as createHash2 } from "crypto";
|
|
418
475
|
function sha256(input) {
|
|
419
|
-
return
|
|
476
|
+
return createHash2("sha256").update(input, "utf8").digest("hex");
|
|
420
477
|
}
|
|
421
478
|
|
|
422
479
|
// src/reporter-core/result-key.ts
|
package/package.json
CHANGED