@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/reporter-core.d.ts
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 };
|
package/dist/reporter-core.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// src/reporter-core/session.ts
|
|
2
|
-
import { createHash } from "crypto";
|
|
2
|
+
import { createHash, randomUUID } from "crypto";
|
|
3
|
+
import { promises as disk } from "fs";
|
|
4
|
+
import { basename as basename2, extname } from "path";
|
|
3
5
|
|
|
4
6
|
// src/cli/api-url.ts
|
|
5
7
|
var DEFAULT_API_URL = "https://beta-api.plune.ai";
|
|
@@ -88,7 +90,7 @@ function createClient(opts) {
|
|
|
88
90
|
const wait = opts.wait ?? sleep;
|
|
89
91
|
const base = opts.apiUrl.replace(/\/+$/, "");
|
|
90
92
|
const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
|
|
91
|
-
async function send(method, path3,
|
|
93
|
+
async function send(method, path3, payload) {
|
|
92
94
|
let last = {
|
|
93
95
|
kind: "unavailable",
|
|
94
96
|
status: null,
|
|
@@ -99,11 +101,11 @@ function createClient(opts) {
|
|
|
99
101
|
try {
|
|
100
102
|
res = await doFetch(`${base}${path3}`, {
|
|
101
103
|
method,
|
|
102
|
-
headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
|
|
104
|
+
headers: { "content-type": payload?.type ?? "application/json", authorization: `Bearer ${opts.token}` },
|
|
103
105
|
// A DELETE carries none. Sending `"undefined"` as a body is what a naive
|
|
104
106
|
// `JSON.stringify(body)` would do, and some proxies answer that with a 400 nobody can
|
|
105
107
|
// explain.
|
|
106
|
-
...
|
|
108
|
+
...payload === void 0 ? {} : { body: payload.body }
|
|
107
109
|
});
|
|
108
110
|
} catch (err) {
|
|
109
111
|
last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
|
|
@@ -129,7 +131,8 @@ function createClient(opts) {
|
|
|
129
131
|
return { ok: false, ...last };
|
|
130
132
|
}
|
|
131
133
|
return {
|
|
132
|
-
post: (path3, body) => send("POST", path3, body),
|
|
134
|
+
post: (path3, body) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }),
|
|
135
|
+
upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }),
|
|
133
136
|
del: (path3) => send("DELETE", path3)
|
|
134
137
|
};
|
|
135
138
|
}
|
|
@@ -196,6 +199,28 @@ function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cw
|
|
|
196
199
|
const where = flag(env, "CI") || flag(env, "GITHUB_ACTIONS") ? "ci" : "local";
|
|
197
200
|
return `${path.basename(cwd)} \xB7 ${stamp} \xB7 ${where}`;
|
|
198
201
|
}
|
|
202
|
+
var PROVIDERS = [
|
|
203
|
+
{ is: "GITHUB_ACTIONS", sha: "GITHUB_SHA", branch: ["GITHUB_HEAD_REF", "GITHUB_REF_NAME"] },
|
|
204
|
+
{ is: "GITLAB_CI", sha: "CI_COMMIT_SHA", branch: ["CI_COMMIT_REF_NAME"] },
|
|
205
|
+
{ is: "JENKINS_URL", sha: "GIT_COMMIT", branch: ["GIT_BRANCH"] }
|
|
206
|
+
];
|
|
207
|
+
function ciCommit(reported, env = process.env) {
|
|
208
|
+
const said = typeof reported === "object" && reported !== null ? reported : {};
|
|
209
|
+
const text = (raw) => typeof raw === "string" && raw.trim() !== "" ? raw.trim() : void 0;
|
|
210
|
+
const provider = PROVIDERS.find((p) => value(env, p.is) !== void 0);
|
|
211
|
+
const job = {
|
|
212
|
+
sha: provider === void 0 ? void 0 : value(env, provider.sha),
|
|
213
|
+
branch: provider?.branch.map((name) => value(env, name)).find((v) => v !== void 0)
|
|
214
|
+
};
|
|
215
|
+
const sha = text(said["commitHash"]);
|
|
216
|
+
const sameCommit = sha === void 0 || sha === job.sha;
|
|
217
|
+
const commit = sha ?? job.sha;
|
|
218
|
+
const branch = text(said["branch"]) ?? (sameCommit ? job.branch : void 0);
|
|
219
|
+
return {
|
|
220
|
+
...commit !== void 0 ? { sha: commit } : {},
|
|
221
|
+
...branch !== void 0 ? { branch } : {}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
199
224
|
|
|
200
225
|
// src/reporter-core/fallback.ts
|
|
201
226
|
import * as fs from "fs";
|
|
@@ -207,6 +232,8 @@ function appendBatch(file, batch) {
|
|
|
207
232
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
208
233
|
runId: batch.runId,
|
|
209
234
|
externalKey: batch.externalKey,
|
|
235
|
+
...batch.session !== void 0 ? { session: batch.session } : {},
|
|
236
|
+
...batch.meta !== void 0 ? { meta: batch.meta } : {},
|
|
210
237
|
results: batch.results
|
|
211
238
|
};
|
|
212
239
|
fs.appendFileSync(file, `${JSON.stringify(line)}
|
|
@@ -222,6 +249,37 @@ var EXPECTED_MAX = 1e4;
|
|
|
222
249
|
var KEY_MAX = 1024;
|
|
223
250
|
var TITLE_MAX = 300;
|
|
224
251
|
var COUNTED = ["accepted", "duplicate", "conflict", "rejected"];
|
|
252
|
+
var SCREENSHOT_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
|
|
253
|
+
var SCREENSHOT_BYTES = 2 * 1024 * 1024;
|
|
254
|
+
var SCREENSHOTS_PER_RESULT = 20;
|
|
255
|
+
var TYPE_BY_EXTENSION = {
|
|
256
|
+
".png": "image/png",
|
|
257
|
+
".jpg": "image/jpeg",
|
|
258
|
+
".jpeg": "image/jpeg",
|
|
259
|
+
".webp": "image/webp"
|
|
260
|
+
};
|
|
261
|
+
var UPLOADS_AT_ONCE = 4;
|
|
262
|
+
var NAME_MAX = 200;
|
|
263
|
+
function nameOf(file) {
|
|
264
|
+
const own = file.name.split(/[\\/]/).pop()?.trim() || basename2(file.path);
|
|
265
|
+
const fileExt = extname(file.path);
|
|
266
|
+
const ownExt = extname(own);
|
|
267
|
+
const typed = ownExt !== "" && (ownExt.toLowerCase() === fileExt.toLowerCase() || TYPE_BY_EXTENSION[ownExt.toLowerCase()] !== void 0);
|
|
268
|
+
const ext = typed ? ownExt : fileExt;
|
|
269
|
+
let stem = typed ? own.slice(0, -ownExt.length) : own;
|
|
270
|
+
if (stem.length + ext.length > NAME_MAX) {
|
|
271
|
+
stem = stem.slice(0, Math.max(0, NAME_MAX - ext.length));
|
|
272
|
+
if (/[\uD800-\uDBFF]$/.test(stem)) stem = stem.slice(0, -1);
|
|
273
|
+
}
|
|
274
|
+
return `${stem}${ext}`;
|
|
275
|
+
}
|
|
276
|
+
var SKIPPED = {
|
|
277
|
+
hidden: "its name starts with an underscore, which hides an attachment in Playwright's own reports too",
|
|
278
|
+
type: "Plune takes PNG, JPEG and WebP",
|
|
279
|
+
limit: `a result holds at most ${SCREENSHOTS_PER_RESULT}`,
|
|
280
|
+
missing: "the file cannot be read on this machine",
|
|
281
|
+
size: "Plune takes a file of at most 2 MB"
|
|
282
|
+
};
|
|
225
283
|
function boundKey(key) {
|
|
226
284
|
if (key.value.length <= KEY_MAX) return key;
|
|
227
285
|
const digest = createHash("sha256").update(key.value).digest("hex").slice(0, 16);
|
|
@@ -229,6 +287,12 @@ function boundKey(key) {
|
|
|
229
287
|
if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
|
|
230
288
|
return { ...key, value: `${head}#${digest}` };
|
|
231
289
|
}
|
|
290
|
+
function whyUnoffered(out) {
|
|
291
|
+
if (out.ok || out.status !== 429) return "other";
|
|
292
|
+
if (/review queue quota reached/i.test(out.detail)) return "queue";
|
|
293
|
+
if (/test case quota reached/i.test(out.detail)) return "cases";
|
|
294
|
+
return "other";
|
|
295
|
+
}
|
|
232
296
|
function boundTitle(title) {
|
|
233
297
|
return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
|
|
234
298
|
}
|
|
@@ -272,6 +336,7 @@ async function startRun(passed, declared) {
|
|
|
272
336
|
`));
|
|
273
337
|
const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
|
|
274
338
|
const externalKey = cfg.externalKey ?? null;
|
|
339
|
+
const sessionMarker = randomUUID();
|
|
275
340
|
const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
|
|
276
341
|
const offerDiscovered = cfg.offerDiscovered ?? false;
|
|
277
342
|
const token = cfg.token ?? loadToken() ?? "";
|
|
@@ -289,7 +354,8 @@ async function startRun(passed, declared) {
|
|
|
289
354
|
offered: 0,
|
|
290
355
|
created: 0,
|
|
291
356
|
unoffered: 0,
|
|
292
|
-
deferred: 0
|
|
357
|
+
deferred: 0,
|
|
358
|
+
screenshots: { uploaded: 0, skipped: 0, failed: 0 }
|
|
293
359
|
};
|
|
294
360
|
const resolved = /* @__PURE__ */ new Map();
|
|
295
361
|
let buffer = [];
|
|
@@ -299,6 +365,7 @@ async function startRun(passed, declared) {
|
|
|
299
365
|
let offline = token === "";
|
|
300
366
|
let done = false;
|
|
301
367
|
const shortened = /* @__PURE__ */ new Set();
|
|
368
|
+
const skipsSaid = /* @__PURE__ */ new Set();
|
|
302
369
|
function bound(keys) {
|
|
303
370
|
return keys.map((key) => {
|
|
304
371
|
const fit = boundKey(key);
|
|
@@ -321,7 +388,13 @@ async function startRun(passed, declared) {
|
|
|
321
388
|
log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
|
|
322
389
|
}
|
|
323
390
|
function defer(results) {
|
|
324
|
-
appendBatch(fallbackPath, {
|
|
391
|
+
appendBatch(fallbackPath, {
|
|
392
|
+
runId,
|
|
393
|
+
externalKey,
|
|
394
|
+
session: sessionMarker,
|
|
395
|
+
...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
|
|
396
|
+
results
|
|
397
|
+
});
|
|
325
398
|
stats.deferred += results.length;
|
|
326
399
|
}
|
|
327
400
|
async function resolveKeys(values) {
|
|
@@ -418,6 +491,7 @@ async function startRun(passed, declared) {
|
|
|
418
491
|
});
|
|
419
492
|
if (!out.ok || !Array.isArray(out.body?.results)) {
|
|
420
493
|
stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
|
|
494
|
+
stats.unofferedWhy = whyUnoffered(out);
|
|
421
495
|
log(
|
|
422
496
|
`plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.ok ? "the answer had no results" : out.detail || out.kind}).`
|
|
423
497
|
);
|
|
@@ -427,6 +501,60 @@ async function startRun(passed, declared) {
|
|
|
427
501
|
stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
|
|
428
502
|
}
|
|
429
503
|
}
|
|
504
|
+
function skip(name, why) {
|
|
505
|
+
stats.screenshots.skipped += 1;
|
|
506
|
+
if (skipsSaid.has(why)) return;
|
|
507
|
+
skipsSaid.add(why);
|
|
508
|
+
log(`plune: screenshot "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
|
|
509
|
+
}
|
|
510
|
+
async function uploadOne(shot) {
|
|
511
|
+
let bytes;
|
|
512
|
+
try {
|
|
513
|
+
if ((await disk.stat(shot.path)).size > SCREENSHOT_BYTES) return skip(shot.name, "size");
|
|
514
|
+
bytes = await disk.readFile(shot.path);
|
|
515
|
+
} catch {
|
|
516
|
+
return skip(shot.name, "missing");
|
|
517
|
+
}
|
|
518
|
+
const out = await client.upload(
|
|
519
|
+
`/v1/results/${encodeURIComponent(shot.id)}/files?name=${encodeURIComponent(shot.name)}`,
|
|
520
|
+
bytes,
|
|
521
|
+
shot.type
|
|
522
|
+
);
|
|
523
|
+
if (out.ok) {
|
|
524
|
+
stats.screenshots.uploaded += 1;
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
stats.screenshots.failed += 1;
|
|
528
|
+
if (stats.screenshots.failed === 1) {
|
|
529
|
+
log(`plune: could not upload a screenshot (${out.detail || out.kind}) \u2014 its result is reported without it.`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
async function upload(items, filesByKey) {
|
|
533
|
+
if (filesByKey.size === 0 || !Array.isArray(items)) return;
|
|
534
|
+
const shots = [];
|
|
535
|
+
for (const item of items) {
|
|
536
|
+
if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
|
|
537
|
+
let room = SCREENSHOTS_PER_RESULT;
|
|
538
|
+
for (const file of filesByKey.get(item.resultKey) ?? []) {
|
|
539
|
+
const type = file.contentType?.split(";")[0]?.trim().toLowerCase() || TYPE_BY_EXTENSION[extname(file.path).toLowerCase()];
|
|
540
|
+
if (!type?.startsWith("image/")) continue;
|
|
541
|
+
const name = nameOf(file);
|
|
542
|
+
if (file.name.startsWith("_")) skip(name, "hidden");
|
|
543
|
+
else if (!SCREENSHOT_TYPES.has(type)) skip(name, "type");
|
|
544
|
+
else if (room === 0) skip(name, "limit");
|
|
545
|
+
else {
|
|
546
|
+
room -= 1;
|
|
547
|
+
shots.push({ id: item.id, name, type, path: file.path });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
let next = 0;
|
|
552
|
+
await Promise.all(
|
|
553
|
+
Array.from({ length: Math.min(UPLOADS_AT_ONCE, shots.length) }, async () => {
|
|
554
|
+
while (next < shots.length) await uploadOne(shots[next++]);
|
|
555
|
+
})
|
|
556
|
+
);
|
|
557
|
+
}
|
|
430
558
|
async function flush() {
|
|
431
559
|
if (buffer.length === 0) return;
|
|
432
560
|
const pending = buffer;
|
|
@@ -438,11 +566,13 @@ async function startRun(passed, declared) {
|
|
|
438
566
|
await resolveKeys(pending.flatMap((p) => p.keys));
|
|
439
567
|
const submissions = [];
|
|
440
568
|
const unanswered = [];
|
|
569
|
+
const filesByKey = /* @__PURE__ */ new Map();
|
|
441
570
|
for (const result of pending) {
|
|
442
571
|
const match = matchFor(result);
|
|
443
572
|
if (match.kind === "found") {
|
|
444
|
-
const { keys: _keys, ...rest } = result;
|
|
573
|
+
const { keys: _keys, files, ...rest } = result;
|
|
445
574
|
submissions.push({ ...rest, testCaseId: match.testCaseId });
|
|
575
|
+
if (files !== void 0) filesByKey.set(result.resultKey, files);
|
|
446
576
|
} else if (match.kind === "unmatched") {
|
|
447
577
|
stats.unresolved += 1;
|
|
448
578
|
remember(result);
|
|
@@ -460,14 +590,16 @@ async function startRun(passed, declared) {
|
|
|
460
590
|
defer(part);
|
|
461
591
|
continue;
|
|
462
592
|
}
|
|
463
|
-
const out = await client.post(
|
|
464
|
-
results
|
|
465
|
-
|
|
593
|
+
const out = await client.post(
|
|
594
|
+
`/v1/runs/${runId}/results`,
|
|
595
|
+
{ results: part }
|
|
596
|
+
);
|
|
466
597
|
const counts = out.ok ? out.body?.counts : void 0;
|
|
467
598
|
const told = COUNTED.filter((k) => typeof counts?.[k] === "number");
|
|
468
599
|
if (told.length > 0) {
|
|
469
600
|
for (const k of told) stats[k] += counts[k];
|
|
470
601
|
progress();
|
|
602
|
+
await upload(out.ok ? out.body?.items : void 0, filesByKey);
|
|
471
603
|
continue;
|
|
472
604
|
}
|
|
473
605
|
if (out.ok) {
|
|
@@ -497,6 +629,12 @@ async function startRun(passed, declared) {
|
|
|
497
629
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
498
630
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
499
631
|
if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
|
|
632
|
+
const shots = stats.screenshots;
|
|
633
|
+
if (shots.uploaded + shots.skipped + shots.failed > 0) {
|
|
634
|
+
parts.push(
|
|
635
|
+
`${shots.uploaded} screenshot(s) uploaded` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} failed to upload` : "")
|
|
636
|
+
);
|
|
637
|
+
}
|
|
500
638
|
if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
|
|
501
639
|
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
502
640
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
@@ -595,6 +733,9 @@ function errorContextOf(attempt, home = homedir2()) {
|
|
|
595
733
|
const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
|
|
596
734
|
return cut(machineless(text, attempt.repoRoot, home));
|
|
597
735
|
}
|
|
736
|
+
function filesOf(attempt) {
|
|
737
|
+
return attempt.attachments.filter((a) => a.path !== void 0);
|
|
738
|
+
}
|
|
598
739
|
function machineless(raw, repoRoot, home) {
|
|
599
740
|
let text = stripVTControlCharacters(raw).replaceAll("\x1B", "");
|
|
600
741
|
const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
|
|
@@ -692,9 +833,11 @@ export {
|
|
|
692
833
|
RESULT_KEY_MAX,
|
|
693
834
|
UNSUPPORTED_VARS,
|
|
694
835
|
appendBatch,
|
|
836
|
+
ciCommit,
|
|
695
837
|
createClient,
|
|
696
838
|
errorContextOf,
|
|
697
839
|
failureOf,
|
|
840
|
+
filesOf,
|
|
698
841
|
readEnv,
|
|
699
842
|
repoRootOf,
|
|
700
843
|
resultKey,
|
package/package.json
CHANGED