@plune-ai/cli 0.11.1 → 0.12.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 +25 -3
- package/dist/cli.cjs +241 -31
- package/dist/reporter-core.cjs +11 -2
- package/dist/reporter-core.d.cts +10 -0
- package/dist/reporter-core.d.ts +10 -0
- package/dist/reporter-core.js +11 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -110,10 +110,12 @@ provider API key is read from the environment based on `provider.type`:
|
|
|
110
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
111
|
| `plune run report` | Replay `.plune/pending-results.jsonl` — send what the reporter could not. Flags: `--file <path>`. |
|
|
112
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. |
|
|
113
|
+
| `plune pull [file]` | Write the project's test cases as **one Markdown document** — Testomat's classical format, plus Plune's own columns — to `plune/cases.md` (or `[file]`). Refuses to overwrite a file git sees as modified; `--force` overrides, `--suite <id>` takes one suite or folder and what is under it. |
|
|
114
|
+
| `plune push [file] [--dry-run]` | Send the document back. Cases are matched by `id`; a block without one becomes a draft; an unknown id is refused by line; **nothing is deleted**. Prints the report — created, updated, unchanged, refused, warnings — and with `--dry-run` writes nothing. Exit `4` when anything was refused. |
|
|
113
115
|
|
|
114
116
|
Global flags: `-c, --config <path>` · `-v, --verbose` · `--no-color`.
|
|
115
117
|
|
|
116
|
-
**Exit codes:** `0` everything passed · `1` an assertion failed · `2` configuration or execution error.
|
|
118
|
+
**Exit codes:** `0` everything passed · `1` an assertion failed · `2` configuration or execution error · `3` `pull` refused to overwrite uncommitted changes · `4` `push` had refusals.
|
|
117
119
|
|
|
118
120
|
## Already running tests? Bring the results in
|
|
119
121
|
|
|
@@ -158,6 +160,23 @@ job closes the run instead of the explicit `finish`.
|
|
|
158
160
|
`plune run exec` sets it for you — anything reporting inside it, this command included, joins
|
|
159
161
|
without closing.
|
|
160
162
|
|
|
163
|
+
### When a test is deleted
|
|
164
|
+
|
|
165
|
+
A test that vanishes from the code stays in Plune as an active case until something says otherwise.
|
|
166
|
+
The run that covers the whole suite is what says it:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
PLUNE_FULL_RUN=1 plune run import ./junit.xml # or the same variable on the Playwright reporter's job
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
When that run finishes having reported everything it expected, every case its source used to
|
|
173
|
+
report and did not this time is marked **detached** — not deleted, not retired: it keeps its history
|
|
174
|
+
and its place, and the dashboard shows it with the run as the reason. The next result on it brings
|
|
175
|
+
it back by itself. Set the variable only on the job that runs everything: a run of one file or a
|
|
176
|
+
`--grep` also reports everything it expected, and with the flag it would detach the rest. A run that
|
|
177
|
+
lost a shard (`notRun` is not empty) never detaches anything. `plune run start` has no list of
|
|
178
|
+
tests to compare against, so the flag means nothing there.
|
|
179
|
+
|
|
161
180
|
### What the run is called
|
|
162
181
|
|
|
163
182
|
A run nobody named is called after the directory, the minute it started and where it ran —
|
|
@@ -177,11 +196,14 @@ the [Plune platform](https://plune.ai/platform):
|
|
|
177
196
|
plune login # paste the API token from your platform settings page
|
|
178
197
|
plune run # exactly as before — the run is saved locally
|
|
179
198
|
plune sync # upload .plune/last-run.json, print the read-back URL
|
|
199
|
+
plune pull # the test cases as plune/cases.md — edit them anywhere
|
|
200
|
+
plune push --dry-run # what a push would change, before it changes anything
|
|
180
201
|
```
|
|
181
202
|
|
|
182
203
|
The token is stored at `~/.config/plune/credentials.json` (mode `0600`, honours `XDG_CONFIG_HOME`)
|
|
183
|
-
and is never printed or logged. `PLUNE_API_URL` points `sync` at a different server —
|
|
184
|
-
self-hosted backend.
|
|
204
|
+
and is never printed or logged. `PLUNE_API_URL` points `sync`, `pull` and `push` at a different server —
|
|
205
|
+
useful for a self-hosted backend. The Markdown format `pull` writes and `push` reads is documented at
|
|
206
|
+
[docs.plune.ai/platform/cases/markdown](https://docs.plune.ai/platform/cases/markdown/).
|
|
185
207
|
|
|
186
208
|
## Programmatic API
|
|
187
209
|
|
package/dist/cli.cjs
CHANGED
|
@@ -512,7 +512,7 @@ var init_retry = __esm({
|
|
|
512
512
|
"use strict";
|
|
513
513
|
init_cjs_shims();
|
|
514
514
|
init_errors2();
|
|
515
|
-
defaultSleep = (ms) => new Promise((
|
|
515
|
+
defaultSleep = (ms) => new Promise((resolve7) => setTimeout(resolve7, ms));
|
|
516
516
|
}
|
|
517
517
|
});
|
|
518
518
|
|
|
@@ -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 send(method,
|
|
2213
|
+
async function send(method, path14, body) {
|
|
2214
2214
|
let last = {
|
|
2215
2215
|
kind: "unavailable",
|
|
2216
2216
|
status: null,
|
|
@@ -2219,7 +2219,7 @@ 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}${
|
|
2222
|
+
res = await doFetch(`${base}${path14}`, {
|
|
2223
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
|
|
@@ -2247,8 +2247,8 @@ function createClient(opts) {
|
|
|
2247
2247
|
return { ok: false, ...last };
|
|
2248
2248
|
}
|
|
2249
2249
|
return {
|
|
2250
|
-
post: (
|
|
2251
|
-
del: (
|
|
2250
|
+
post: (path14, body) => send("POST", path14, body),
|
|
2251
|
+
del: (path14) => send("DELETE", path14)
|
|
2252
2252
|
};
|
|
2253
2253
|
}
|
|
2254
2254
|
var RETRY_DELAYS_MS, sleep;
|
|
@@ -2292,6 +2292,7 @@ function readEnv(env = process.env) {
|
|
|
2292
2292
|
const environment = value(env, "PLUNE_ENV");
|
|
2293
2293
|
const labels = list(env, "PLUNE_LABELS");
|
|
2294
2294
|
const offerDiscovered = flag(env, "PLUNE_CREATE");
|
|
2295
|
+
const full = flag(env, "PLUNE_FULL_RUN");
|
|
2295
2296
|
return {
|
|
2296
2297
|
config: {
|
|
2297
2298
|
...externalKey !== void 0 ? { externalKey } : {},
|
|
@@ -2304,7 +2305,8 @@ function readEnv(env = process.env) {
|
|
|
2304
2305
|
...labels !== void 0 ? { labels } : {},
|
|
2305
2306
|
// Only when set: `false` here would outrank a config file that asked for it, and an unset
|
|
2306
2307
|
// variable is not an instruction.
|
|
2307
|
-
...offerDiscovered ? { offerDiscovered } : {}
|
|
2308
|
+
...offerDiscovered ? { offerDiscovered } : {},
|
|
2309
|
+
...full ? { full } : {}
|
|
2308
2310
|
},
|
|
2309
2311
|
// Two names for two situations that end the same way. `SHARED_RUN` says other processes are
|
|
2310
2312
|
// reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
|
|
@@ -2459,7 +2461,11 @@ async function startRun(passed, declared) {
|
|
|
2459
2461
|
const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
|
|
2460
2462
|
if (typeof hit === "string") return [{ testCaseId: hit }];
|
|
2461
2463
|
return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
|
|
2462
|
-
})
|
|
2464
|
+
}),
|
|
2465
|
+
// The claim travels with the list it is about (D20). A runner that could not enumerate
|
|
2466
|
+
// its tests has no list, and a claim without one is nothing the platform can check —
|
|
2467
|
+
// so it is not sent, rather than sent and silently meaningless.
|
|
2468
|
+
...cfg.full === true ? { full: true } : {}
|
|
2463
2469
|
};
|
|
2464
2470
|
if (expected !== void 0 && expected.length > EXPECTED_MAX) {
|
|
2465
2471
|
log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
|
|
@@ -2626,6 +2632,9 @@ async function startRun(passed, declared) {
|
|
|
2626
2632
|
...reason !== void 0 ? { reason } : {}
|
|
2627
2633
|
});
|
|
2628
2634
|
if (!out.ok) log(`plune: could not close run ${runId} (${out.detail || out.kind}).`);
|
|
2635
|
+
else if (typeof out.body.detached === "number" && out.body.detached > 0) {
|
|
2636
|
+
log(`plune: ${out.body.detached} test case(s) marked detached \u2014 this full run no longer reports them.`);
|
|
2637
|
+
}
|
|
2629
2638
|
}
|
|
2630
2639
|
summarise();
|
|
2631
2640
|
},
|
|
@@ -2754,14 +2763,14 @@ async function handleRunExec(options) {
|
|
|
2754
2763
|
if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
|
|
2755
2764
|
const run = await handleRunStart(options);
|
|
2756
2765
|
const spawnImpl = options.spawnImpl ?? import_node_child_process.spawn;
|
|
2757
|
-
const code = await new Promise((
|
|
2766
|
+
const code = await new Promise((resolve7) => {
|
|
2758
2767
|
const child = spawnImpl(command, args, {
|
|
2759
2768
|
stdio: "inherit",
|
|
2760
2769
|
shell: process.platform === "win32",
|
|
2761
2770
|
env: { ...process.env, PLUNE_RUN: run.externalKey, PLUNE_PROCEED: "1" }
|
|
2762
2771
|
});
|
|
2763
|
-
child.on("close", (exitCode) =>
|
|
2764
|
-
child.on("error", () =>
|
|
2772
|
+
child.on("close", (exitCode) => resolve7(exitCode ?? 1));
|
|
2773
|
+
child.on("error", () => resolve7(1));
|
|
2765
2774
|
});
|
|
2766
2775
|
await handleRunFinish(run.id, options);
|
|
2767
2776
|
return code;
|
|
@@ -4324,6 +4333,145 @@ var init_ingest = __esm({
|
|
|
4324
4333
|
}
|
|
4325
4334
|
});
|
|
4326
4335
|
|
|
4336
|
+
// src/cli/commands/cases.ts
|
|
4337
|
+
var cases_exports = {};
|
|
4338
|
+
__export(cases_exports, {
|
|
4339
|
+
DEFAULT_CASES_FILE: () => DEFAULT_CASES_FILE,
|
|
4340
|
+
DirtyFileError: () => DirtyFileError,
|
|
4341
|
+
formatReport: () => formatReport,
|
|
4342
|
+
handlePull: () => handlePull,
|
|
4343
|
+
handlePush: () => handlePush,
|
|
4344
|
+
reportCasesFailure: () => reportCasesFailure
|
|
4345
|
+
});
|
|
4346
|
+
function gitDirty(file) {
|
|
4347
|
+
try {
|
|
4348
|
+
const out = (0, import_node_child_process2.execFileSync)("git", ["status", "--porcelain", "--", file], {
|
|
4349
|
+
cwd: path13.dirname(file),
|
|
4350
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
4351
|
+
encoding: "utf8"
|
|
4352
|
+
});
|
|
4353
|
+
return out.trim() !== "";
|
|
4354
|
+
} catch {
|
|
4355
|
+
return false;
|
|
4356
|
+
}
|
|
4357
|
+
}
|
|
4358
|
+
async function safeErrorDetail3(res) {
|
|
4359
|
+
try {
|
|
4360
|
+
const body = await res.json();
|
|
4361
|
+
return typeof body.error === "string" ? body.error : "";
|
|
4362
|
+
} catch {
|
|
4363
|
+
return "";
|
|
4364
|
+
}
|
|
4365
|
+
}
|
|
4366
|
+
function tokenOf(deps) {
|
|
4367
|
+
const token = (deps.loadToken ?? loadToken)();
|
|
4368
|
+
if (token === null) throw new NotLoggedInError();
|
|
4369
|
+
return token;
|
|
4370
|
+
}
|
|
4371
|
+
async function handlePull(deps = {}) {
|
|
4372
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
4373
|
+
const apiUrl = resolveApiUrl(deps.apiUrl);
|
|
4374
|
+
const token = tokenOf(deps);
|
|
4375
|
+
const file = path13.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
|
|
4376
|
+
if (fs15.existsSync(file) && !deps.force && (deps.isDirty ?? gitDirty)(file))
|
|
4377
|
+
throw new DirtyFileError(path13.relative(cwd, file) || file);
|
|
4378
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
4379
|
+
const endpoint = deps.suiteId !== void 0 ? `${apiUrl}/v1/suites/${encodeURIComponent(deps.suiteId)}/markdown` : `${apiUrl}/v1/test-cases/markdown`;
|
|
4380
|
+
let res;
|
|
4381
|
+
try {
|
|
4382
|
+
res = await doFetch(endpoint, { headers: { authorization: `Bearer ${token}` } });
|
|
4383
|
+
} catch {
|
|
4384
|
+
throw new SyncNetworkError(apiUrl);
|
|
4385
|
+
}
|
|
4386
|
+
if (!res.ok) {
|
|
4387
|
+
const detail = await safeErrorDetail3(res);
|
|
4388
|
+
if (res.status === 401) throw new TokenRejectedError2();
|
|
4389
|
+
throw new SyncHttpError(res.status, detail);
|
|
4390
|
+
}
|
|
4391
|
+
const text2 = await res.text();
|
|
4392
|
+
fs15.mkdirSync(path13.dirname(file), { recursive: true });
|
|
4393
|
+
fs15.writeFileSync(file, text2, "utf8");
|
|
4394
|
+
return { file, cases: Number(res.headers.get("x-plune-cases") ?? "0") };
|
|
4395
|
+
}
|
|
4396
|
+
async function handlePush(deps = {}) {
|
|
4397
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
4398
|
+
const apiUrl = resolveApiUrl(deps.apiUrl);
|
|
4399
|
+
const token = tokenOf(deps);
|
|
4400
|
+
const file = path13.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
|
|
4401
|
+
let text2;
|
|
4402
|
+
try {
|
|
4403
|
+
text2 = fs15.readFileSync(file, "utf8");
|
|
4404
|
+
} catch {
|
|
4405
|
+
throw new SyncFileError(
|
|
4406
|
+
`No document to push: ${file}. Run "plune pull" first (or pass a file).`
|
|
4407
|
+
);
|
|
4408
|
+
}
|
|
4409
|
+
if (text2.trim() === "") throw new SyncFileError(`The document is empty: ${file}`);
|
|
4410
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
4411
|
+
let res;
|
|
4412
|
+
try {
|
|
4413
|
+
res = await doFetch(`${apiUrl}/v1/test-cases/markdown${deps.dryRun ? "?dryRun=true" : ""}`, {
|
|
4414
|
+
method: "POST",
|
|
4415
|
+
headers: { "content-type": "text/markdown", authorization: `Bearer ${token}` },
|
|
4416
|
+
body: text2
|
|
4417
|
+
});
|
|
4418
|
+
} catch {
|
|
4419
|
+
throw new SyncNetworkError(apiUrl);
|
|
4420
|
+
}
|
|
4421
|
+
if (!res.ok) {
|
|
4422
|
+
const detail = await safeErrorDetail3(res);
|
|
4423
|
+
if (res.status === 401) throw new TokenRejectedError2();
|
|
4424
|
+
throw new SyncHttpError(res.status, detail);
|
|
4425
|
+
}
|
|
4426
|
+
return await res.json();
|
|
4427
|
+
}
|
|
4428
|
+
function formatReport(report) {
|
|
4429
|
+
const verb = (past, future) => report.dryRun ? future : past;
|
|
4430
|
+
const lines = [
|
|
4431
|
+
`${report.dryRun ? "Dry run \u2014 nothing written." : "Pushed."} ${report.created.length} ${verb("created", "to create")} \xB7 ${report.updated.length} ${verb("updated", "to update")} \xB7 ${report.unchanged.length} unchanged \xB7 ${report.refused.length} refused` + (report.suites.created.length > 0 ? ` \xB7 ${report.suites.created.length} suite(s) ${verb("created", "to create")}` : "")
|
|
4432
|
+
];
|
|
4433
|
+
for (const r of report.refused)
|
|
4434
|
+
lines.push(` refused line ${r.line} ${r.title || "(no title)"} \u2014 ${r.error}`);
|
|
4435
|
+
for (const w of report.warnings)
|
|
4436
|
+
lines.push(` warning ${w.line !== void 0 ? `line ${w.line} ` : ""}${w.message}`);
|
|
4437
|
+
return lines.join("\n") + "\n";
|
|
4438
|
+
}
|
|
4439
|
+
function reportCasesFailure(err, write) {
|
|
4440
|
+
if (err instanceof DirtyFileError) {
|
|
4441
|
+
write(err.message + "\n");
|
|
4442
|
+
return 3;
|
|
4443
|
+
}
|
|
4444
|
+
if (err instanceof NotLoggedInError || err instanceof TokenRejectedError2 || err instanceof SyncFileError) {
|
|
4445
|
+
write(err.message + "\n");
|
|
4446
|
+
return 2;
|
|
4447
|
+
}
|
|
4448
|
+
if (err instanceof SyncNetworkError || err instanceof SyncHttpError) {
|
|
4449
|
+
write(err.message + "\n");
|
|
4450
|
+
return 1;
|
|
4451
|
+
}
|
|
4452
|
+
return null;
|
|
4453
|
+
}
|
|
4454
|
+
var import_node_child_process2, fs15, path13, DEFAULT_CASES_FILE, DirtyFileError;
|
|
4455
|
+
var init_cases = __esm({
|
|
4456
|
+
"src/cli/commands/cases.ts"() {
|
|
4457
|
+
"use strict";
|
|
4458
|
+
init_cjs_shims();
|
|
4459
|
+
import_node_child_process2 = require("child_process");
|
|
4460
|
+
fs15 = __toESM(require("fs"), 1);
|
|
4461
|
+
path13 = __toESM(require("path"), 1);
|
|
4462
|
+
init_api_url();
|
|
4463
|
+
init_credentials();
|
|
4464
|
+
init_sync();
|
|
4465
|
+
DEFAULT_CASES_FILE = path13.join("plune", "cases.md");
|
|
4466
|
+
DirtyFileError = class extends Error {
|
|
4467
|
+
constructor(file) {
|
|
4468
|
+
super(`${file} has uncommitted changes; commit them or pass --force to overwrite.`);
|
|
4469
|
+
this.name = "DirtyFileError";
|
|
4470
|
+
}
|
|
4471
|
+
};
|
|
4472
|
+
}
|
|
4473
|
+
});
|
|
4474
|
+
|
|
4327
4475
|
// src/cli.ts
|
|
4328
4476
|
var cli_exports = {};
|
|
4329
4477
|
__export(cli_exports, {
|
|
@@ -4481,23 +4629,30 @@ function createProgram() {
|
|
|
4481
4629
|
runCommand.command("start").description("Open a platform run (or join one already carrying the key) and print its id").option("--key <externalKey>", "The key several processes share; generated when absent").option("--json", "Print one machine-readable line instead of prose", false).action(async (options, command) => {
|
|
4482
4630
|
try {
|
|
4483
4631
|
const { handleRunStart: handleRunStart2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
|
|
4484
|
-
await handleRunStart2({
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
}
|
|
4488
|
-
});
|
|
4489
|
-
runCommand.command("finish").argument("<id>", "The run to close").description("Close a platform run \u2014 what the reporter tells you to do for a run left open").option("--terminate", "Record the run as cut short rather than finished", false).option("--reason <text>", "Why, for a terminated run").action(async (id, options, command) => {
|
|
4490
|
-
try {
|
|
4491
|
-
const { handleRunFinish: handleRunFinish2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
|
|
4492
|
-
await handleRunFinish2(id, {
|
|
4493
|
-
terminate: options.terminate,
|
|
4494
|
-
...options.reason !== void 0 ? { reason: options.reason } : {}
|
|
4632
|
+
await handleRunStart2({
|
|
4633
|
+
...options.key !== void 0 ? { key: options.key } : {},
|
|
4634
|
+
json: options.json
|
|
4495
4635
|
});
|
|
4496
4636
|
} catch (err) {
|
|
4497
4637
|
await failRunCommand(err, verboseOf(command));
|
|
4498
4638
|
}
|
|
4499
4639
|
});
|
|
4500
|
-
runCommand.command("
|
|
4640
|
+
runCommand.command("finish").argument("<id>", "The run to close").description("Close a platform run \u2014 what the reporter tells you to do for a run left open").option("--terminate", "Record the run as cut short rather than finished", false).option("--reason <text>", "Why, for a terminated run").action(
|
|
4641
|
+
async (id, options, command) => {
|
|
4642
|
+
try {
|
|
4643
|
+
const { handleRunFinish: handleRunFinish2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
|
|
4644
|
+
await handleRunFinish2(id, {
|
|
4645
|
+
terminate: options.terminate,
|
|
4646
|
+
...options.reason !== void 0 ? { reason: options.reason } : {}
|
|
4647
|
+
});
|
|
4648
|
+
} catch (err) {
|
|
4649
|
+
await failRunCommand(err, verboseOf(command));
|
|
4650
|
+
}
|
|
4651
|
+
}
|
|
4652
|
+
);
|
|
4653
|
+
runCommand.command("exec").argument("<command...>", "The command to run inside the run, after `--`").description(
|
|
4654
|
+
"Open a run, run a command inside it, close the run \u2014 whatever the command returns"
|
|
4655
|
+
).option("--key <externalKey>", "The key several processes share; generated when absent").action(async (argv, options, command) => {
|
|
4501
4656
|
try {
|
|
4502
4657
|
const { handleRunExec: handleRunExec2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
|
|
4503
4658
|
process.exitCode = await handleRunExec2({
|
|
@@ -4508,7 +4663,10 @@ function createProgram() {
|
|
|
4508
4663
|
await failRunCommand(err, verboseOf(command));
|
|
4509
4664
|
}
|
|
4510
4665
|
});
|
|
4511
|
-
runCommand.command("delete").argument(
|
|
4666
|
+
runCommand.command("delete").argument(
|
|
4667
|
+
"<id>",
|
|
4668
|
+
"The run id \u2014 the one `plune run import` printed, or the one in the dashboard URL"
|
|
4669
|
+
).description("Delete a run and everything it produced \u2014 recoverable for six months").action(async (id, _options, command) => {
|
|
4512
4670
|
try {
|
|
4513
4671
|
const { handleRunDelete: handleRunDelete2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
|
|
4514
4672
|
await handleRunDelete2(id);
|
|
@@ -4526,14 +4684,19 @@ function createProgram() {
|
|
|
4526
4684
|
});
|
|
4527
4685
|
runCommand.command("import").argument("<file>", "A JUnit XML or Playwright JSON report").description("Turn a report another runner wrote into a run in Plune \u2014 no provider key needed").option("--format <fmt>", "junit | playwright-json; detected from the file when omitted").option("--key <externalKey>", "The key several jobs share so their reports land in one run").option("--create", "Offer tests nothing matched to the review queue", false).action(
|
|
4528
4686
|
async (file, options, command) => {
|
|
4529
|
-
const [
|
|
4687
|
+
const [
|
|
4688
|
+
{ handleRunImport: handleRunImport2 },
|
|
4689
|
+
{ IMPORT_FORMATS: IMPORT_FORMATS2, UnknownFormatError: UnknownFormatError2, XmlParseError: XmlParseError2, JsonReportError: JsonReportError2 }
|
|
4690
|
+
] = await Promise.all([
|
|
4530
4691
|
Promise.resolve().then(() => (init_run_import(), run_import_exports)),
|
|
4531
4692
|
Promise.resolve().then(() => (init_importers(), importers_exports))
|
|
4532
4693
|
]);
|
|
4533
4694
|
const format = options.format;
|
|
4534
4695
|
if (format !== void 0 && !IMPORT_FORMATS2.includes(format)) {
|
|
4535
|
-
process.stderr.write(
|
|
4536
|
-
`)
|
|
4696
|
+
process.stderr.write(
|
|
4697
|
+
`Unknown --format "${format}". Use ${IMPORT_FORMATS2.join(" or ")}.
|
|
4698
|
+
`
|
|
4699
|
+
);
|
|
4537
4700
|
process.exit(2);
|
|
4538
4701
|
return;
|
|
4539
4702
|
}
|
|
@@ -4626,10 +4789,10 @@ function createProgram() {
|
|
|
4626
4789
|
const { handleLogin: handleLogin2, reportLoginFailure: reportLoginFailure2 } = await Promise.resolve().then(() => (init_login(), login_exports));
|
|
4627
4790
|
try {
|
|
4628
4791
|
const token = options.token ?? await readTokenFromStdin();
|
|
4629
|
-
const { path:
|
|
4792
|
+
const { path: path14, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
|
|
4630
4793
|
process.stdout.write(
|
|
4631
|
-
verified ? `Logged in. Token checked against the API and saved to ${
|
|
4632
|
-
` : `Token saved to ${
|
|
4794
|
+
verified ? `Logged in. Token checked against the API and saved to ${path14}
|
|
4795
|
+
` : `Token saved to ${path14} WITHOUT being checked (--skip-verify).
|
|
4633
4796
|
`
|
|
4634
4797
|
);
|
|
4635
4798
|
} catch (err) {
|
|
@@ -4646,9 +4809,9 @@ function createProgram() {
|
|
|
4646
4809
|
const verbose = command.optsWithGlobals().verbose === true;
|
|
4647
4810
|
const { handleLogout: handleLogout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
|
|
4648
4811
|
try {
|
|
4649
|
-
const { removed, path:
|
|
4812
|
+
const { removed, path: path14 } = handleLogout2();
|
|
4650
4813
|
process.stdout.write(
|
|
4651
|
-
removed ? `Logged out. Removed ${
|
|
4814
|
+
removed ? `Logged out. Removed ${path14}
|
|
4652
4815
|
` : "Not logged in \u2014 nothing to remove.\n"
|
|
4653
4816
|
);
|
|
4654
4817
|
} catch (err) {
|
|
@@ -4695,6 +4858,53 @@ function createProgram() {
|
|
|
4695
4858
|
failUnexpected(err, verbose);
|
|
4696
4859
|
}
|
|
4697
4860
|
});
|
|
4861
|
+
program.command("pull").argument("[file]", "Where to write the document (default: plune/cases.md)").description(
|
|
4862
|
+
"Write the project's test cases as one Markdown document (GET /v1/test-cases/markdown)"
|
|
4863
|
+
).option("--suite <id>", "Only this suite or folder and what is under it").option("--force", "Overwrite the file even when git says it has uncommitted changes", false).action(
|
|
4864
|
+
async (file, options, command) => {
|
|
4865
|
+
const verbose = command.optsWithGlobals().verbose === true;
|
|
4866
|
+
const { handlePull: handlePull2, reportCasesFailure: reportCasesFailure2 } = await Promise.resolve().then(() => (init_cases(), cases_exports));
|
|
4867
|
+
try {
|
|
4868
|
+
const result = await handlePull2({
|
|
4869
|
+
...file !== void 0 ? { file } : {},
|
|
4870
|
+
...options.suite !== void 0 ? { suiteId: options.suite } : {},
|
|
4871
|
+
force: options.force
|
|
4872
|
+
});
|
|
4873
|
+
process.stdout.write(`Pulled ${result.cases} case(s) \u2192 ${result.file}
|
|
4874
|
+
`);
|
|
4875
|
+
} catch (err) {
|
|
4876
|
+
const code = reportCasesFailure2(err, (s) => process.stderr.write(s));
|
|
4877
|
+
if (code !== null) {
|
|
4878
|
+
maybeStack(err, verbose);
|
|
4879
|
+
process.exitCode = code;
|
|
4880
|
+
return;
|
|
4881
|
+
}
|
|
4882
|
+
failUnexpected(err, verbose);
|
|
4883
|
+
}
|
|
4884
|
+
}
|
|
4885
|
+
);
|
|
4886
|
+
program.command("push").argument("[file]", "The document to send (default: plune/cases.md)").description(
|
|
4887
|
+
"Send a Markdown document of test cases back to Plune (POST /v1/test-cases/markdown)"
|
|
4888
|
+
).option("--dry-run", "Show what would change and write nothing", false).action(async (file, options, command) => {
|
|
4889
|
+
const verbose = command.optsWithGlobals().verbose === true;
|
|
4890
|
+
const { handlePush: handlePush2, formatReport: formatReport2, reportCasesFailure: reportCasesFailure2 } = await Promise.resolve().then(() => (init_cases(), cases_exports));
|
|
4891
|
+
try {
|
|
4892
|
+
const report = await handlePush2({
|
|
4893
|
+
...file !== void 0 ? { file } : {},
|
|
4894
|
+
dryRun: options.dryRun
|
|
4895
|
+
});
|
|
4896
|
+
process.stdout.write(formatReport2(report));
|
|
4897
|
+
if (report.refused.length > 0) process.exitCode = 4;
|
|
4898
|
+
} catch (err) {
|
|
4899
|
+
const code = reportCasesFailure2(err, (s) => process.stderr.write(s));
|
|
4900
|
+
if (code !== null) {
|
|
4901
|
+
maybeStack(err, verbose);
|
|
4902
|
+
process.exitCode = code;
|
|
4903
|
+
return;
|
|
4904
|
+
}
|
|
4905
|
+
failUnexpected(err, verbose);
|
|
4906
|
+
}
|
|
4907
|
+
});
|
|
4698
4908
|
program.on("command:*", (operands) => {
|
|
4699
4909
|
process.stderr.write(`Unknown command: ${operands.join(" ")}
|
|
4700
4910
|
`);
|
package/dist/reporter-core.cjs
CHANGED
|
@@ -172,6 +172,7 @@ function readEnv(env = process.env) {
|
|
|
172
172
|
const environment = value(env, "PLUNE_ENV");
|
|
173
173
|
const labels = list(env, "PLUNE_LABELS");
|
|
174
174
|
const offerDiscovered = flag(env, "PLUNE_CREATE");
|
|
175
|
+
const full = flag(env, "PLUNE_FULL_RUN");
|
|
175
176
|
return {
|
|
176
177
|
config: {
|
|
177
178
|
...externalKey !== void 0 ? { externalKey } : {},
|
|
@@ -184,7 +185,8 @@ function readEnv(env = process.env) {
|
|
|
184
185
|
...labels !== void 0 ? { labels } : {},
|
|
185
186
|
// Only when set: `false` here would outrank a config file that asked for it, and an unset
|
|
186
187
|
// variable is not an instruction.
|
|
187
|
-
...offerDiscovered ? { offerDiscovered } : {}
|
|
188
|
+
...offerDiscovered ? { offerDiscovered } : {},
|
|
189
|
+
...full ? { full } : {}
|
|
188
190
|
},
|
|
189
191
|
// Two names for two situations that end the same way. `SHARED_RUN` says other processes are
|
|
190
192
|
// reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
|
|
@@ -330,7 +332,11 @@ async function startRun(passed, declared) {
|
|
|
330
332
|
const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
|
|
331
333
|
if (typeof hit === "string") return [{ testCaseId: hit }];
|
|
332
334
|
return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
|
|
333
|
-
})
|
|
335
|
+
}),
|
|
336
|
+
// The claim travels with the list it is about (D20). A runner that could not enumerate
|
|
337
|
+
// its tests has no list, and a claim without one is nothing the platform can check —
|
|
338
|
+
// so it is not sent, rather than sent and silently meaningless.
|
|
339
|
+
...cfg.full === true ? { full: true } : {}
|
|
334
340
|
};
|
|
335
341
|
if (expected !== void 0 && expected.length > EXPECTED_MAX) {
|
|
336
342
|
log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
|
|
@@ -497,6 +503,9 @@ async function startRun(passed, declared) {
|
|
|
497
503
|
...reason !== void 0 ? { reason } : {}
|
|
498
504
|
});
|
|
499
505
|
if (!out.ok) log(`plune: could not close run ${runId} (${out.detail || out.kind}).`);
|
|
506
|
+
else if (typeof out.body.detached === "number" && out.body.detached > 0) {
|
|
507
|
+
log(`plune: ${out.body.detached} test case(s) marked detached \u2014 this full run no longer reports them.`);
|
|
508
|
+
}
|
|
500
509
|
}
|
|
501
510
|
summarise();
|
|
502
511
|
},
|
package/dist/reporter-core.d.cts
CHANGED
|
@@ -188,6 +188,16 @@ interface ReporterConfig {
|
|
|
188
188
|
* how somebody says "yes, tell me what we are running that you do not know about".
|
|
189
189
|
*/
|
|
190
190
|
offerDiscovered?: boolean;
|
|
191
|
+
/**
|
|
192
|
+
* This run is the whole suite (`PLUNE_FULL_RUN=1`, D20).
|
|
193
|
+
*
|
|
194
|
+
* Sent as `configuration.full` beside `expected`. When such a run finishes having reported
|
|
195
|
+
* everything it expected, the platform moves the cases this source used to report and did not
|
|
196
|
+
* this time to `detached` — a test that vanished from the code stops looking alive. Off by
|
|
197
|
+
* default because nothing here can tell a suite that shrank from `-g smoke` or a single file:
|
|
198
|
+
* only whoever typed the command knows, so the CI job that runs everything is the one to set it.
|
|
199
|
+
*/
|
|
200
|
+
full?: boolean;
|
|
191
201
|
/** Transport seam — the same one `sync.ts` uses so tests need no server. */
|
|
192
202
|
fetchImpl?: typeof fetch;
|
|
193
203
|
/** Where a line for the operator goes. Defaults to stderr. */
|
package/dist/reporter-core.d.ts
CHANGED
|
@@ -188,6 +188,16 @@ interface ReporterConfig {
|
|
|
188
188
|
* how somebody says "yes, tell me what we are running that you do not know about".
|
|
189
189
|
*/
|
|
190
190
|
offerDiscovered?: boolean;
|
|
191
|
+
/**
|
|
192
|
+
* This run is the whole suite (`PLUNE_FULL_RUN=1`, D20).
|
|
193
|
+
*
|
|
194
|
+
* Sent as `configuration.full` beside `expected`. When such a run finishes having reported
|
|
195
|
+
* everything it expected, the platform moves the cases this source used to report and did not
|
|
196
|
+
* this time to `detached` — a test that vanished from the code stops looking alive. Off by
|
|
197
|
+
* default because nothing here can tell a suite that shrank from `-g smoke` or a single file:
|
|
198
|
+
* only whoever typed the command knows, so the CI job that runs everything is the one to set it.
|
|
199
|
+
*/
|
|
200
|
+
full?: boolean;
|
|
191
201
|
/** Transport seam — the same one `sync.ts` uses so tests need no server. */
|
|
192
202
|
fetchImpl?: typeof fetch;
|
|
193
203
|
/** Where a line for the operator goes. Defaults to stderr. */
|
package/dist/reporter-core.js
CHANGED
|
@@ -129,6 +129,7 @@ function readEnv(env = process.env) {
|
|
|
129
129
|
const environment = value(env, "PLUNE_ENV");
|
|
130
130
|
const labels = list(env, "PLUNE_LABELS");
|
|
131
131
|
const offerDiscovered = flag(env, "PLUNE_CREATE");
|
|
132
|
+
const full = flag(env, "PLUNE_FULL_RUN");
|
|
132
133
|
return {
|
|
133
134
|
config: {
|
|
134
135
|
...externalKey !== void 0 ? { externalKey } : {},
|
|
@@ -141,7 +142,8 @@ function readEnv(env = process.env) {
|
|
|
141
142
|
...labels !== void 0 ? { labels } : {},
|
|
142
143
|
// Only when set: `false` here would outrank a config file that asked for it, and an unset
|
|
143
144
|
// variable is not an instruction.
|
|
144
|
-
...offerDiscovered ? { offerDiscovered } : {}
|
|
145
|
+
...offerDiscovered ? { offerDiscovered } : {},
|
|
146
|
+
...full ? { full } : {}
|
|
145
147
|
},
|
|
146
148
|
// Two names for two situations that end the same way. `SHARED_RUN` says other processes are
|
|
147
149
|
// reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
|
|
@@ -287,7 +289,11 @@ async function startRun(passed, declared) {
|
|
|
287
289
|
const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
|
|
288
290
|
if (typeof hit === "string") return [{ testCaseId: hit }];
|
|
289
291
|
return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
|
|
290
|
-
})
|
|
292
|
+
}),
|
|
293
|
+
// The claim travels with the list it is about (D20). A runner that could not enumerate
|
|
294
|
+
// its tests has no list, and a claim without one is nothing the platform can check —
|
|
295
|
+
// so it is not sent, rather than sent and silently meaningless.
|
|
296
|
+
...cfg.full === true ? { full: true } : {}
|
|
291
297
|
};
|
|
292
298
|
if (expected !== void 0 && expected.length > EXPECTED_MAX) {
|
|
293
299
|
log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
|
|
@@ -454,6 +460,9 @@ async function startRun(passed, declared) {
|
|
|
454
460
|
...reason !== void 0 ? { reason } : {}
|
|
455
461
|
});
|
|
456
462
|
if (!out.ok) log(`plune: could not close run ${runId} (${out.detail || out.kind}).`);
|
|
463
|
+
else if (typeof out.body.detached === "number" && out.body.detached > 0) {
|
|
464
|
+
log(`plune: ${out.body.detached} test case(s) marked detached \u2014 this full run no longer reports them.`);
|
|
465
|
+
}
|
|
457
466
|
}
|
|
458
467
|
summarise();
|
|
459
468
|
},
|
package/package.json
CHANGED