@plune-ai/cli 0.14.3 → 0.16.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 +359 -107
- package/dist/index.cjs +34 -3
- package/dist/index.js +34 -3
- package/dist/reporter-core.cjs +161 -16
- package/dist/reporter-core.d.cts +75 -4
- package/dist/reporter-core.d.ts +75 -4
- package/dist/reporter-core.js +154 -11
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -414,11 +414,25 @@ async function withRetry(fn, options) {
|
|
|
414
414
|
|
|
415
415
|
// src/providers/prices.ts
|
|
416
416
|
var PRICE_TABLE = {
|
|
417
|
-
// Anthropic
|
|
417
|
+
// Anthropic, current models. Source: platform.claude.com/docs/en/about-claude/pricing — base
|
|
418
|
+
// input and output tokens per million, read on 2026-09-29, kept here per thousand.
|
|
419
|
+
"claude-fable-5-1": { input_per_1k_usd: 0.01, output_per_1k_usd: 0.05 },
|
|
420
|
+
"claude-opus-5-5": { input_per_1k_usd: 4e-3, output_per_1k_usd: 0.02 },
|
|
421
|
+
"claude-sonnet-5-5": { input_per_1k_usd: 2e-3, output_per_1k_usd: 0.01 },
|
|
422
|
+
"claude-haiku-4-5": { input_per_1k_usd: 1e-3, output_per_1k_usd: 5e-3 },
|
|
423
|
+
// The dated id of the same model; a config may name either.
|
|
424
|
+
"claude-haiku-4-5-20251001": { input_per_1k_usd: 1e-3, output_per_1k_usd: 5e-3 },
|
|
425
|
+
// Anthropic, older ids: unchanged, so a config that still names one prices as it did. Anthropic has
|
|
426
|
+
// retired these models — a new project should not start on them.
|
|
418
427
|
"claude-3-5-haiku-latest": { input_per_1k_usd: 8e-4, output_per_1k_usd: 4e-3 },
|
|
419
428
|
"claude-3-5-sonnet-latest": { input_per_1k_usd: 3e-3, output_per_1k_usd: 0.015 },
|
|
420
429
|
"claude-3-opus-latest": { input_per_1k_usd: 0.015, output_per_1k_usd: 0.075 },
|
|
421
|
-
// OpenAI
|
|
430
|
+
// OpenAI, current models. Source: developers.openai.com/api/docs/pricing — standard tier, the
|
|
431
|
+
// short-context rates (a long-context request costs more), read on 2026-09-29.
|
|
432
|
+
"gpt-6-astra": { input_per_1k_usd: 0.01, output_per_1k_usd: 0.05 },
|
|
433
|
+
"gpt-6.1-sol": { input_per_1k_usd: 2e-3, output_per_1k_usd: 0.01 },
|
|
434
|
+
"gpt-6-luna": { input_per_1k_usd: 1e-4, output_per_1k_usd: 5e-4 },
|
|
435
|
+
// OpenAI, older ids: unchanged (the same rates the page above still lists for both).
|
|
422
436
|
"gpt-4o": { input_per_1k_usd: 25e-4, output_per_1k_usd: 0.01 },
|
|
423
437
|
"gpt-4o-mini": { input_per_1k_usd: 15e-5, output_per_1k_usd: 6e-4 },
|
|
424
438
|
// OpenAI via OpenRouter (namespaced ids). OpenRouter passes OpenAI list price through for
|
|
@@ -607,6 +621,18 @@ function getProvider(config, env, pricing) {
|
|
|
607
621
|
return createDefaultRegistry().resolve(config.type, config, env, pricing);
|
|
608
622
|
}
|
|
609
623
|
|
|
624
|
+
// src/providers/estimate.ts
|
|
625
|
+
function makeEstimateProvider(config, pricing) {
|
|
626
|
+
return {
|
|
627
|
+
complete: () => Promise.reject(
|
|
628
|
+
new Error("The --dry-run provider only estimates cost; it never calls a model.")
|
|
629
|
+
),
|
|
630
|
+
estimateCost: (usage, reportedCostUsd) => ({
|
|
631
|
+
cost_usd: resolveCost(usage, config.model, reportedCostUsd, pricing)
|
|
632
|
+
})
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
610
636
|
// src/providers/mock.ts
|
|
611
637
|
function makeMockProvider() {
|
|
612
638
|
return {
|
|
@@ -1473,11 +1499,16 @@ var NOOP_CACHE = { get: () => void 0, set: () => {
|
|
|
1473
1499
|
function isMockMode(env) {
|
|
1474
1500
|
return env["PLUNE_MOCK_PROVIDER"] === "1";
|
|
1475
1501
|
}
|
|
1502
|
+
function resolveProviderFor(config, dryRun) {
|
|
1503
|
+
if (isMockMode(process.env)) return () => makeMockProvider();
|
|
1504
|
+
if (dryRun) return (cfg) => makeEstimateProvider(cfg, config.pricing);
|
|
1505
|
+
return (cfg) => getProvider(cfg, process.env, config.pricing);
|
|
1506
|
+
}
|
|
1476
1507
|
function buildRealDeps(config, baseDir, dryRun) {
|
|
1477
1508
|
const dir = path4.join(baseDir, ".plune");
|
|
1478
1509
|
fs5.mkdirSync(dir, { recursive: true });
|
|
1479
1510
|
return {
|
|
1480
|
-
resolveProvider:
|
|
1511
|
+
resolveProvider: resolveProviderFor(config, dryRun),
|
|
1481
1512
|
embedder: getDefaultEmbedder(),
|
|
1482
1513
|
cache: dryRun ? NOOP_CACHE : openCache(path4.join(dir, "cache.db")),
|
|
1483
1514
|
now: Date.now,
|
package/dist/index.js
CHANGED
|
@@ -361,11 +361,25 @@ async function withRetry(fn, options) {
|
|
|
361
361
|
|
|
362
362
|
// src/providers/prices.ts
|
|
363
363
|
var PRICE_TABLE = {
|
|
364
|
-
// Anthropic
|
|
364
|
+
// Anthropic, current models. Source: platform.claude.com/docs/en/about-claude/pricing — base
|
|
365
|
+
// input and output tokens per million, read on 2026-09-29, kept here per thousand.
|
|
366
|
+
"claude-fable-5-1": { input_per_1k_usd: 0.01, output_per_1k_usd: 0.05 },
|
|
367
|
+
"claude-opus-5-5": { input_per_1k_usd: 4e-3, output_per_1k_usd: 0.02 },
|
|
368
|
+
"claude-sonnet-5-5": { input_per_1k_usd: 2e-3, output_per_1k_usd: 0.01 },
|
|
369
|
+
"claude-haiku-4-5": { input_per_1k_usd: 1e-3, output_per_1k_usd: 5e-3 },
|
|
370
|
+
// The dated id of the same model; a config may name either.
|
|
371
|
+
"claude-haiku-4-5-20251001": { input_per_1k_usd: 1e-3, output_per_1k_usd: 5e-3 },
|
|
372
|
+
// Anthropic, older ids: unchanged, so a config that still names one prices as it did. Anthropic has
|
|
373
|
+
// retired these models — a new project should not start on them.
|
|
365
374
|
"claude-3-5-haiku-latest": { input_per_1k_usd: 8e-4, output_per_1k_usd: 4e-3 },
|
|
366
375
|
"claude-3-5-sonnet-latest": { input_per_1k_usd: 3e-3, output_per_1k_usd: 0.015 },
|
|
367
376
|
"claude-3-opus-latest": { input_per_1k_usd: 0.015, output_per_1k_usd: 0.075 },
|
|
368
|
-
// OpenAI
|
|
377
|
+
// OpenAI, current models. Source: developers.openai.com/api/docs/pricing — standard tier, the
|
|
378
|
+
// short-context rates (a long-context request costs more), read on 2026-09-29.
|
|
379
|
+
"gpt-6-astra": { input_per_1k_usd: 0.01, output_per_1k_usd: 0.05 },
|
|
380
|
+
"gpt-6.1-sol": { input_per_1k_usd: 2e-3, output_per_1k_usd: 0.01 },
|
|
381
|
+
"gpt-6-luna": { input_per_1k_usd: 1e-4, output_per_1k_usd: 5e-4 },
|
|
382
|
+
// OpenAI, older ids: unchanged (the same rates the page above still lists for both).
|
|
369
383
|
"gpt-4o": { input_per_1k_usd: 25e-4, output_per_1k_usd: 0.01 },
|
|
370
384
|
"gpt-4o-mini": { input_per_1k_usd: 15e-5, output_per_1k_usd: 6e-4 },
|
|
371
385
|
// OpenAI via OpenRouter (namespaced ids). OpenRouter passes OpenAI list price through for
|
|
@@ -554,6 +568,18 @@ function getProvider(config, env, pricing) {
|
|
|
554
568
|
return createDefaultRegistry().resolve(config.type, config, env, pricing);
|
|
555
569
|
}
|
|
556
570
|
|
|
571
|
+
// src/providers/estimate.ts
|
|
572
|
+
function makeEstimateProvider(config, pricing) {
|
|
573
|
+
return {
|
|
574
|
+
complete: () => Promise.reject(
|
|
575
|
+
new Error("The --dry-run provider only estimates cost; it never calls a model.")
|
|
576
|
+
),
|
|
577
|
+
estimateCost: (usage, reportedCostUsd) => ({
|
|
578
|
+
cost_usd: resolveCost(usage, config.model, reportedCostUsd, pricing)
|
|
579
|
+
})
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
557
583
|
// src/providers/mock.ts
|
|
558
584
|
function makeMockProvider() {
|
|
559
585
|
return {
|
|
@@ -1420,11 +1446,16 @@ var NOOP_CACHE = { get: () => void 0, set: () => {
|
|
|
1420
1446
|
function isMockMode(env) {
|
|
1421
1447
|
return env["PLUNE_MOCK_PROVIDER"] === "1";
|
|
1422
1448
|
}
|
|
1449
|
+
function resolveProviderFor(config, dryRun) {
|
|
1450
|
+
if (isMockMode(process.env)) return () => makeMockProvider();
|
|
1451
|
+
if (dryRun) return (cfg) => makeEstimateProvider(cfg, config.pricing);
|
|
1452
|
+
return (cfg) => getProvider(cfg, process.env, config.pricing);
|
|
1453
|
+
}
|
|
1423
1454
|
function buildRealDeps(config, baseDir, dryRun) {
|
|
1424
1455
|
const dir = path4.join(baseDir, ".plune");
|
|
1425
1456
|
fs5.mkdirSync(dir, { recursive: true });
|
|
1426
1457
|
return {
|
|
1427
|
-
resolveProvider:
|
|
1458
|
+
resolveProvider: resolveProviderFor(config, dryRun),
|
|
1428
1459
|
embedder: getDefaultEmbedder(),
|
|
1429
1460
|
cache: dryRun ? NOOP_CACHE : openCache(path4.join(dir, "cache.db")),
|
|
1430
1461
|
now: Date.now,
|
package/dist/reporter-core.cjs
CHANGED
|
@@ -34,9 +34,11 @@ __export(reporter_core_exports, {
|
|
|
34
34
|
RESULT_KEY_MAX: () => RESULT_KEY_MAX,
|
|
35
35
|
UNSUPPORTED_VARS: () => UNSUPPORTED_VARS,
|
|
36
36
|
appendBatch: () => appendBatch,
|
|
37
|
+
ciCommit: () => ciCommit,
|
|
37
38
|
createClient: () => createClient,
|
|
38
39
|
errorContextOf: () => errorContextOf,
|
|
39
40
|
failureOf: () => failureOf,
|
|
41
|
+
filesOf: () => filesOf,
|
|
40
42
|
readEnv: () => readEnv,
|
|
41
43
|
repoRootOf: () => repoRootOf,
|
|
42
44
|
resultKey: () => resultKey,
|
|
@@ -47,6 +49,8 @@ module.exports = __toCommonJS(reporter_core_exports);
|
|
|
47
49
|
|
|
48
50
|
// src/reporter-core/session.ts
|
|
49
51
|
var import_node_crypto = require("crypto");
|
|
52
|
+
var import_node_fs2 = require("fs");
|
|
53
|
+
var import_node_path2 = require("path");
|
|
50
54
|
|
|
51
55
|
// src/cli/api-url.ts
|
|
52
56
|
var DEFAULT_API_URL = "https://beta-api.plune.ai";
|
|
@@ -135,7 +139,7 @@ function createClient(opts) {
|
|
|
135
139
|
const wait = opts.wait ?? sleep;
|
|
136
140
|
const base = opts.apiUrl.replace(/\/+$/, "");
|
|
137
141
|
const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
|
|
138
|
-
async function send(method, path3,
|
|
142
|
+
async function send(method, path3, payload) {
|
|
139
143
|
let last = {
|
|
140
144
|
kind: "unavailable",
|
|
141
145
|
status: null,
|
|
@@ -146,11 +150,11 @@ function createClient(opts) {
|
|
|
146
150
|
try {
|
|
147
151
|
res = await doFetch(`${base}${path3}`, {
|
|
148
152
|
method,
|
|
149
|
-
headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
|
|
153
|
+
headers: { "content-type": payload?.type ?? "application/json", authorization: `Bearer ${opts.token}` },
|
|
150
154
|
// A DELETE carries none. Sending `"undefined"` as a body is what a naive
|
|
151
155
|
// `JSON.stringify(body)` would do, and some proxies answer that with a 400 nobody can
|
|
152
156
|
// explain.
|
|
153
|
-
...
|
|
157
|
+
...payload === void 0 ? {} : { body: payload.body }
|
|
154
158
|
});
|
|
155
159
|
} catch (err) {
|
|
156
160
|
last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
|
|
@@ -176,7 +180,8 @@ function createClient(opts) {
|
|
|
176
180
|
return { ok: false, ...last };
|
|
177
181
|
}
|
|
178
182
|
return {
|
|
179
|
-
post: (path3, body) => send("POST", path3, body),
|
|
183
|
+
post: (path3, body) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }),
|
|
184
|
+
upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }),
|
|
180
185
|
del: (path3) => send("DELETE", path3)
|
|
181
186
|
};
|
|
182
187
|
}
|
|
@@ -243,6 +248,28 @@ function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cw
|
|
|
243
248
|
const where = flag(env, "CI") || flag(env, "GITHUB_ACTIONS") ? "ci" : "local";
|
|
244
249
|
return `${path.basename(cwd)} \xB7 ${stamp} \xB7 ${where}`;
|
|
245
250
|
}
|
|
251
|
+
var PROVIDERS = [
|
|
252
|
+
{ is: "GITHUB_ACTIONS", sha: "GITHUB_SHA", branch: ["GITHUB_HEAD_REF", "GITHUB_REF_NAME"] },
|
|
253
|
+
{ is: "GITLAB_CI", sha: "CI_COMMIT_SHA", branch: ["CI_COMMIT_REF_NAME"] },
|
|
254
|
+
{ is: "JENKINS_URL", sha: "GIT_COMMIT", branch: ["GIT_BRANCH"] }
|
|
255
|
+
];
|
|
256
|
+
function ciCommit(reported, env = process.env) {
|
|
257
|
+
const said = typeof reported === "object" && reported !== null ? reported : {};
|
|
258
|
+
const text = (raw) => typeof raw === "string" && raw.trim() !== "" ? raw.trim() : void 0;
|
|
259
|
+
const provider = PROVIDERS.find((p) => value(env, p.is) !== void 0);
|
|
260
|
+
const job = {
|
|
261
|
+
sha: provider === void 0 ? void 0 : value(env, provider.sha),
|
|
262
|
+
branch: provider?.branch.map((name) => value(env, name)).find((v) => v !== void 0)
|
|
263
|
+
};
|
|
264
|
+
const sha = text(said["commitHash"]);
|
|
265
|
+
const sameCommit = sha === void 0 || sha === job.sha;
|
|
266
|
+
const commit = sha ?? job.sha;
|
|
267
|
+
const branch = text(said["branch"]) ?? (sameCommit ? job.branch : void 0);
|
|
268
|
+
return {
|
|
269
|
+
...commit !== void 0 ? { sha: commit } : {},
|
|
270
|
+
...branch !== void 0 ? { branch } : {}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
246
273
|
|
|
247
274
|
// src/reporter-core/fallback.ts
|
|
248
275
|
var fs = __toESM(require("fs"), 1);
|
|
@@ -254,6 +281,8 @@ function appendBatch(file, batch) {
|
|
|
254
281
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
255
282
|
runId: batch.runId,
|
|
256
283
|
externalKey: batch.externalKey,
|
|
284
|
+
...batch.session !== void 0 ? { session: batch.session } : {},
|
|
285
|
+
...batch.meta !== void 0 ? { meta: batch.meta } : {},
|
|
257
286
|
results: batch.results
|
|
258
287
|
};
|
|
259
288
|
fs.appendFileSync(file, `${JSON.stringify(line)}
|
|
@@ -269,6 +298,37 @@ var EXPECTED_MAX = 1e4;
|
|
|
269
298
|
var KEY_MAX = 1024;
|
|
270
299
|
var TITLE_MAX = 300;
|
|
271
300
|
var COUNTED = ["accepted", "duplicate", "conflict", "rejected"];
|
|
301
|
+
var SCREENSHOT_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
|
|
302
|
+
var SCREENSHOT_BYTES = 2 * 1024 * 1024;
|
|
303
|
+
var SCREENSHOTS_PER_RESULT = 20;
|
|
304
|
+
var TYPE_BY_EXTENSION = {
|
|
305
|
+
".png": "image/png",
|
|
306
|
+
".jpg": "image/jpeg",
|
|
307
|
+
".jpeg": "image/jpeg",
|
|
308
|
+
".webp": "image/webp"
|
|
309
|
+
};
|
|
310
|
+
var UPLOADS_AT_ONCE = 4;
|
|
311
|
+
var NAME_MAX = 200;
|
|
312
|
+
function nameOf(file) {
|
|
313
|
+
const own = file.name.split(/[\\/]/).pop()?.trim() || (0, import_node_path2.basename)(file.path);
|
|
314
|
+
const fileExt = (0, import_node_path2.extname)(file.path);
|
|
315
|
+
const ownExt = (0, import_node_path2.extname)(own);
|
|
316
|
+
const typed = ownExt !== "" && (ownExt.toLowerCase() === fileExt.toLowerCase() || TYPE_BY_EXTENSION[ownExt.toLowerCase()] !== void 0);
|
|
317
|
+
const ext = typed ? ownExt : fileExt;
|
|
318
|
+
let stem = typed ? own.slice(0, -ownExt.length) : own;
|
|
319
|
+
if (stem.length + ext.length > NAME_MAX) {
|
|
320
|
+
stem = stem.slice(0, Math.max(0, NAME_MAX - ext.length));
|
|
321
|
+
if (/[\uD800-\uDBFF]$/.test(stem)) stem = stem.slice(0, -1);
|
|
322
|
+
}
|
|
323
|
+
return `${stem}${ext}`;
|
|
324
|
+
}
|
|
325
|
+
var SKIPPED = {
|
|
326
|
+
hidden: "its name starts with an underscore, which hides an attachment in Playwright's own reports too",
|
|
327
|
+
type: "Plune takes PNG, JPEG and WebP",
|
|
328
|
+
limit: `a result holds at most ${SCREENSHOTS_PER_RESULT}`,
|
|
329
|
+
missing: "the file cannot be read on this machine",
|
|
330
|
+
size: "Plune takes a file of at most 2 MB"
|
|
331
|
+
};
|
|
272
332
|
function boundKey(key) {
|
|
273
333
|
if (key.value.length <= KEY_MAX) return key;
|
|
274
334
|
const digest = (0, import_node_crypto.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
|
|
@@ -276,6 +336,12 @@ function boundKey(key) {
|
|
|
276
336
|
if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
|
|
277
337
|
return { ...key, value: `${head}#${digest}` };
|
|
278
338
|
}
|
|
339
|
+
function whyUnoffered(out) {
|
|
340
|
+
if (out.ok || out.status !== 429) return "other";
|
|
341
|
+
if (/review queue quota reached/i.test(out.detail)) return "queue";
|
|
342
|
+
if (/test case quota reached/i.test(out.detail)) return "cases";
|
|
343
|
+
return "other";
|
|
344
|
+
}
|
|
279
345
|
function boundTitle(title) {
|
|
280
346
|
return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
|
|
281
347
|
}
|
|
@@ -319,6 +385,7 @@ async function startRun(passed, declared) {
|
|
|
319
385
|
`));
|
|
320
386
|
const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
|
|
321
387
|
const externalKey = cfg.externalKey ?? null;
|
|
388
|
+
const sessionMarker = (0, import_node_crypto.randomUUID)();
|
|
322
389
|
const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
|
|
323
390
|
const offerDiscovered = cfg.offerDiscovered ?? false;
|
|
324
391
|
const token = cfg.token ?? loadToken() ?? "";
|
|
@@ -336,7 +403,8 @@ async function startRun(passed, declared) {
|
|
|
336
403
|
offered: 0,
|
|
337
404
|
created: 0,
|
|
338
405
|
unoffered: 0,
|
|
339
|
-
deferred: 0
|
|
406
|
+
deferred: 0,
|
|
407
|
+
screenshots: { uploaded: 0, skipped: 0, failed: 0 }
|
|
340
408
|
};
|
|
341
409
|
const resolved = /* @__PURE__ */ new Map();
|
|
342
410
|
let buffer = [];
|
|
@@ -346,6 +414,7 @@ async function startRun(passed, declared) {
|
|
|
346
414
|
let offline = token === "";
|
|
347
415
|
let done = false;
|
|
348
416
|
const shortened = /* @__PURE__ */ new Set();
|
|
417
|
+
const skipsSaid = /* @__PURE__ */ new Set();
|
|
349
418
|
function bound(keys) {
|
|
350
419
|
return keys.map((key) => {
|
|
351
420
|
const fit = boundKey(key);
|
|
@@ -368,7 +437,13 @@ async function startRun(passed, declared) {
|
|
|
368
437
|
log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
|
|
369
438
|
}
|
|
370
439
|
function defer(results) {
|
|
371
|
-
appendBatch(fallbackPath, {
|
|
440
|
+
appendBatch(fallbackPath, {
|
|
441
|
+
runId,
|
|
442
|
+
externalKey,
|
|
443
|
+
session: sessionMarker,
|
|
444
|
+
...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
|
|
445
|
+
results
|
|
446
|
+
});
|
|
372
447
|
stats.deferred += results.length;
|
|
373
448
|
}
|
|
374
449
|
async function resolveKeys(values) {
|
|
@@ -465,6 +540,7 @@ async function startRun(passed, declared) {
|
|
|
465
540
|
});
|
|
466
541
|
if (!out.ok || !Array.isArray(out.body?.results)) {
|
|
467
542
|
stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
|
|
543
|
+
stats.unofferedWhy = whyUnoffered(out);
|
|
468
544
|
log(
|
|
469
545
|
`plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.ok ? "the answer had no results" : out.detail || out.kind}).`
|
|
470
546
|
);
|
|
@@ -474,6 +550,60 @@ async function startRun(passed, declared) {
|
|
|
474
550
|
stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
|
|
475
551
|
}
|
|
476
552
|
}
|
|
553
|
+
function skip(name, why) {
|
|
554
|
+
stats.screenshots.skipped += 1;
|
|
555
|
+
if (skipsSaid.has(why)) return;
|
|
556
|
+
skipsSaid.add(why);
|
|
557
|
+
log(`plune: screenshot "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
|
|
558
|
+
}
|
|
559
|
+
async function uploadOne(shot) {
|
|
560
|
+
let bytes;
|
|
561
|
+
try {
|
|
562
|
+
if ((await import_node_fs2.promises.stat(shot.path)).size > SCREENSHOT_BYTES) return skip(shot.name, "size");
|
|
563
|
+
bytes = await import_node_fs2.promises.readFile(shot.path);
|
|
564
|
+
} catch {
|
|
565
|
+
return skip(shot.name, "missing");
|
|
566
|
+
}
|
|
567
|
+
const out = await client.upload(
|
|
568
|
+
`/v1/results/${encodeURIComponent(shot.id)}/files?name=${encodeURIComponent(shot.name)}`,
|
|
569
|
+
bytes,
|
|
570
|
+
shot.type
|
|
571
|
+
);
|
|
572
|
+
if (out.ok) {
|
|
573
|
+
stats.screenshots.uploaded += 1;
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
stats.screenshots.failed += 1;
|
|
577
|
+
if (stats.screenshots.failed === 1) {
|
|
578
|
+
log(`plune: could not upload a screenshot (${out.detail || out.kind}) \u2014 its result is reported without it.`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async function upload(items, filesByKey) {
|
|
582
|
+
if (filesByKey.size === 0 || !Array.isArray(items)) return;
|
|
583
|
+
const shots = [];
|
|
584
|
+
for (const item of items) {
|
|
585
|
+
if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
|
|
586
|
+
let room = SCREENSHOTS_PER_RESULT;
|
|
587
|
+
for (const file of filesByKey.get(item.resultKey) ?? []) {
|
|
588
|
+
const type = file.contentType?.split(";")[0]?.trim().toLowerCase() || TYPE_BY_EXTENSION[(0, import_node_path2.extname)(file.path).toLowerCase()];
|
|
589
|
+
if (!type?.startsWith("image/")) continue;
|
|
590
|
+
const name = nameOf(file);
|
|
591
|
+
if (file.name.startsWith("_")) skip(name, "hidden");
|
|
592
|
+
else if (!SCREENSHOT_TYPES.has(type)) skip(name, "type");
|
|
593
|
+
else if (room === 0) skip(name, "limit");
|
|
594
|
+
else {
|
|
595
|
+
room -= 1;
|
|
596
|
+
shots.push({ id: item.id, name, type, path: file.path });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
let next = 0;
|
|
601
|
+
await Promise.all(
|
|
602
|
+
Array.from({ length: Math.min(UPLOADS_AT_ONCE, shots.length) }, async () => {
|
|
603
|
+
while (next < shots.length) await uploadOne(shots[next++]);
|
|
604
|
+
})
|
|
605
|
+
);
|
|
606
|
+
}
|
|
477
607
|
async function flush() {
|
|
478
608
|
if (buffer.length === 0) return;
|
|
479
609
|
const pending = buffer;
|
|
@@ -485,11 +615,13 @@ async function startRun(passed, declared) {
|
|
|
485
615
|
await resolveKeys(pending.flatMap((p) => p.keys));
|
|
486
616
|
const submissions = [];
|
|
487
617
|
const unanswered = [];
|
|
618
|
+
const filesByKey = /* @__PURE__ */ new Map();
|
|
488
619
|
for (const result of pending) {
|
|
489
620
|
const match = matchFor(result);
|
|
490
621
|
if (match.kind === "found") {
|
|
491
|
-
const { keys: _keys, ...rest } = result;
|
|
622
|
+
const { keys: _keys, files, ...rest } = result;
|
|
492
623
|
submissions.push({ ...rest, testCaseId: match.testCaseId });
|
|
624
|
+
if (files !== void 0) filesByKey.set(result.resultKey, files);
|
|
493
625
|
} else if (match.kind === "unmatched") {
|
|
494
626
|
stats.unresolved += 1;
|
|
495
627
|
remember(result);
|
|
@@ -507,14 +639,16 @@ async function startRun(passed, declared) {
|
|
|
507
639
|
defer(part);
|
|
508
640
|
continue;
|
|
509
641
|
}
|
|
510
|
-
const out = await client.post(
|
|
511
|
-
results
|
|
512
|
-
|
|
642
|
+
const out = await client.post(
|
|
643
|
+
`/v1/runs/${runId}/results`,
|
|
644
|
+
{ results: part }
|
|
645
|
+
);
|
|
513
646
|
const counts = out.ok ? out.body?.counts : void 0;
|
|
514
647
|
const told = COUNTED.filter((k) => typeof counts?.[k] === "number");
|
|
515
648
|
if (told.length > 0) {
|
|
516
649
|
for (const k of told) stats[k] += counts[k];
|
|
517
650
|
progress();
|
|
651
|
+
await upload(out.ok ? out.body?.items : void 0, filesByKey);
|
|
518
652
|
continue;
|
|
519
653
|
}
|
|
520
654
|
if (out.ok) {
|
|
@@ -544,6 +678,12 @@ async function startRun(passed, declared) {
|
|
|
544
678
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
545
679
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
546
680
|
if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
|
|
681
|
+
const shots = stats.screenshots;
|
|
682
|
+
if (shots.uploaded + shots.skipped + shots.failed > 0) {
|
|
683
|
+
parts.push(
|
|
684
|
+
`${shots.uploaded} screenshot(s) uploaded` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} failed to upload` : "")
|
|
685
|
+
);
|
|
686
|
+
}
|
|
547
687
|
if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
|
|
548
688
|
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
549
689
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
@@ -611,9 +751,9 @@ function resultKey(testId, retry) {
|
|
|
611
751
|
}
|
|
612
752
|
|
|
613
753
|
// src/reporter-core/failure-detail.ts
|
|
614
|
-
var
|
|
754
|
+
var import_node_fs3 = require("fs");
|
|
615
755
|
var import_node_os2 = require("os");
|
|
616
|
-
var
|
|
756
|
+
var import_node_path3 = require("path");
|
|
617
757
|
var import_node_util = require("util");
|
|
618
758
|
function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
|
|
619
759
|
const error = chosen(attempt);
|
|
@@ -633,15 +773,18 @@ function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
|
|
|
633
773
|
return Object.keys(failure).length === 0 ? void 0 : failure;
|
|
634
774
|
}
|
|
635
775
|
function repoRootOf(dir, stopAt) {
|
|
636
|
-
for (let here = (0,
|
|
637
|
-
if ((0,
|
|
638
|
-
if (here === stopAt || (0,
|
|
776
|
+
for (let here = (0, import_node_path3.resolve)(dir); ; here = (0, import_node_path3.dirname)(here)) {
|
|
777
|
+
if ((0, import_node_fs3.existsSync)((0, import_node_path3.join)(here, ".git"))) return here;
|
|
778
|
+
if (here === stopAt || (0, import_node_path3.dirname)(here) === here) return void 0;
|
|
639
779
|
}
|
|
640
780
|
}
|
|
641
781
|
function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
|
|
642
782
|
const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
|
|
643
783
|
return cut(machineless(text, attempt.repoRoot, home));
|
|
644
784
|
}
|
|
785
|
+
function filesOf(attempt) {
|
|
786
|
+
return attempt.attachments.filter((a) => a.path !== void 0);
|
|
787
|
+
}
|
|
645
788
|
function machineless(raw, repoRoot, home) {
|
|
646
789
|
let text = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
|
|
647
790
|
const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
|
|
@@ -720,7 +863,7 @@ function fileOf(raw) {
|
|
|
720
863
|
}
|
|
721
864
|
return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
|
|
722
865
|
}
|
|
723
|
-
var normal = (path3) =>
|
|
866
|
+
var normal = (path3) => import_node_path3.posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
|
|
724
867
|
function fromRoot(place, root) {
|
|
725
868
|
if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
|
|
726
869
|
const base = normal(root).replace(/\/+$/, "");
|
|
@@ -740,9 +883,11 @@ function webLink(href) {
|
|
|
740
883
|
RESULT_KEY_MAX,
|
|
741
884
|
UNSUPPORTED_VARS,
|
|
742
885
|
appendBatch,
|
|
886
|
+
ciCommit,
|
|
743
887
|
createClient,
|
|
744
888
|
errorContextOf,
|
|
745
889
|
failureOf,
|
|
890
|
+
filesOf,
|
|
746
891
|
readEnv,
|
|
747
892
|
repoRootOf,
|
|
748
893
|
resultKey,
|
package/dist/reporter-core.d.cts
CHANGED
|
@@ -44,12 +44,18 @@ interface Execution {
|
|
|
44
44
|
retry: number;
|
|
45
45
|
worker?: string;
|
|
46
46
|
}
|
|
47
|
-
/** A link, not a file
|
|
47
|
+
/** A link, not a file: this carries what someone else already hosts. A file the runner kept is `files`. */
|
|
48
48
|
interface Attachment {
|
|
49
49
|
name: string;
|
|
50
50
|
url: string;
|
|
51
51
|
contentType?: string;
|
|
52
52
|
}
|
|
53
|
+
/** A file the runner kept for an attempt, where it lies on this machine. */
|
|
54
|
+
interface ResultFile {
|
|
55
|
+
name: string;
|
|
56
|
+
contentType?: string;
|
|
57
|
+
path: string;
|
|
58
|
+
}
|
|
53
59
|
/** One assertion's outcome, in the shape the platform already stores for its own runner. */
|
|
54
60
|
interface AssertionRecord {
|
|
55
61
|
type: string;
|
|
@@ -108,6 +114,12 @@ interface PendingResult {
|
|
|
108
114
|
params?: Record<string, unknown>;
|
|
109
115
|
attachments?: Attachment[];
|
|
110
116
|
assertions?: AssertionRecord[];
|
|
117
|
+
/**
|
|
118
|
+
* The files the runner kept for this attempt, passed or not — built by `filesOf`. Never part of a
|
|
119
|
+
* batch: the paths are this machine's. Once the platform has accepted the result, the core uploads
|
|
120
|
+
* the screenshots among them to it (platform ADR 0040).
|
|
121
|
+
*/
|
|
122
|
+
files?: ResultFile[];
|
|
111
123
|
}
|
|
112
124
|
/**
|
|
113
125
|
* What a failed attempt says about itself on the run page (#790, platform ADR-0002): every field
|
|
@@ -125,7 +137,8 @@ interface FailureDetail {
|
|
|
125
137
|
line: number;
|
|
126
138
|
column?: number;
|
|
127
139
|
};
|
|
128
|
-
/** The files the runner kept for the attempt, by name
|
|
140
|
+
/** The files the runner kept for the attempt, by name. The screenshots among them are uploaded to the
|
|
141
|
+
* result besides (`files`); the rest stay in the CI run. */
|
|
129
142
|
artifacts?: {
|
|
130
143
|
name: string;
|
|
131
144
|
contentType?: string;
|
|
@@ -178,7 +191,7 @@ interface FailedAttempt {
|
|
|
178
191
|
repoRoot?: string;
|
|
179
192
|
}
|
|
180
193
|
/** What a run submission looks like once a key has resolved. Built by the core, never by a caller. */
|
|
181
|
-
interface ResultSubmission extends Omit<PendingResult, 'keys' | 'testCaseId'> {
|
|
194
|
+
interface ResultSubmission extends Omit<PendingResult, 'keys' | 'testCaseId' | 'files'> {
|
|
182
195
|
testCaseId: string;
|
|
183
196
|
}
|
|
184
197
|
/** One entry of the "what this run intended to execute" list, from which the platform derives
|
|
@@ -187,6 +200,12 @@ interface ExpectedEntry {
|
|
|
187
200
|
testCaseId?: string;
|
|
188
201
|
externalKey?: KeyRef;
|
|
189
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Why the platform would not take the tests offered to it (plune-ai/cli#71): its review queue is at its
|
|
205
|
+
* ceiling, the project is at its case limit, or anything else — a throttle, an outage, a run that is not
|
|
206
|
+
* there. What a person does next depends on which, and only the first two have advice of their own.
|
|
207
|
+
*/
|
|
208
|
+
type UnofferedWhy = 'queue' | 'cases' | 'other';
|
|
190
209
|
/** The run as the platform answers with it. Only `id` is load-bearing for the client. */
|
|
191
210
|
interface RunRecord {
|
|
192
211
|
id: string;
|
|
@@ -227,12 +246,28 @@ interface RunStats extends SubmitCounts {
|
|
|
227
246
|
* queue at capacity means "empty it and import again"; nothing else here means that.
|
|
228
247
|
*/
|
|
229
248
|
unoffered: number;
|
|
249
|
+
/**
|
|
250
|
+
* Why they were not, said by the platform's own words and present only when `unoffered` is not zero:
|
|
251
|
+
* that is what carries the reason the paragraph above says a subtraction cannot. The platform's line
|
|
252
|
+
* about it is printed with the failure; this is what a command turns into the advice that fits.
|
|
253
|
+
*/
|
|
254
|
+
unofferedWhy?: UnofferedWhy;
|
|
230
255
|
/**
|
|
231
256
|
* Results not delivered, written to the fallback file instead of the platform (#790): a batch
|
|
232
257
|
* refused for any reason — its size, the network, the token, a closed run — or a result whose keys
|
|
233
258
|
* the platform could not be asked about. Apart from `rejected` and `unresolved`, which it did answer.
|
|
234
259
|
*/
|
|
235
260
|
deferred: number;
|
|
261
|
+
/**
|
|
262
|
+
* The screenshots of the accepted results (platform ADR 0040): uploaded to their result; skipped
|
|
263
|
+
* because the platform would refuse them or the file is not here; refused or unanswered on the way.
|
|
264
|
+
* None of them costs a result — a result is reported whether its screenshots arrive or not.
|
|
265
|
+
*/
|
|
266
|
+
screenshots: {
|
|
267
|
+
uploaded: number;
|
|
268
|
+
skipped: number;
|
|
269
|
+
failed: number;
|
|
270
|
+
};
|
|
236
271
|
}
|
|
237
272
|
/** Everything the core needs to talk to a deployment. */
|
|
238
273
|
interface ReporterConfig {
|
|
@@ -352,6 +387,20 @@ interface EnvSettings {
|
|
|
352
387
|
ignored: string[];
|
|
353
388
|
}
|
|
354
389
|
declare function readEnv(env?: NodeJS.ProcessEnv): EnvSettings;
|
|
390
|
+
/**
|
|
391
|
+
* The commit and the branch a run belongs to (plune-ai/plune#927), for `meta.sha` and `meta.branch`.
|
|
392
|
+
*
|
|
393
|
+
* `reported` is what the runner said about its CI — Playwright's `metadata.ci`, or the copy of it in
|
|
394
|
+
* its JSON report — and outranks the job's variables: it is a project's own word where the CI is one
|
|
395
|
+
* Playwright does not know, and it is where the tests ran when another job imports the report. So a
|
|
396
|
+
* report naming a commit other than this job's gets that commit and no branch of this job's: half of
|
|
397
|
+
* each would be a commit on a branch it is not on. Playwright gives no branch on GitHub at all, which
|
|
398
|
+
* is why the job's variables are read there.
|
|
399
|
+
*
|
|
400
|
+
* Each field is present only when something said it: outside a CI there is nothing to say, and nothing
|
|
401
|
+
* is guessed.
|
|
402
|
+
*/
|
|
403
|
+
declare function ciCommit(reported?: unknown, env?: NodeJS.ProcessEnv): Pick<RunMeta, 'sha' | 'branch'>;
|
|
355
404
|
|
|
356
405
|
/**
|
|
357
406
|
* What ends up in a deferred batch.
|
|
@@ -374,10 +423,21 @@ declare const DEFAULT_FALLBACK_PATH: string;
|
|
|
374
423
|
*
|
|
375
424
|
* `runId: null` is a real state, not a missing value — the platform was unreachable when the run
|
|
376
425
|
* was supposed to be created, so these results have no run yet and replay has to make one.
|
|
426
|
+
*
|
|
427
|
+
* `meta` is where that run came from — the commit and branch, the CI run, the runner (plune-ai/plune#927).
|
|
428
|
+
* Recorded so the run replay makes is the run the reporter would have made; replay reads it and nothing
|
|
429
|
+
* else about the machine it runs on. A line written before the field existed has none.
|
|
430
|
+
*
|
|
431
|
+
* `session` marks the reporter session that wrote the line, made once by that session (plune-ai/cli#45).
|
|
432
|
+
* A run of 24 tests at a batch of 10 is three lines, and replay opens one run for the lines that share a
|
|
433
|
+
* marker rather than one for each. A line written before the field existed has none, and is one run's
|
|
434
|
+
* worth by itself — nothing in it says otherwise.
|
|
377
435
|
*/
|
|
378
436
|
interface DeferredBatch {
|
|
379
437
|
runId: string | null;
|
|
380
438
|
externalKey: string | null;
|
|
439
|
+
session?: string;
|
|
440
|
+
meta?: RunMeta;
|
|
381
441
|
results: DeferredResult[];
|
|
382
442
|
}
|
|
383
443
|
/**
|
|
@@ -415,6 +475,12 @@ declare function repoRootOf(dir: string, stopAt?: string): string | undefined;
|
|
|
415
475
|
* searched for credentials: the platform does that before its own cut (ADR-0005).
|
|
416
476
|
*/
|
|
417
477
|
declare function errorContextOf(attempt: FailedAttempt, home?: string): string;
|
|
478
|
+
/**
|
|
479
|
+
* The files the runner kept for one attempt, whatever its status: a screenshot of a test that passed
|
|
480
|
+
* is how a person sees what it checked (plune-ai/plune#913). Which of them the platform takes, the
|
|
481
|
+
* session decides as it uploads — once, for both roads.
|
|
482
|
+
*/
|
|
483
|
+
declare function filesOf(attempt: FailedAttempt): ResultFile[];
|
|
418
484
|
/**
|
|
419
485
|
* A link only for an address a browser opens as a page — never `file:`, `javascript:` or half a URL.
|
|
420
486
|
* As the platform checks it: `new URL` alone mends `https:/host` and `https:host` into https, and a
|
|
@@ -452,6 +518,11 @@ interface ClientOptions {
|
|
|
452
518
|
}
|
|
453
519
|
interface PlatformClient {
|
|
454
520
|
post<T>(path: string, body: unknown): Promise<ClientOutcome<T>>;
|
|
521
|
+
/**
|
|
522
|
+
* A file's bytes as the body, under the file's own type (platform ADR 0040) — not JSON. Through the
|
|
523
|
+
* same retries as a POST, so a 429 on the platform's upload limit waits out its `Retry-After`.
|
|
524
|
+
*/
|
|
525
|
+
upload<T>(path: string, bytes: Buffer, contentType: string): Promise<ClientOutcome<T>>;
|
|
455
526
|
/**
|
|
456
527
|
* A DELETE, through the same retries, the same classification and the same token scrubbing as a
|
|
457
528
|
* POST. It is here rather than as a bare `fetch` in the one command that needs it because all
|
|
@@ -462,4 +533,4 @@ interface PlatformClient {
|
|
|
462
533
|
}
|
|
463
534
|
declare function createClient(opts: ClientOptions): PlatformClient;
|
|
464
535
|
|
|
465
|
-
export { type AssertionRecord, type Attachment, type AttemptError, type ClientFailureKind, type ClientOptions, type ClientOutcome, DEFAULT_FALLBACK_PATH, type DeclaredStep, type DeferredBatch, type DeferredResult, type EnvSettings, type Execution, type ExpectedEntry, type ExternalKeyKind, type FailedAttempt, type FailureDetail, type KeyRef, type PendingResult, type PlatformClient, RESULT_KEY_MAX, type ReporterConfig, type ResultStatus, type ResultSubmission, type RunKind, type RunMeta, type RunRecord, type RunSession, type RunStats, type RunnerLocation, type SubmitCounts, UNSUPPORTED_VARS, appendBatch, createClient, errorContextOf, failureOf, readEnv, repoRootOf, resultKey, startRun, webLink };
|
|
536
|
+
export { type AssertionRecord, type Attachment, type AttemptError, type ClientFailureKind, type ClientOptions, type ClientOutcome, DEFAULT_FALLBACK_PATH, type DeclaredStep, type DeferredBatch, type DeferredResult, type EnvSettings, type Execution, type ExpectedEntry, type ExternalKeyKind, type FailedAttempt, type FailureDetail, type KeyRef, type PendingResult, type PlatformClient, RESULT_KEY_MAX, type ReporterConfig, type ResultFile, type ResultStatus, type ResultSubmission, type RunKind, type RunMeta, type RunRecord, type RunSession, type RunStats, type RunnerLocation, type SubmitCounts, UNSUPPORTED_VARS, type UnofferedWhy, appendBatch, ciCommit, createClient, errorContextOf, failureOf, filesOf, readEnv, repoRootOf, resultKey, startRun, webLink };
|