@plune-ai/cli 0.9.1 → 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 +5 -0
- package/dist/cli.cjs +89 -14
- package/dist/reporter-core.cjs +59 -13
- package/dist/reporter-core.d.cts +18 -1
- package/dist/reporter-core.d.ts +18 -1
- package/dist/reporter-core.js +59 -13
- 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`.
|
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,7 @@ async function startRun(passed, expected) {
|
|
|
2373
2392
|
rejected: 0,
|
|
2374
2393
|
unresolved: 0,
|
|
2375
2394
|
offered: 0,
|
|
2395
|
+
created: 0,
|
|
2376
2396
|
unoffered: 0,
|
|
2377
2397
|
deferred: 0
|
|
2378
2398
|
};
|
|
@@ -2383,6 +2403,22 @@ async function startRun(passed, expected) {
|
|
|
2383
2403
|
let joined = false;
|
|
2384
2404
|
let offline = token === "";
|
|
2385
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);
|
|
2386
2422
|
if (offline) {
|
|
2387
2423
|
log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
|
|
2388
2424
|
}
|
|
@@ -2410,9 +2446,9 @@ async function startRun(passed, expected) {
|
|
|
2410
2446
|
}
|
|
2411
2447
|
if (!offline) {
|
|
2412
2448
|
if (expected !== void 0) await resolveKeys(expected.flat());
|
|
2413
|
-
const
|
|
2414
|
-
const configuration =
|
|
2415
|
-
expected:
|
|
2449
|
+
const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
|
|
2450
|
+
const configuration = stated2.length === 0 ? void 0 : {
|
|
2451
|
+
expected: stated2.flatMap((keys) => {
|
|
2416
2452
|
const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
|
|
2417
2453
|
if (typeof hit === "string") return [{ testCaseId: hit }];
|
|
2418
2454
|
return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
|
|
@@ -2456,9 +2492,11 @@ async function startRun(passed, expected) {
|
|
|
2456
2492
|
if (!offerDiscovered) return;
|
|
2457
2493
|
if (result.title === void 0 || result.specRef === void 0) return;
|
|
2458
2494
|
if (result.keys.length === 0) return;
|
|
2495
|
+
const title = boundTitle(result.title);
|
|
2496
|
+
if (title !== result.title) shortened.add(result.title);
|
|
2459
2497
|
discoveries.set(result.keys[0].value, {
|
|
2460
2498
|
keys: result.keys,
|
|
2461
|
-
title
|
|
2499
|
+
title,
|
|
2462
2500
|
source: result.source,
|
|
2463
2501
|
specRef: result.specRef,
|
|
2464
2502
|
rawStatus: result.rawStatus
|
|
@@ -2480,6 +2518,7 @@ async function startRun(passed, expected) {
|
|
|
2480
2518
|
return;
|
|
2481
2519
|
}
|
|
2482
2520
|
stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
|
|
2521
|
+
stats.created += out.body.results.filter((r) => r.outcome === "created").length;
|
|
2483
2522
|
}
|
|
2484
2523
|
}
|
|
2485
2524
|
async function flush() {
|
|
@@ -2545,7 +2584,9 @@ async function startRun(passed, expected) {
|
|
|
2545
2584
|
if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
|
|
2546
2585
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
2547
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)`);
|
|
2548
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`);
|
|
2549
2590
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
2550
2591
|
}
|
|
2551
2592
|
return {
|
|
@@ -2559,7 +2600,7 @@ async function startRun(passed, expected) {
|
|
|
2559
2600
|
return stats;
|
|
2560
2601
|
},
|
|
2561
2602
|
async add(result) {
|
|
2562
|
-
buffer.push(result);
|
|
2603
|
+
buffer.push({ ...result, keys: bound(result.keys) });
|
|
2563
2604
|
if (buffer.length >= batchSize) await flush();
|
|
2564
2605
|
},
|
|
2565
2606
|
flush,
|
|
@@ -2589,11 +2630,12 @@ async function startRun(passed, expected) {
|
|
|
2589
2630
|
}
|
|
2590
2631
|
};
|
|
2591
2632
|
}
|
|
2592
|
-
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;
|
|
2593
2634
|
var init_session = __esm({
|
|
2594
2635
|
"src/reporter-core/session.ts"() {
|
|
2595
2636
|
"use strict";
|
|
2596
2637
|
init_cjs_shims();
|
|
2638
|
+
import_node_crypto2 = require("crypto");
|
|
2597
2639
|
init_api_url();
|
|
2598
2640
|
init_credentials();
|
|
2599
2641
|
init_client();
|
|
@@ -2604,6 +2646,8 @@ var init_session = __esm({
|
|
|
2604
2646
|
DISCOVER_MAX = 500;
|
|
2605
2647
|
BATCH_DEFAULT = 100;
|
|
2606
2648
|
EXPECTED_MAX = 1e4;
|
|
2649
|
+
KEY_MAX = 1024;
|
|
2650
|
+
TITLE_MAX = 300;
|
|
2607
2651
|
}
|
|
2608
2652
|
});
|
|
2609
2653
|
|
|
@@ -2612,6 +2656,7 @@ var run_lifecycle_exports = {};
|
|
|
2612
2656
|
__export(run_lifecycle_exports, {
|
|
2613
2657
|
NoTokenError: () => NoTokenError,
|
|
2614
2658
|
RunCommandError: () => RunCommandError,
|
|
2659
|
+
handleRunDelete: () => handleRunDelete,
|
|
2615
2660
|
handleRunExec: () => handleRunExec,
|
|
2616
2661
|
handleRunFinish: () => handleRunFinish,
|
|
2617
2662
|
handleRunReport: () => handleRunReport,
|
|
@@ -2675,6 +2720,22 @@ async function handleRunFinish(id, options = {}) {
|
|
|
2675
2720
|
}
|
|
2676
2721
|
write(`Run ${id} ${event === "finish" ? "finished" : "terminated"}.`);
|
|
2677
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
|
+
}
|
|
2678
2739
|
async function handleRunExec(options) {
|
|
2679
2740
|
const [command, ...args] = options.argv;
|
|
2680
2741
|
if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
|
|
@@ -3208,6 +3269,7 @@ async function handleRunImport(options) {
|
|
|
3208
3269
|
rejected: stats.rejected,
|
|
3209
3270
|
unresolved: stats.unresolved,
|
|
3210
3271
|
offered: stats.offered,
|
|
3272
|
+
created: stats.created,
|
|
3211
3273
|
unoffered: stats.unoffered,
|
|
3212
3274
|
deferred: stats.deferred,
|
|
3213
3275
|
unlocatable: results.filter((r) => r.specRef === void 0).length,
|
|
@@ -3236,11 +3298,16 @@ function describe(result, apiUrl) {
|
|
|
3236
3298
|
`${result.deferred} could not be sent and are in the fallback file \u2014 "plune run report" retries them.`
|
|
3237
3299
|
);
|
|
3238
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
|
+
}
|
|
3239
3306
|
if (result.offered > 0) {
|
|
3240
3307
|
lines.push(`${result.offered} unknown test(s) offered to the review queue.`);
|
|
3241
|
-
} else if (result.unresolved > 0 && !result.offering) {
|
|
3308
|
+
} else if (result.unresolved > 0 && result.created === 0 && !result.offering) {
|
|
3242
3309
|
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) {
|
|
3310
|
+
} else if (result.unresolved > 0 && result.created === 0 && result.unoffered === 0) {
|
|
3244
3311
|
lines.push("Nothing new to offer \u2014 the unmatched tests are already in the review queue.");
|
|
3245
3312
|
}
|
|
3246
3313
|
if (result.unoffered > 0) {
|
|
@@ -4428,6 +4495,14 @@ function createProgram() {
|
|
|
4428
4495
|
await failRunCommand(err, verboseOf(command));
|
|
4429
4496
|
}
|
|
4430
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
|
+
});
|
|
4431
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) => {
|
|
4432
4507
|
try {
|
|
4433
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,7 @@ async function startRun(passed, expected) {
|
|
|
239
263
|
rejected: 0,
|
|
240
264
|
unresolved: 0,
|
|
241
265
|
offered: 0,
|
|
266
|
+
created: 0,
|
|
242
267
|
unoffered: 0,
|
|
243
268
|
deferred: 0
|
|
244
269
|
};
|
|
@@ -249,6 +274,22 @@ async function startRun(passed, expected) {
|
|
|
249
274
|
let joined = false;
|
|
250
275
|
let offline = token === "";
|
|
251
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);
|
|
252
293
|
if (offline) {
|
|
253
294
|
log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
|
|
254
295
|
}
|
|
@@ -276,9 +317,9 @@ async function startRun(passed, expected) {
|
|
|
276
317
|
}
|
|
277
318
|
if (!offline) {
|
|
278
319
|
if (expected !== void 0) await resolveKeys(expected.flat());
|
|
279
|
-
const
|
|
280
|
-
const configuration =
|
|
281
|
-
expected:
|
|
320
|
+
const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
|
|
321
|
+
const configuration = stated2.length === 0 ? void 0 : {
|
|
322
|
+
expected: stated2.flatMap((keys) => {
|
|
282
323
|
const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
|
|
283
324
|
if (typeof hit === "string") return [{ testCaseId: hit }];
|
|
284
325
|
return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
|
|
@@ -322,9 +363,11 @@ async function startRun(passed, expected) {
|
|
|
322
363
|
if (!offerDiscovered) return;
|
|
323
364
|
if (result.title === void 0 || result.specRef === void 0) return;
|
|
324
365
|
if (result.keys.length === 0) return;
|
|
366
|
+
const title = boundTitle(result.title);
|
|
367
|
+
if (title !== result.title) shortened.add(result.title);
|
|
325
368
|
discoveries.set(result.keys[0].value, {
|
|
326
369
|
keys: result.keys,
|
|
327
|
-
title
|
|
370
|
+
title,
|
|
328
371
|
source: result.source,
|
|
329
372
|
specRef: result.specRef,
|
|
330
373
|
rawStatus: result.rawStatus
|
|
@@ -346,6 +389,7 @@ async function startRun(passed, expected) {
|
|
|
346
389
|
return;
|
|
347
390
|
}
|
|
348
391
|
stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
|
|
392
|
+
stats.created += out.body.results.filter((r) => r.outcome === "created").length;
|
|
349
393
|
}
|
|
350
394
|
}
|
|
351
395
|
async function flush() {
|
|
@@ -411,7 +455,9 @@ async function startRun(passed, expected) {
|
|
|
411
455
|
if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
|
|
412
456
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
413
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)`);
|
|
414
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`);
|
|
415
461
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
416
462
|
}
|
|
417
463
|
return {
|
|
@@ -425,7 +471,7 @@ async function startRun(passed, expected) {
|
|
|
425
471
|
return stats;
|
|
426
472
|
},
|
|
427
473
|
async add(result) {
|
|
428
|
-
buffer.push(result);
|
|
474
|
+
buffer.push({ ...result, keys: bound(result.keys) });
|
|
429
475
|
if (buffer.length >= batchSize) await flush();
|
|
430
476
|
},
|
|
431
477
|
flush,
|
|
@@ -457,9 +503,9 @@ async function startRun(passed, expected) {
|
|
|
457
503
|
}
|
|
458
504
|
|
|
459
505
|
// src/util/hash.ts
|
|
460
|
-
var
|
|
506
|
+
var import_node_crypto2 = require("crypto");
|
|
461
507
|
function sha256(input) {
|
|
462
|
-
return (0,
|
|
508
|
+
return (0, import_node_crypto2.createHash)("sha256").update(input, "utf8").digest("hex");
|
|
463
509
|
}
|
|
464
510
|
|
|
465
511
|
// 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, 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,7 @@ async function startRun(passed, expected) {
|
|
|
196
220
|
rejected: 0,
|
|
197
221
|
unresolved: 0,
|
|
198
222
|
offered: 0,
|
|
223
|
+
created: 0,
|
|
199
224
|
unoffered: 0,
|
|
200
225
|
deferred: 0
|
|
201
226
|
};
|
|
@@ -206,6 +231,22 @@ async function startRun(passed, expected) {
|
|
|
206
231
|
let joined = false;
|
|
207
232
|
let offline = token === "";
|
|
208
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);
|
|
209
250
|
if (offline) {
|
|
210
251
|
log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
|
|
211
252
|
}
|
|
@@ -233,9 +274,9 @@ async function startRun(passed, expected) {
|
|
|
233
274
|
}
|
|
234
275
|
if (!offline) {
|
|
235
276
|
if (expected !== void 0) await resolveKeys(expected.flat());
|
|
236
|
-
const
|
|
237
|
-
const configuration =
|
|
238
|
-
expected:
|
|
277
|
+
const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
|
|
278
|
+
const configuration = stated2.length === 0 ? void 0 : {
|
|
279
|
+
expected: stated2.flatMap((keys) => {
|
|
239
280
|
const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
|
|
240
281
|
if (typeof hit === "string") return [{ testCaseId: hit }];
|
|
241
282
|
return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
|
|
@@ -279,9 +320,11 @@ async function startRun(passed, expected) {
|
|
|
279
320
|
if (!offerDiscovered) return;
|
|
280
321
|
if (result.title === void 0 || result.specRef === void 0) return;
|
|
281
322
|
if (result.keys.length === 0) return;
|
|
323
|
+
const title = boundTitle(result.title);
|
|
324
|
+
if (title !== result.title) shortened.add(result.title);
|
|
282
325
|
discoveries.set(result.keys[0].value, {
|
|
283
326
|
keys: result.keys,
|
|
284
|
-
title
|
|
327
|
+
title,
|
|
285
328
|
source: result.source,
|
|
286
329
|
specRef: result.specRef,
|
|
287
330
|
rawStatus: result.rawStatus
|
|
@@ -303,6 +346,7 @@ async function startRun(passed, expected) {
|
|
|
303
346
|
return;
|
|
304
347
|
}
|
|
305
348
|
stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
|
|
349
|
+
stats.created += out.body.results.filter((r) => r.outcome === "created").length;
|
|
306
350
|
}
|
|
307
351
|
}
|
|
308
352
|
async function flush() {
|
|
@@ -368,7 +412,9 @@ async function startRun(passed, expected) {
|
|
|
368
412
|
if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
|
|
369
413
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
370
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)`);
|
|
371
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`);
|
|
372
418
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
373
419
|
}
|
|
374
420
|
return {
|
|
@@ -382,7 +428,7 @@ async function startRun(passed, expected) {
|
|
|
382
428
|
return stats;
|
|
383
429
|
},
|
|
384
430
|
async add(result) {
|
|
385
|
-
buffer.push(result);
|
|
431
|
+
buffer.push({ ...result, keys: bound(result.keys) });
|
|
386
432
|
if (buffer.length >= batchSize) await flush();
|
|
387
433
|
},
|
|
388
434
|
flush,
|
|
@@ -414,9 +460,9 @@ async function startRun(passed, expected) {
|
|
|
414
460
|
}
|
|
415
461
|
|
|
416
462
|
// src/util/hash.ts
|
|
417
|
-
import { createHash } from "crypto";
|
|
463
|
+
import { createHash as createHash2 } from "crypto";
|
|
418
464
|
function sha256(input) {
|
|
419
|
-
return
|
|
465
|
+
return createHash2("sha256").update(input, "utf8").digest("hex");
|
|
420
466
|
}
|
|
421
467
|
|
|
422
468
|
// src/reporter-core/result-key.ts
|
package/package.json
CHANGED