@plune-ai/cli 0.9.0 → 0.10.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 +27 -6
- package/dist/cli.cjs +120 -19
- package/dist/reporter-core.cjs +77 -16
- package/dist/reporter-core.d.cts +26 -1
- package/dist/reporter-core.d.ts +26 -1
- package/dist/reporter-core.js +77 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -103,7 +103,12 @@ provider API key is read from the environment based on `provider.type`:
|
|
|
103
103
|
| `plune login` | Save a [Plune platform](https://plune.ai/platform) API token so `sync` and `ingest` can reach it. The token is **checked against the API before it is saved**, so a wrong one fails here rather than two commands later. Get one at `https://beta.plune.ai` → Settings → API tokens. Flags: `--token <token>` (omit to paste it or pipe it via stdin), `--skip-verify` (save without checking, for offline setup). |
|
|
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
|
-
| `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, `--create` to offer unmatched tests to the review queue. |
|
|
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`.
|
|
@@ -135,14 +140,30 @@ which reports as the run happens and needs no second step:
|
|
|
135
140
|
reporter: [['list'], ['@plune-ai/playwright']],
|
|
136
141
|
```
|
|
137
142
|
|
|
138
|
-
|
|
139
|
-
|
|
143
|
+
### Several jobs, one run
|
|
144
|
+
|
|
145
|
+
Two suites, or a sharded matrix, report into a single run when every job shares a key **and** knows
|
|
146
|
+
it is not the last one:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
PLUNE_SHARED_RUN=1 plune run import ./junit.xml --key "$GITHUB_RUN_ID"
|
|
150
|
+
PLUNE_SHARED_RUN=1 plune run import ./app/junit.xml --key "$GITHUB_RUN_ID"
|
|
151
|
+
plune run finish "$RUN_ID" # once, when they are all done
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`PLUNE_SHARED_RUN=1` is what stops a job closing a run its siblings are still reporting into; the
|
|
155
|
+
key alone says where the results go, not who ends the run. Leave it unset in the last job and that
|
|
156
|
+
job closes the run instead of the explicit `finish`.
|
|
157
|
+
|
|
158
|
+
`plune run exec` sets it for you — anything reporting inside it, this command included, joins
|
|
159
|
+
without closing.
|
|
140
160
|
|
|
141
161
|
## Optional: keep a history
|
|
142
162
|
|
|
143
|
-
Everything above works with no account, no network, and no token — that does not change.
|
|
144
|
-
|
|
145
|
-
|
|
163
|
+
Everything above works with no account, no network, and no token — that does not change. `run`,
|
|
164
|
+
`report`, `diff` and `init` never open a socket, and that is the half this promise is about. If you
|
|
165
|
+
also want run history, trends, and a shared dashboard, the commands below push your local runs to
|
|
166
|
+
the [Plune platform](https://plune.ai/platform):
|
|
146
167
|
|
|
147
168
|
```bash
|
|
148
169
|
plune login # paste the API token from your platform settings page
|
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, path12, body) {
|
|
2214
2214
|
let last = {
|
|
2215
2215
|
kind: "unavailable",
|
|
2216
2216
|
status: null,
|
|
@@ -2220,16 +2220,22 @@ function createClient(opts) {
|
|
|
2220
2220
|
let res;
|
|
2221
2221
|
try {
|
|
2222
2222
|
res = await doFetch(`${base}${path12}`, {
|
|
2223
|
-
method
|
|
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: (path12, body) => send("POST", path12, body),
|
|
2251
|
+
del: (path12) => send("DELETE", path12)
|
|
2252
|
+
};
|
|
2244
2253
|
}
|
|
2245
2254
|
var RETRY_DELAYS_MS, sleep;
|
|
2246
2255
|
var init_client = __esm({
|
|
@@ -2337,6 +2346,16 @@ var init_fallback = __esm({
|
|
|
2337
2346
|
});
|
|
2338
2347
|
|
|
2339
2348
|
// src/reporter-core/session.ts
|
|
2349
|
+
function boundKey(key) {
|
|
2350
|
+
if (key.value.length <= KEY_MAX) return key;
|
|
2351
|
+
const digest = (0, import_node_crypto2.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
|
|
2352
|
+
let head = key.value.slice(0, KEY_MAX - digest.length - 1);
|
|
2353
|
+
if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
|
|
2354
|
+
return { ...key, value: `${head}#${digest}` };
|
|
2355
|
+
}
|
|
2356
|
+
function boundTitle(title) {
|
|
2357
|
+
return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
|
|
2358
|
+
}
|
|
2340
2359
|
function chunk(items, size) {
|
|
2341
2360
|
const out = [];
|
|
2342
2361
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
@@ -2350,7 +2369,7 @@ function warnIfDropped(sent, run, log) {
|
|
|
2350
2369
|
`plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
|
|
2351
2370
|
);
|
|
2352
2371
|
}
|
|
2353
|
-
async function startRun(passed,
|
|
2372
|
+
async function startRun(passed, declared) {
|
|
2354
2373
|
const env = readEnv();
|
|
2355
2374
|
const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
|
|
2356
2375
|
const cfg = { ...env.config, ...stated };
|
|
@@ -2373,6 +2392,8 @@ async function startRun(passed, expected) {
|
|
|
2373
2392
|
rejected: 0,
|
|
2374
2393
|
unresolved: 0,
|
|
2375
2394
|
offered: 0,
|
|
2395
|
+
created: 0,
|
|
2396
|
+
unoffered: 0,
|
|
2376
2397
|
deferred: 0
|
|
2377
2398
|
};
|
|
2378
2399
|
const resolved = /* @__PURE__ */ new Map();
|
|
@@ -2382,6 +2403,22 @@ async function startRun(passed, expected) {
|
|
|
2382
2403
|
let joined = false;
|
|
2383
2404
|
let offline = token === "";
|
|
2384
2405
|
let done = false;
|
|
2406
|
+
const shortened = /* @__PURE__ */ new Set();
|
|
2407
|
+
function bound(keys) {
|
|
2408
|
+
return keys.map((key) => {
|
|
2409
|
+
const fit = boundKey(key);
|
|
2410
|
+
if (fit !== key && !shortened.has(key.value)) {
|
|
2411
|
+
shortened.add(key.value);
|
|
2412
|
+
if (shortened.size === 1) {
|
|
2413
|
+
log(
|
|
2414
|
+
`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.`
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
return fit;
|
|
2419
|
+
});
|
|
2420
|
+
}
|
|
2421
|
+
const expected = declared?.map(bound);
|
|
2385
2422
|
if (offline) {
|
|
2386
2423
|
log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
|
|
2387
2424
|
}
|
|
@@ -2409,9 +2446,9 @@ async function startRun(passed, expected) {
|
|
|
2409
2446
|
}
|
|
2410
2447
|
if (!offline) {
|
|
2411
2448
|
if (expected !== void 0) await resolveKeys(expected.flat());
|
|
2412
|
-
const
|
|
2413
|
-
const configuration =
|
|
2414
|
-
expected:
|
|
2449
|
+
const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
|
|
2450
|
+
const configuration = stated2.length === 0 ? void 0 : {
|
|
2451
|
+
expected: stated2.flatMap((keys) => {
|
|
2415
2452
|
const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
|
|
2416
2453
|
if (typeof hit === "string") return [{ testCaseId: hit }];
|
|
2417
2454
|
return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
|
|
@@ -2455,9 +2492,11 @@ async function startRun(passed, expected) {
|
|
|
2455
2492
|
if (!offerDiscovered) return;
|
|
2456
2493
|
if (result.title === void 0 || result.specRef === void 0) return;
|
|
2457
2494
|
if (result.keys.length === 0) return;
|
|
2495
|
+
const title = boundTitle(result.title);
|
|
2496
|
+
if (title !== result.title) shortened.add(result.title);
|
|
2458
2497
|
discoveries.set(result.keys[0].value, {
|
|
2459
2498
|
keys: result.keys,
|
|
2460
|
-
title
|
|
2499
|
+
title,
|
|
2461
2500
|
source: result.source,
|
|
2462
2501
|
specRef: result.specRef,
|
|
2463
2502
|
rawStatus: result.rawStatus
|
|
@@ -2465,16 +2504,21 @@ async function startRun(passed, expected) {
|
|
|
2465
2504
|
}
|
|
2466
2505
|
async function offer() {
|
|
2467
2506
|
if (discoveries.size === 0 || offline || runId === null) return;
|
|
2468
|
-
|
|
2507
|
+
const batches = chunk([...discoveries.values()], DISCOVER_MAX);
|
|
2508
|
+
for (const [index, batch] of batches.entries()) {
|
|
2469
2509
|
const out = await client.post("/v1/review-items/discovered", {
|
|
2470
2510
|
discovered: batch,
|
|
2471
2511
|
runId
|
|
2472
2512
|
});
|
|
2473
2513
|
if (!out.ok) {
|
|
2474
|
-
|
|
2514
|
+
stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
|
|
2515
|
+
log(
|
|
2516
|
+
`plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.detail || out.kind}).`
|
|
2517
|
+
);
|
|
2475
2518
|
return;
|
|
2476
2519
|
}
|
|
2477
2520
|
stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
|
|
2521
|
+
stats.created += out.body.results.filter((r) => r.outcome === "created").length;
|
|
2478
2522
|
}
|
|
2479
2523
|
}
|
|
2480
2524
|
async function flush() {
|
|
@@ -2501,7 +2545,10 @@ async function startRun(passed, expected) {
|
|
|
2501
2545
|
}
|
|
2502
2546
|
}
|
|
2503
2547
|
if (unanswered.length > 0) defer(unanswered);
|
|
2504
|
-
if (submissions.length === 0)
|
|
2548
|
+
if (submissions.length === 0) {
|
|
2549
|
+
progress();
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2505
2552
|
const out = await client.post(
|
|
2506
2553
|
`/v1/runs/${runId}/results`,
|
|
2507
2554
|
{ results: submissions }
|
|
@@ -2511,6 +2558,7 @@ async function startRun(passed, expected) {
|
|
|
2511
2558
|
stats.duplicate += out.body.counts.duplicate ?? 0;
|
|
2512
2559
|
stats.conflict += out.body.counts.conflict ?? 0;
|
|
2513
2560
|
stats.rejected += out.body.counts.rejected ?? 0;
|
|
2561
|
+
progress();
|
|
2514
2562
|
return;
|
|
2515
2563
|
}
|
|
2516
2564
|
if (out.kind === "auth") {
|
|
@@ -2523,6 +2571,12 @@ async function startRun(passed, expected) {
|
|
|
2523
2571
|
}
|
|
2524
2572
|
defer(submissions);
|
|
2525
2573
|
}
|
|
2574
|
+
function progress() {
|
|
2575
|
+
const total = expected?.length ?? 0;
|
|
2576
|
+
if (total <= batchSize) return;
|
|
2577
|
+
const done2 = stats.accepted + stats.duplicate + stats.conflict + stats.rejected + stats.unresolved;
|
|
2578
|
+
log(`plune: ${Math.min(done2, total)} of ${total} results sent`);
|
|
2579
|
+
}
|
|
2526
2580
|
function summarise() {
|
|
2527
2581
|
const parts = [`${stats.accepted} accepted`];
|
|
2528
2582
|
if (stats.duplicate > 0) parts.push(`${stats.duplicate} already reported`);
|
|
@@ -2530,7 +2584,9 @@ async function startRun(passed, expected) {
|
|
|
2530
2584
|
if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
|
|
2531
2585
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
2532
2586
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
2587
|
+
if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
|
|
2533
2588
|
if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
|
|
2589
|
+
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
2534
2590
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
2535
2591
|
}
|
|
2536
2592
|
return {
|
|
@@ -2544,7 +2600,7 @@ async function startRun(passed, expected) {
|
|
|
2544
2600
|
return stats;
|
|
2545
2601
|
},
|
|
2546
2602
|
async add(result) {
|
|
2547
|
-
buffer.push(result);
|
|
2603
|
+
buffer.push({ ...result, keys: bound(result.keys) });
|
|
2548
2604
|
if (buffer.length >= batchSize) await flush();
|
|
2549
2605
|
},
|
|
2550
2606
|
flush,
|
|
@@ -2574,11 +2630,12 @@ async function startRun(passed, expected) {
|
|
|
2574
2630
|
}
|
|
2575
2631
|
};
|
|
2576
2632
|
}
|
|
2577
|
-
var RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX;
|
|
2633
|
+
var import_node_crypto2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX;
|
|
2578
2634
|
var init_session = __esm({
|
|
2579
2635
|
"src/reporter-core/session.ts"() {
|
|
2580
2636
|
"use strict";
|
|
2581
2637
|
init_cjs_shims();
|
|
2638
|
+
import_node_crypto2 = require("crypto");
|
|
2582
2639
|
init_api_url();
|
|
2583
2640
|
init_credentials();
|
|
2584
2641
|
init_client();
|
|
@@ -2589,6 +2646,8 @@ var init_session = __esm({
|
|
|
2589
2646
|
DISCOVER_MAX = 500;
|
|
2590
2647
|
BATCH_DEFAULT = 100;
|
|
2591
2648
|
EXPECTED_MAX = 1e4;
|
|
2649
|
+
KEY_MAX = 1024;
|
|
2650
|
+
TITLE_MAX = 300;
|
|
2592
2651
|
}
|
|
2593
2652
|
});
|
|
2594
2653
|
|
|
@@ -2597,6 +2656,7 @@ var run_lifecycle_exports = {};
|
|
|
2597
2656
|
__export(run_lifecycle_exports, {
|
|
2598
2657
|
NoTokenError: () => NoTokenError,
|
|
2599
2658
|
RunCommandError: () => RunCommandError,
|
|
2659
|
+
handleRunDelete: () => handleRunDelete,
|
|
2600
2660
|
handleRunExec: () => handleRunExec,
|
|
2601
2661
|
handleRunFinish: () => handleRunFinish,
|
|
2602
2662
|
handleRunReport: () => handleRunReport,
|
|
@@ -2660,6 +2720,22 @@ async function handleRunFinish(id, options = {}) {
|
|
|
2660
2720
|
}
|
|
2661
2721
|
write(`Run ${id} ${event === "finish" ? "finished" : "terminated"}.`);
|
|
2662
2722
|
}
|
|
2723
|
+
async function handleRunDelete(id, options = {}) {
|
|
2724
|
+
const { client, write } = connect(options);
|
|
2725
|
+
const out = await client.del(`/v1/runs/${encodeURIComponent(id)}`);
|
|
2726
|
+
if (!out.ok) {
|
|
2727
|
+
throw new RunCommandError(
|
|
2728
|
+
out.status === 404 ? (
|
|
2729
|
+
// Three causes, one answer, and the message names all three because the platform will not:
|
|
2730
|
+
// a 404 that distinguished "not yours" from "not there" would tell a stranger which ids
|
|
2731
|
+
// exist. The person holding the terminal is better served by the list than by a guess.
|
|
2732
|
+
`No run '${id}' \u2014 check the id, whether the token belongs to the same project, or whether it is already deleted.`
|
|
2733
|
+
) : `Could not delete run ${id} \u2014 ${out.detail || out.kind}.`
|
|
2734
|
+
);
|
|
2735
|
+
}
|
|
2736
|
+
write(`Run ${id} deleted, with its results and the review-queue entries it produced.`);
|
|
2737
|
+
write("Recoverable for six months \u2014 ask Plune to put it back.");
|
|
2738
|
+
}
|
|
2663
2739
|
async function handleRunExec(options) {
|
|
2664
2740
|
const [command, ...args] = options.argv;
|
|
2665
2741
|
if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
|
|
@@ -3180,7 +3256,8 @@ async function handleRunImport(options) {
|
|
|
3180
3256
|
results.map((r) => r.keys)
|
|
3181
3257
|
);
|
|
3182
3258
|
for (const result2 of results) await session.add(result2);
|
|
3183
|
-
await session.
|
|
3259
|
+
if (env.keepOpen) await session.leaveOpen();
|
|
3260
|
+
else await session.finish();
|
|
3184
3261
|
const stats = session.stats;
|
|
3185
3262
|
const result = {
|
|
3186
3263
|
runId: session.runId,
|
|
@@ -3192,6 +3269,8 @@ async function handleRunImport(options) {
|
|
|
3192
3269
|
rejected: stats.rejected,
|
|
3193
3270
|
unresolved: stats.unresolved,
|
|
3194
3271
|
offered: stats.offered,
|
|
3272
|
+
created: stats.created,
|
|
3273
|
+
unoffered: stats.unoffered,
|
|
3195
3274
|
deferred: stats.deferred,
|
|
3196
3275
|
unlocatable: results.filter((r) => r.specRef === void 0).length,
|
|
3197
3276
|
offering: options.create === true
|
|
@@ -3205,7 +3284,11 @@ function runUrl2(apiUrl, id) {
|
|
|
3205
3284
|
}
|
|
3206
3285
|
function describe(result, apiUrl) {
|
|
3207
3286
|
const lines = [
|
|
3208
|
-
`Imported
|
|
3287
|
+
// `Read`, not `Imported`: the verb describes the FILE, which was read whatever happened next.
|
|
3288
|
+
// «Imported 555 result(s): 0 accepted, 0 already there, 0 unmatched» was a sentence whose two
|
|
3289
|
+
// halves disagreed, and readers stop at the verb — a run that reached nobody read as one that
|
|
3290
|
+
// worked (#622).
|
|
3291
|
+
`Read ${result.parsed} result(s) from a ${result.format} report: ${result.accepted} accepted, ${result.duplicate} already there, ${result.unresolved} unmatched.`
|
|
3209
3292
|
];
|
|
3210
3293
|
if (result.conflict > 0 || result.rejected > 0) {
|
|
3211
3294
|
lines.push(`${result.conflict} conflicted and ${result.rejected} were refused.`);
|
|
@@ -3215,13 +3298,23 @@ function describe(result, apiUrl) {
|
|
|
3215
3298
|
`${result.deferred} could not be sent and are in the fallback file \u2014 "plune run report" retries them.`
|
|
3216
3299
|
);
|
|
3217
3300
|
}
|
|
3301
|
+
if (result.created > 0) {
|
|
3302
|
+
lines.push(
|
|
3303
|
+
`${result.created} unknown test(s) became cases without review \u2014 this project trusts the source. Change that under Settings \u2192 Trusted sources.`
|
|
3304
|
+
);
|
|
3305
|
+
}
|
|
3218
3306
|
if (result.offered > 0) {
|
|
3219
3307
|
lines.push(`${result.offered} unknown test(s) offered to the review queue.`);
|
|
3220
|
-
} else if (result.unresolved > 0 && !result.offering) {
|
|
3308
|
+
} else if (result.unresolved > 0 && result.created === 0 && !result.offering) {
|
|
3221
3309
|
lines.push("Nothing was offered to the review queue \u2014 pass --create to propose the unmatched tests.");
|
|
3222
|
-
} else if (result.unresolved > 0) {
|
|
3310
|
+
} else if (result.unresolved > 0 && result.created === 0 && result.unoffered === 0) {
|
|
3223
3311
|
lines.push("Nothing new to offer \u2014 the unmatched tests are already in the review queue.");
|
|
3224
3312
|
}
|
|
3313
|
+
if (result.unoffered > 0) {
|
|
3314
|
+
lines.push(
|
|
3315
|
+
`${result.unoffered} more could not be offered \u2014 the review queue is full. Approve or reject what is waiting, then import this report again to offer the rest.`
|
|
3316
|
+
);
|
|
3317
|
+
}
|
|
3225
3318
|
if (result.unlocatable > 0) {
|
|
3226
3319
|
lines.push(
|
|
3227
3320
|
`${result.unlocatable} test(s) carry no file or class in the report, so they cannot be offered for review \u2014 a reviewer would have nowhere to look.`
|
|
@@ -4402,6 +4495,14 @@ function createProgram() {
|
|
|
4402
4495
|
await failRunCommand(err, verboseOf(command));
|
|
4403
4496
|
}
|
|
4404
4497
|
});
|
|
4498
|
+
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) => {
|
|
4499
|
+
try {
|
|
4500
|
+
const { handleRunDelete: handleRunDelete2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
|
|
4501
|
+
await handleRunDelete2(id);
|
|
4502
|
+
} catch (err) {
|
|
4503
|
+
await failRunCommand(err, verboseOf(command));
|
|
4504
|
+
}
|
|
4505
|
+
});
|
|
4405
4506
|
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) => {
|
|
4406
4507
|
try {
|
|
4407
4508
|
const { handleRunReport: handleRunReport2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
|
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, path2, body) {
|
|
98
101
|
let last = {
|
|
99
102
|
kind: "unavailable",
|
|
100
103
|
status: null,
|
|
@@ -104,16 +107,22 @@ function createClient(opts) {
|
|
|
104
107
|
let res;
|
|
105
108
|
try {
|
|
106
109
|
res = await doFetch(`${base}${path2}`, {
|
|
107
|
-
method
|
|
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,7 +133,10 @@ function createClient(opts) {
|
|
|
124
133
|
}
|
|
125
134
|
return { ok: false, ...last };
|
|
126
135
|
}
|
|
127
|
-
return {
|
|
136
|
+
return {
|
|
137
|
+
post: (path2, body) => send("POST", path2, body),
|
|
138
|
+
del: (path2) => send("DELETE", path2)
|
|
139
|
+
};
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
// src/reporter-core/env.ts
|
|
@@ -203,6 +215,18 @@ var BATCH_MAX = 500;
|
|
|
203
215
|
var DISCOVER_MAX = 500;
|
|
204
216
|
var BATCH_DEFAULT = 100;
|
|
205
217
|
var EXPECTED_MAX = 1e4;
|
|
218
|
+
var KEY_MAX = 1024;
|
|
219
|
+
var TITLE_MAX = 300;
|
|
220
|
+
function boundKey(key) {
|
|
221
|
+
if (key.value.length <= KEY_MAX) return key;
|
|
222
|
+
const digest = (0, import_node_crypto.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
|
|
223
|
+
let head = key.value.slice(0, KEY_MAX - digest.length - 1);
|
|
224
|
+
if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
|
|
225
|
+
return { ...key, value: `${head}#${digest}` };
|
|
226
|
+
}
|
|
227
|
+
function boundTitle(title) {
|
|
228
|
+
return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
|
|
229
|
+
}
|
|
206
230
|
function chunk(items, size) {
|
|
207
231
|
const out = [];
|
|
208
232
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
@@ -216,7 +240,7 @@ function warnIfDropped(sent, run, log) {
|
|
|
216
240
|
`plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
|
|
217
241
|
);
|
|
218
242
|
}
|
|
219
|
-
async function startRun(passed,
|
|
243
|
+
async function startRun(passed, declared) {
|
|
220
244
|
const env = readEnv();
|
|
221
245
|
const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
|
|
222
246
|
const cfg = { ...env.config, ...stated };
|
|
@@ -239,6 +263,8 @@ async function startRun(passed, expected) {
|
|
|
239
263
|
rejected: 0,
|
|
240
264
|
unresolved: 0,
|
|
241
265
|
offered: 0,
|
|
266
|
+
created: 0,
|
|
267
|
+
unoffered: 0,
|
|
242
268
|
deferred: 0
|
|
243
269
|
};
|
|
244
270
|
const resolved = /* @__PURE__ */ new Map();
|
|
@@ -248,6 +274,22 @@ async function startRun(passed, expected) {
|
|
|
248
274
|
let joined = false;
|
|
249
275
|
let offline = token === "";
|
|
250
276
|
let done = false;
|
|
277
|
+
const shortened = /* @__PURE__ */ new Set();
|
|
278
|
+
function bound(keys) {
|
|
279
|
+
return keys.map((key) => {
|
|
280
|
+
const fit = boundKey(key);
|
|
281
|
+
if (fit !== key && !shortened.has(key.value)) {
|
|
282
|
+
shortened.add(key.value);
|
|
283
|
+
if (shortened.size === 1) {
|
|
284
|
+
log(
|
|
285
|
+
`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.`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return fit;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
const expected = declared?.map(bound);
|
|
251
293
|
if (offline) {
|
|
252
294
|
log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
|
|
253
295
|
}
|
|
@@ -275,9 +317,9 @@ async function startRun(passed, expected) {
|
|
|
275
317
|
}
|
|
276
318
|
if (!offline) {
|
|
277
319
|
if (expected !== void 0) await resolveKeys(expected.flat());
|
|
278
|
-
const
|
|
279
|
-
const configuration =
|
|
280
|
-
expected:
|
|
320
|
+
const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
|
|
321
|
+
const configuration = stated2.length === 0 ? void 0 : {
|
|
322
|
+
expected: stated2.flatMap((keys) => {
|
|
281
323
|
const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
|
|
282
324
|
if (typeof hit === "string") return [{ testCaseId: hit }];
|
|
283
325
|
return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
|
|
@@ -321,9 +363,11 @@ async function startRun(passed, expected) {
|
|
|
321
363
|
if (!offerDiscovered) return;
|
|
322
364
|
if (result.title === void 0 || result.specRef === void 0) return;
|
|
323
365
|
if (result.keys.length === 0) return;
|
|
366
|
+
const title = boundTitle(result.title);
|
|
367
|
+
if (title !== result.title) shortened.add(result.title);
|
|
324
368
|
discoveries.set(result.keys[0].value, {
|
|
325
369
|
keys: result.keys,
|
|
326
|
-
title
|
|
370
|
+
title,
|
|
327
371
|
source: result.source,
|
|
328
372
|
specRef: result.specRef,
|
|
329
373
|
rawStatus: result.rawStatus
|
|
@@ -331,16 +375,21 @@ async function startRun(passed, expected) {
|
|
|
331
375
|
}
|
|
332
376
|
async function offer() {
|
|
333
377
|
if (discoveries.size === 0 || offline || runId === null) return;
|
|
334
|
-
|
|
378
|
+
const batches = chunk([...discoveries.values()], DISCOVER_MAX);
|
|
379
|
+
for (const [index, batch] of batches.entries()) {
|
|
335
380
|
const out = await client.post("/v1/review-items/discovered", {
|
|
336
381
|
discovered: batch,
|
|
337
382
|
runId
|
|
338
383
|
});
|
|
339
384
|
if (!out.ok) {
|
|
340
|
-
|
|
385
|
+
stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
|
|
386
|
+
log(
|
|
387
|
+
`plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.detail || out.kind}).`
|
|
388
|
+
);
|
|
341
389
|
return;
|
|
342
390
|
}
|
|
343
391
|
stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
|
|
392
|
+
stats.created += out.body.results.filter((r) => r.outcome === "created").length;
|
|
344
393
|
}
|
|
345
394
|
}
|
|
346
395
|
async function flush() {
|
|
@@ -367,7 +416,10 @@ async function startRun(passed, expected) {
|
|
|
367
416
|
}
|
|
368
417
|
}
|
|
369
418
|
if (unanswered.length > 0) defer(unanswered);
|
|
370
|
-
if (submissions.length === 0)
|
|
419
|
+
if (submissions.length === 0) {
|
|
420
|
+
progress();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
371
423
|
const out = await client.post(
|
|
372
424
|
`/v1/runs/${runId}/results`,
|
|
373
425
|
{ results: submissions }
|
|
@@ -377,6 +429,7 @@ async function startRun(passed, expected) {
|
|
|
377
429
|
stats.duplicate += out.body.counts.duplicate ?? 0;
|
|
378
430
|
stats.conflict += out.body.counts.conflict ?? 0;
|
|
379
431
|
stats.rejected += out.body.counts.rejected ?? 0;
|
|
432
|
+
progress();
|
|
380
433
|
return;
|
|
381
434
|
}
|
|
382
435
|
if (out.kind === "auth") {
|
|
@@ -389,6 +442,12 @@ async function startRun(passed, expected) {
|
|
|
389
442
|
}
|
|
390
443
|
defer(submissions);
|
|
391
444
|
}
|
|
445
|
+
function progress() {
|
|
446
|
+
const total = expected?.length ?? 0;
|
|
447
|
+
if (total <= batchSize) return;
|
|
448
|
+
const done2 = stats.accepted + stats.duplicate + stats.conflict + stats.rejected + stats.unresolved;
|
|
449
|
+
log(`plune: ${Math.min(done2, total)} of ${total} results sent`);
|
|
450
|
+
}
|
|
392
451
|
function summarise() {
|
|
393
452
|
const parts = [`${stats.accepted} accepted`];
|
|
394
453
|
if (stats.duplicate > 0) parts.push(`${stats.duplicate} already reported`);
|
|
@@ -396,7 +455,9 @@ async function startRun(passed, expected) {
|
|
|
396
455
|
if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
|
|
397
456
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
398
457
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
458
|
+
if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
|
|
399
459
|
if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
|
|
460
|
+
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
400
461
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
401
462
|
}
|
|
402
463
|
return {
|
|
@@ -410,7 +471,7 @@ async function startRun(passed, expected) {
|
|
|
410
471
|
return stats;
|
|
411
472
|
},
|
|
412
473
|
async add(result) {
|
|
413
|
-
buffer.push(result);
|
|
474
|
+
buffer.push({ ...result, keys: bound(result.keys) });
|
|
414
475
|
if (buffer.length >= batchSize) await flush();
|
|
415
476
|
},
|
|
416
477
|
flush,
|
|
@@ -442,9 +503,9 @@ async function startRun(passed, expected) {
|
|
|
442
503
|
}
|
|
443
504
|
|
|
444
505
|
// src/util/hash.ts
|
|
445
|
-
var
|
|
506
|
+
var import_node_crypto2 = require("crypto");
|
|
446
507
|
function sha256(input) {
|
|
447
|
-
return (0,
|
|
508
|
+
return (0, import_node_crypto2.createHash)("sha256").update(input, "utf8").digest("hex");
|
|
448
509
|
}
|
|
449
510
|
|
|
450
511
|
// src/reporter-core/result-key.ts
|
package/dist/reporter-core.d.cts
CHANGED
|
@@ -139,6 +139,24 @@ 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;
|
|
152
|
+
/**
|
|
153
|
+
* Unmatched tests that were NOT offered, because the queue would not take them (#627).
|
|
154
|
+
*
|
|
155
|
+
* Separate from `offered` rather than a shortfall computed against `unresolved`: the reason a
|
|
156
|
+
* test went unoffered is the caller's next action, and a subtraction cannot carry a reason. A
|
|
157
|
+
* queue at capacity means "empty it and import again"; nothing else here means that.
|
|
158
|
+
*/
|
|
159
|
+
unoffered: number;
|
|
142
160
|
/** Results written to the fallback file instead of the platform. */
|
|
143
161
|
deferred: number;
|
|
144
162
|
}
|
|
@@ -197,7 +215,7 @@ interface RunSession {
|
|
|
197
215
|
/** Send what is left and deliberately leave the run open — the shard case (AC-03). */
|
|
198
216
|
leaveOpen(): Promise<void>;
|
|
199
217
|
}
|
|
200
|
-
declare function startRun(passed: ReporterConfig,
|
|
218
|
+
declare function startRun(passed: ReporterConfig, declared?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
|
|
201
219
|
|
|
202
220
|
/**
|
|
203
221
|
* The ceiling the platform's contract puts on `resultKey` (`resultSubmissionSchema`).
|
|
@@ -333,6 +351,13 @@ interface ClientOptions {
|
|
|
333
351
|
}
|
|
334
352
|
interface PlatformClient {
|
|
335
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>>;
|
|
336
361
|
}
|
|
337
362
|
declare function createClient(opts: ClientOptions): PlatformClient;
|
|
338
363
|
|
package/dist/reporter-core.d.ts
CHANGED
|
@@ -139,6 +139,24 @@ 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;
|
|
152
|
+
/**
|
|
153
|
+
* Unmatched tests that were NOT offered, because the queue would not take them (#627).
|
|
154
|
+
*
|
|
155
|
+
* Separate from `offered` rather than a shortfall computed against `unresolved`: the reason a
|
|
156
|
+
* test went unoffered is the caller's next action, and a subtraction cannot carry a reason. A
|
|
157
|
+
* queue at capacity means "empty it and import again"; nothing else here means that.
|
|
158
|
+
*/
|
|
159
|
+
unoffered: number;
|
|
142
160
|
/** Results written to the fallback file instead of the platform. */
|
|
143
161
|
deferred: number;
|
|
144
162
|
}
|
|
@@ -197,7 +215,7 @@ interface RunSession {
|
|
|
197
215
|
/** Send what is left and deliberately leave the run open — the shard case (AC-03). */
|
|
198
216
|
leaveOpen(): Promise<void>;
|
|
199
217
|
}
|
|
200
|
-
declare function startRun(passed: ReporterConfig,
|
|
218
|
+
declare function startRun(passed: ReporterConfig, declared?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
|
|
201
219
|
|
|
202
220
|
/**
|
|
203
221
|
* The ceiling the platform's contract puts on `resultKey` (`resultSubmissionSchema`).
|
|
@@ -333,6 +351,13 @@ interface ClientOptions {
|
|
|
333
351
|
}
|
|
334
352
|
interface PlatformClient {
|
|
335
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>>;
|
|
336
361
|
}
|
|
337
362
|
declare function createClient(opts: ClientOptions): PlatformClient;
|
|
338
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, path2, body) {
|
|
55
58
|
let last = {
|
|
56
59
|
kind: "unavailable",
|
|
57
60
|
status: null,
|
|
@@ -61,16 +64,22 @@ function createClient(opts) {
|
|
|
61
64
|
let res;
|
|
62
65
|
try {
|
|
63
66
|
res = await doFetch(`${base}${path2}`, {
|
|
64
|
-
method
|
|
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,7 +90,10 @@ function createClient(opts) {
|
|
|
81
90
|
}
|
|
82
91
|
return { ok: false, ...last };
|
|
83
92
|
}
|
|
84
|
-
return {
|
|
93
|
+
return {
|
|
94
|
+
post: (path2, body) => send("POST", path2, body),
|
|
95
|
+
del: (path2) => send("DELETE", path2)
|
|
96
|
+
};
|
|
85
97
|
}
|
|
86
98
|
|
|
87
99
|
// src/reporter-core/env.ts
|
|
@@ -160,6 +172,18 @@ var BATCH_MAX = 500;
|
|
|
160
172
|
var DISCOVER_MAX = 500;
|
|
161
173
|
var BATCH_DEFAULT = 100;
|
|
162
174
|
var EXPECTED_MAX = 1e4;
|
|
175
|
+
var KEY_MAX = 1024;
|
|
176
|
+
var TITLE_MAX = 300;
|
|
177
|
+
function boundKey(key) {
|
|
178
|
+
if (key.value.length <= KEY_MAX) return key;
|
|
179
|
+
const digest = createHash("sha256").update(key.value).digest("hex").slice(0, 16);
|
|
180
|
+
let head = key.value.slice(0, KEY_MAX - digest.length - 1);
|
|
181
|
+
if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
|
|
182
|
+
return { ...key, value: `${head}#${digest}` };
|
|
183
|
+
}
|
|
184
|
+
function boundTitle(title) {
|
|
185
|
+
return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
|
|
186
|
+
}
|
|
163
187
|
function chunk(items, size) {
|
|
164
188
|
const out = [];
|
|
165
189
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
@@ -173,7 +197,7 @@ function warnIfDropped(sent, run, log) {
|
|
|
173
197
|
`plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
|
|
174
198
|
);
|
|
175
199
|
}
|
|
176
|
-
async function startRun(passed,
|
|
200
|
+
async function startRun(passed, declared) {
|
|
177
201
|
const env = readEnv();
|
|
178
202
|
const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
|
|
179
203
|
const cfg = { ...env.config, ...stated };
|
|
@@ -196,6 +220,8 @@ async function startRun(passed, expected) {
|
|
|
196
220
|
rejected: 0,
|
|
197
221
|
unresolved: 0,
|
|
198
222
|
offered: 0,
|
|
223
|
+
created: 0,
|
|
224
|
+
unoffered: 0,
|
|
199
225
|
deferred: 0
|
|
200
226
|
};
|
|
201
227
|
const resolved = /* @__PURE__ */ new Map();
|
|
@@ -205,6 +231,22 @@ async function startRun(passed, expected) {
|
|
|
205
231
|
let joined = false;
|
|
206
232
|
let offline = token === "";
|
|
207
233
|
let done = false;
|
|
234
|
+
const shortened = /* @__PURE__ */ new Set();
|
|
235
|
+
function bound(keys) {
|
|
236
|
+
return keys.map((key) => {
|
|
237
|
+
const fit = boundKey(key);
|
|
238
|
+
if (fit !== key && !shortened.has(key.value)) {
|
|
239
|
+
shortened.add(key.value);
|
|
240
|
+
if (shortened.size === 1) {
|
|
241
|
+
log(
|
|
242
|
+
`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.`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return fit;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const expected = declared?.map(bound);
|
|
208
250
|
if (offline) {
|
|
209
251
|
log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
|
|
210
252
|
}
|
|
@@ -232,9 +274,9 @@ async function startRun(passed, expected) {
|
|
|
232
274
|
}
|
|
233
275
|
if (!offline) {
|
|
234
276
|
if (expected !== void 0) await resolveKeys(expected.flat());
|
|
235
|
-
const
|
|
236
|
-
const configuration =
|
|
237
|
-
expected:
|
|
277
|
+
const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
|
|
278
|
+
const configuration = stated2.length === 0 ? void 0 : {
|
|
279
|
+
expected: stated2.flatMap((keys) => {
|
|
238
280
|
const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
|
|
239
281
|
if (typeof hit === "string") return [{ testCaseId: hit }];
|
|
240
282
|
return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
|
|
@@ -278,9 +320,11 @@ async function startRun(passed, expected) {
|
|
|
278
320
|
if (!offerDiscovered) return;
|
|
279
321
|
if (result.title === void 0 || result.specRef === void 0) return;
|
|
280
322
|
if (result.keys.length === 0) return;
|
|
323
|
+
const title = boundTitle(result.title);
|
|
324
|
+
if (title !== result.title) shortened.add(result.title);
|
|
281
325
|
discoveries.set(result.keys[0].value, {
|
|
282
326
|
keys: result.keys,
|
|
283
|
-
title
|
|
327
|
+
title,
|
|
284
328
|
source: result.source,
|
|
285
329
|
specRef: result.specRef,
|
|
286
330
|
rawStatus: result.rawStatus
|
|
@@ -288,16 +332,21 @@ async function startRun(passed, expected) {
|
|
|
288
332
|
}
|
|
289
333
|
async function offer() {
|
|
290
334
|
if (discoveries.size === 0 || offline || runId === null) return;
|
|
291
|
-
|
|
335
|
+
const batches = chunk([...discoveries.values()], DISCOVER_MAX);
|
|
336
|
+
for (const [index, batch] of batches.entries()) {
|
|
292
337
|
const out = await client.post("/v1/review-items/discovered", {
|
|
293
338
|
discovered: batch,
|
|
294
339
|
runId
|
|
295
340
|
});
|
|
296
341
|
if (!out.ok) {
|
|
297
|
-
|
|
342
|
+
stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
|
|
343
|
+
log(
|
|
344
|
+
`plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.detail || out.kind}).`
|
|
345
|
+
);
|
|
298
346
|
return;
|
|
299
347
|
}
|
|
300
348
|
stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
|
|
349
|
+
stats.created += out.body.results.filter((r) => r.outcome === "created").length;
|
|
301
350
|
}
|
|
302
351
|
}
|
|
303
352
|
async function flush() {
|
|
@@ -324,7 +373,10 @@ async function startRun(passed, expected) {
|
|
|
324
373
|
}
|
|
325
374
|
}
|
|
326
375
|
if (unanswered.length > 0) defer(unanswered);
|
|
327
|
-
if (submissions.length === 0)
|
|
376
|
+
if (submissions.length === 0) {
|
|
377
|
+
progress();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
328
380
|
const out = await client.post(
|
|
329
381
|
`/v1/runs/${runId}/results`,
|
|
330
382
|
{ results: submissions }
|
|
@@ -334,6 +386,7 @@ async function startRun(passed, expected) {
|
|
|
334
386
|
stats.duplicate += out.body.counts.duplicate ?? 0;
|
|
335
387
|
stats.conflict += out.body.counts.conflict ?? 0;
|
|
336
388
|
stats.rejected += out.body.counts.rejected ?? 0;
|
|
389
|
+
progress();
|
|
337
390
|
return;
|
|
338
391
|
}
|
|
339
392
|
if (out.kind === "auth") {
|
|
@@ -346,6 +399,12 @@ async function startRun(passed, expected) {
|
|
|
346
399
|
}
|
|
347
400
|
defer(submissions);
|
|
348
401
|
}
|
|
402
|
+
function progress() {
|
|
403
|
+
const total = expected?.length ?? 0;
|
|
404
|
+
if (total <= batchSize) return;
|
|
405
|
+
const done2 = stats.accepted + stats.duplicate + stats.conflict + stats.rejected + stats.unresolved;
|
|
406
|
+
log(`plune: ${Math.min(done2, total)} of ${total} results sent`);
|
|
407
|
+
}
|
|
349
408
|
function summarise() {
|
|
350
409
|
const parts = [`${stats.accepted} accepted`];
|
|
351
410
|
if (stats.duplicate > 0) parts.push(`${stats.duplicate} already reported`);
|
|
@@ -353,7 +412,9 @@ async function startRun(passed, expected) {
|
|
|
353
412
|
if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
|
|
354
413
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
355
414
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
415
|
+
if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
|
|
356
416
|
if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
|
|
417
|
+
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
357
418
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
358
419
|
}
|
|
359
420
|
return {
|
|
@@ -367,7 +428,7 @@ async function startRun(passed, expected) {
|
|
|
367
428
|
return stats;
|
|
368
429
|
},
|
|
369
430
|
async add(result) {
|
|
370
|
-
buffer.push(result);
|
|
431
|
+
buffer.push({ ...result, keys: bound(result.keys) });
|
|
371
432
|
if (buffer.length >= batchSize) await flush();
|
|
372
433
|
},
|
|
373
434
|
flush,
|
|
@@ -399,9 +460,9 @@ async function startRun(passed, expected) {
|
|
|
399
460
|
}
|
|
400
461
|
|
|
401
462
|
// src/util/hash.ts
|
|
402
|
-
import { createHash } from "crypto";
|
|
463
|
+
import { createHash as createHash2 } from "crypto";
|
|
403
464
|
function sha256(input) {
|
|
404
|
-
return
|
|
465
|
+
return createHash2("sha256").update(input, "utf8").digest("hex");
|
|
405
466
|
}
|
|
406
467
|
|
|
407
468
|
// src/reporter-core/result-key.ts
|
package/package.json
CHANGED