@molecule/api-ai-tools 1.8.3 → 1.9.2
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 +264 -14
- package/dist/failure-kind.d.ts +66 -0
- package/dist/failure-kind.d.ts.map +1 -0
- package/dist/failure-kind.js +134 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/pipeline-exit-note.d.ts +37 -0
- package/dist/pipeline-exit-note.d.ts.map +1 -0
- package/dist/pipeline-exit-note.js +154 -0
- package/dist/suite-overlap.d.ts +35 -1
- package/dist/suite-overlap.d.ts.map +1 -1
- package/dist/suite-overlap.js +57 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +14 -0
- package/dist/trace-artifacts.d.ts +77 -7
- package/dist/trace-artifacts.d.ts.map +1 -1
- package/dist/trace-artifacts.js +320 -28
- package/dist/utilities.d.ts +25 -4
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +127 -13
- package/package.json +1 -1
package/dist/utilities.js
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { posix } from 'path';
|
|
8
8
|
import { commandSegments, locatedCommandSegments } from './command-position.js';
|
|
9
|
+
import { classifyRunFailure } from './failure-kind.js';
|
|
9
10
|
import { commandRunsNoTests } from './no-test-run.js';
|
|
10
|
-
import { isDiagnosisRun, scopedRerunCommand, suiteCommandTarget, suiteRunInTheWay, withoutHeredocs, } from './suite-overlap.js';
|
|
11
|
+
import { isDiagnosisRun, scopedRerunCommand, suiteCommandTarget, suiteRunInTheWay, withoutHeredocs, withSuiteConfig, } from './suite-overlap.js';
|
|
11
12
|
/**
|
|
12
13
|
* Shell-safe quoting using single quotes. Unlike JSON.stringify (double quotes),
|
|
13
14
|
* single-quoted strings prevent command substitution ($(), backticks) and variable expansion.
|
|
@@ -1785,6 +1786,32 @@ function normalizedSignature(s) {
|
|
|
1785
1786
|
.replace(/\b\d{5,}\b/g, '<n>')
|
|
1786
1787
|
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<id>'));
|
|
1787
1788
|
}
|
|
1789
|
+
/**
|
|
1790
|
+
* The per-test budget a failure block's own message names, in ms, or null. Only the runner's
|
|
1791
|
+
* budget kill (`Test timeout of 15000ms exceeded`) is read: a locator wait's own
|
|
1792
|
+
* `Timeout 5000ms exceeded` is the config's action timeout, not the budget a test body has to fit
|
|
1793
|
+
* its repeated work into.
|
|
1794
|
+
*
|
|
1795
|
+
* @param block - One failure block, as {@link failureBlocks} splits them.
|
|
1796
|
+
* @returns The budget in ms, or null.
|
|
1797
|
+
*/
|
|
1798
|
+
function failureBudgetMs(block) {
|
|
1799
|
+
const m = /\bTest timeout of (\d+)ms exceeded\b/.exec(block.replace(ANSI_SGR, ''));
|
|
1800
|
+
return m ? Number(m[1]) : null;
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* The cause signature with the budget it normalized away put back: `Test timeout of <n> exceeded`
|
|
1804
|
+
* becomes `Test timeout of 15000ms exceeded`. Every other `<n>` stays — those are the values that
|
|
1805
|
+
* differ between tests of one cause, while the budget is the one that does not, and it is what
|
|
1806
|
+
* turns "a timeout" into "15 creates in 15 s" (xr-molecule-flash-m110 +20:44).
|
|
1807
|
+
*
|
|
1808
|
+
* @param message - The normalized signature.
|
|
1809
|
+
* @param budgetMs - The budget the cause's first block printed.
|
|
1810
|
+
* @returns The signature to print.
|
|
1811
|
+
*/
|
|
1812
|
+
function signatureWithBudget(message, budgetMs) {
|
|
1813
|
+
return message.replace(/Test timeout of <n> exceeded/, `Test timeout of ${budgetMs}ms exceeded`);
|
|
1814
|
+
}
|
|
1788
1815
|
/**
|
|
1789
1816
|
* What separates one cause from another in a `playwright test` log: the error CLASS plus the
|
|
1790
1817
|
* normalized message — the error line, the thing it waited for or asserted on, and the values
|
|
@@ -2233,18 +2260,31 @@ function groupFailures(log, excluded) {
|
|
|
2233
2260
|
if (group)
|
|
2234
2261
|
group.tests.push(test);
|
|
2235
2262
|
// The FIRST block with this signature carries the evidence: a later block of the same cause
|
|
2236
|
-
// says the same thing, and the first is the one the note's own line refers to.
|
|
2263
|
+
// says the same thing, and the first is the one the note's own line refers to. Its LAYER
|
|
2264
|
+
// rides along too: a cause that never reached the app is not an app-code fix
|
|
2265
|
+
// (`failure-kind.ts`).
|
|
2237
2266
|
else
|
|
2238
2267
|
byMessage.set(message, {
|
|
2239
2268
|
tests: [test],
|
|
2240
2269
|
evidence: failureEvidence(failureBody(block), message),
|
|
2270
|
+
budgetMs: failureBudgetMs(block),
|
|
2271
|
+
kind: classifyRunFailure(block)?.kind ?? 'assertion',
|
|
2241
2272
|
});
|
|
2242
2273
|
}
|
|
2243
2274
|
const causes = [];
|
|
2244
2275
|
for (const [message, group] of byMessage) {
|
|
2245
2276
|
const tests = distinctTests(group.tests);
|
|
2246
2277
|
if (tests.length > 0)
|
|
2247
|
-
causes.push({
|
|
2278
|
+
causes.push({
|
|
2279
|
+
message,
|
|
2280
|
+
seconds: null,
|
|
2281
|
+
tests,
|
|
2282
|
+
evidence: group.evidence,
|
|
2283
|
+
// A duration group carries no block, so nothing says the transport failed: a cause with
|
|
2284
|
+
// no layer of its own is read as an assertion (unknown is never `connection`).
|
|
2285
|
+
kind: group.kind,
|
|
2286
|
+
...(group.budgetMs === null ? {} : { budgetMs: group.budgetMs }),
|
|
2287
|
+
});
|
|
2248
2288
|
}
|
|
2249
2289
|
// The failures with no body of their own — a `list` log with no error blocks in it, from a run
|
|
2250
2290
|
// not started in the background — are grouped by how long they took: a band is opened by its
|
|
@@ -2269,7 +2309,13 @@ function groupFailures(log, excluded) {
|
|
|
2269
2309
|
byDuration.push({ seconds: t.seconds, tests: [t] });
|
|
2270
2310
|
}
|
|
2271
2311
|
for (const g of byDuration)
|
|
2272
|
-
causes.push({
|
|
2312
|
+
causes.push({
|
|
2313
|
+
message: null,
|
|
2314
|
+
seconds: g.seconds,
|
|
2315
|
+
tests: g.tests,
|
|
2316
|
+
evidence: [],
|
|
2317
|
+
kind: 'assertion',
|
|
2318
|
+
});
|
|
2273
2319
|
const ignored = distinctTests(named).filter((t) => matchingExcludedTitles(t.title, excluded).length > 0);
|
|
2274
2320
|
return {
|
|
2275
2321
|
causes: causes.sort((a, b) => b.tests.length - a.tests.length),
|
|
@@ -2277,6 +2323,46 @@ function groupFailures(log, excluded) {
|
|
|
2277
2323
|
flaky: flakyTests,
|
|
2278
2324
|
};
|
|
2279
2325
|
}
|
|
2326
|
+
/**
|
|
2327
|
+
* The error every test of a Playwright run reports when the run loaded no base URL: `page.goto('/')`
|
|
2328
|
+
* has nothing to resolve against, so the browser refuses the navigation before any page loads.
|
|
2329
|
+
* xr-molecule-flash-m113 (+20:34) got ten of these and a list that called them ten causes.
|
|
2330
|
+
*/
|
|
2331
|
+
const NO_BASE_URL_ERROR = /Cannot navigate to invalid URL/i;
|
|
2332
|
+
/**
|
|
2333
|
+
* The one-line note for a run whose EVERY failure is {@link NO_BASE_URL_ERROR}: a configuration
|
|
2334
|
+
* failure, not a result about the app.
|
|
2335
|
+
*
|
|
2336
|
+
* A list of causes is the wrong shape here — there is one cause, and it is not in the app: the
|
|
2337
|
+
* command loaded no base URL. The line names the config the run should have loaded (the caller
|
|
2338
|
+
* passes the one the platform recorded for this suite) and hands over the exact narrowed command
|
|
2339
|
+
* with it, so the next run measures the app instead of spending another six minutes on the same
|
|
2340
|
+
* configuration mistake (xr-molecule-flash-m113 ran four such runs, 07:06:17 → 07:12:03).
|
|
2341
|
+
*
|
|
2342
|
+
* @param causes - The run's failures, all carrying the same error.
|
|
2343
|
+
* @param run - How the run was started, when the caller knows it.
|
|
2344
|
+
* @param excluded - The test titles the request carves out (inverted out of the re-run).
|
|
2345
|
+
* @returns The one-line sentence.
|
|
2346
|
+
*/
|
|
2347
|
+
function noBaseUrlConfigNote(causes, run, excluded) {
|
|
2348
|
+
const total = causes.reduce((n, c) => n + c.tests.length, 0);
|
|
2349
|
+
const locations = [...new Set(causes.flatMap((c) => c.tests.map((t) => t.where)).filter(Boolean))];
|
|
2350
|
+
const config = run?.config ?? null;
|
|
2351
|
+
// The command is only worth handing over WITH the config: the same command without it is the
|
|
2352
|
+
// run that just failed this way. Where the caller does not know the config, the line says what
|
|
2353
|
+
// to find instead of repeating the mistake.
|
|
2354
|
+
const rerun = run && config ? rerunOfRunningSuite(run, locations, excluded) : null;
|
|
2355
|
+
const command = rerun ?? (run && config ? withSuiteConfig(run.command, config) : null);
|
|
2356
|
+
return (`Every one of these ${total} failures is the same configuration failure, not a result about ` +
|
|
2357
|
+
`the app: the run loaded no base URL, so every \`page.goto\` was refused with ` +
|
|
2358
|
+
`\`Cannot navigate to invalid URL\` before a page opened. Nothing here is a defect to fix. ` +
|
|
2359
|
+
(config
|
|
2360
|
+
? `\`${config}\` is the config that sets \`use.baseURL\`, and the run before this one loaded ` +
|
|
2361
|
+
`it; this command left it out. `
|
|
2362
|
+
: 'The app’s own Playwright config (the one that sets `use.baseURL`) has to be passed ' +
|
|
2363
|
+
'with `--config`; this command names none. ') +
|
|
2364
|
+
(command ? `Send exactly this: \`${command}\`` : ''));
|
|
2365
|
+
}
|
|
2280
2366
|
/**
|
|
2281
2367
|
* The one-line-per-cause summary of a finished run's failures, or null when the run named fewer
|
|
2282
2368
|
* than two of them.
|
|
@@ -2293,11 +2379,15 @@ function groupFailures(log, excluded) {
|
|
|
2293
2379
|
* exactly those tests ({@link rerunOfRunningSuite}) instead of leaving the executor to re-run the
|
|
2294
2380
|
* whole suite.
|
|
2295
2381
|
*
|
|
2382
|
+
* A run whose failures are ALL {@link NO_BASE_URL_ERROR} is not a set of causes at all: it is one
|
|
2383
|
+
* configuration failure, said in one line ({@link noBaseUrlConfigNote}) rather than a list that
|
|
2384
|
+
* reads as ten defects in the app.
|
|
2385
|
+
*
|
|
2296
2386
|
* @param log - The finished run's output.
|
|
2297
2387
|
* @param excluded - The test titles the request carves out, if the caller knows them.
|
|
2298
2388
|
* @param run - How the run was started, when the caller knows it: the narrowed re-run is built
|
|
2299
|
-
* from `command`/`cwd`, and `log` (the path the output was written to) is what the
|
|
2300
|
-
* sentence greps when more causes exist than the result has room for.
|
|
2389
|
+
* from `command`/`cwd`/`config`, and `log` (the path the output was written to) is what the
|
|
2390
|
+
* "left out" sentence greps when more causes exist than the result has room for.
|
|
2301
2391
|
* @returns The note, or null.
|
|
2302
2392
|
*/
|
|
2303
2393
|
export function failureCausesNote(log, excluded = [], run) {
|
|
@@ -2305,6 +2395,10 @@ export function failureCausesNote(log, excluded = [], run) {
|
|
|
2305
2395
|
const total = causes.reduce((n, c) => n + c.tests.length, 0);
|
|
2306
2396
|
if (total < 2)
|
|
2307
2397
|
return null;
|
|
2398
|
+
// One cause that is not in the app: every failure is the browser refusing a navigation with no
|
|
2399
|
+
// base URL. The list shape is what m113 read as ten causes, so it is never printed for this.
|
|
2400
|
+
if (causes.every((c) => c.message !== null && NO_BASE_URL_ERROR.test(c.message)))
|
|
2401
|
+
return noBaseUrlConfigNote(causes, run, excluded);
|
|
2308
2402
|
// The narrowed run, so the next fix round costs two tests instead of the whole suite
|
|
2309
2403
|
// (xr-molecule-flash-m101 +12:13 re-ran all 139, ~4 minutes, to check two). The command is the
|
|
2310
2404
|
// run's own invocation narrowed by the ONE composer the mid-run note also uses
|
|
@@ -2314,8 +2408,21 @@ export function failureCausesNote(log, excluded = [], run) {
|
|
|
2314
2408
|
const rerun = run ? rerunOfRunningSuite(run, locations, excluded) : null;
|
|
2315
2409
|
const ignoredTitles = [...new Set(ignored.map((t) => t.title || t.where).filter(Boolean))];
|
|
2316
2410
|
const flakyTitles = [...new Set(flaky.map((t) => t.where || t.title).filter(Boolean))];
|
|
2411
|
+
// A cause that never reached the app is not a cause the app's code can fix: the transport
|
|
2412
|
+
// failed, so the one action is a re-run (xr-molecule-flash-m110 +18:42 — a 20 s connection
|
|
2413
|
+
// timeout was read as an assertion and answered with a CORS middleware). Every cause being one
|
|
2414
|
+
// is its own sentence: nothing the app produced was measured, so there is no fix round at all.
|
|
2415
|
+
const connections = causes.filter((cause) => cause.kind === 'connection');
|
|
2416
|
+
if (causes.length > 0 && connections.length === causes.length) {
|
|
2417
|
+
const at = [...new Set(connections.flatMap((c) => c.tests.map((t) => t.where)).filter(Boolean))];
|
|
2418
|
+
return (`${total} failure${total === 1 ? '' : 's'} in this run, and every one is a CONNECTION error — ` +
|
|
2419
|
+
`the requests never reached the app, so nothing here is an app-code cause${at.length ? `: ${at.join(', ')}` : ''}. ` +
|
|
2420
|
+
`Send the same file${at.length === 1 ? '' : 's'} once more as ${at.length === 1 ? 'it is' : 'they are'}; ` +
|
|
2421
|
+
"if the failure repeats, read the app's own log (`read_logs`) — a connection error is never a " +
|
|
2422
|
+
'handler shape, a header or CORS.');
|
|
2423
|
+
}
|
|
2317
2424
|
const header = `${total} failure${total === 1 ? '' : 's'} in this run, grouped by cause — fix every cause ` +
|
|
2318
|
-
`below before the next deploy:\n`;
|
|
2425
|
+
`below${connections.length > 0 ? ' the app’s code can produce, and re-send the connection error once as it is' : ' before the next deploy'}:\n`;
|
|
2319
2426
|
// The flaky sentence sits before the ignored one: the ignored sentence is the LAST thing the
|
|
2320
2427
|
// note says, and callers assert on that ending.
|
|
2321
2428
|
const tail = (rerun ? ` Re-run just these once they are fixed: \`${rerun}\`` : '') +
|
|
@@ -2323,7 +2430,13 @@ export function failureCausesNote(log, excluded = [], run) {
|
|
|
2323
2430
|
? ' Flaky (failed once, passed on the config retry — the runner counts these as flaky, not ' +
|
|
2324
2431
|
`as failures): ${flakyTitles.join(', ')}.`
|
|
2325
2432
|
: '') +
|
|
2326
|
-
(ignoredTitles.length
|
|
2433
|
+
(ignoredTitles.length
|
|
2434
|
+
? ` Ignored (excluded by the request): ${ignoredTitles.join(', ')}.`
|
|
2435
|
+
: '') +
|
|
2436
|
+
(connections.length > 0
|
|
2437
|
+
? ` The connection error is not app code: send that file once more as it is, and if it repeats, ` +
|
|
2438
|
+
"read the app's own log (`read_logs`) — it never reached the app."
|
|
2439
|
+
: '');
|
|
2327
2440
|
// This list is the part of the result that SURVIVES the platform's tool-output cut: the stdout
|
|
2328
2441
|
// above it is cut in the MIDDLE at {@link TOOL_RESULT_CHARS}, which is why the recap must fit
|
|
2329
2442
|
// whole ({@link FAILURE_CAUSES_CHARS}) instead of being capped at a line count that left 3 of
|
|
@@ -2359,14 +2472,15 @@ function failureCauseLine(cause, index) {
|
|
|
2359
2472
|
const tests = cause.tests.slice(0, FAILURE_CAUSE_TESTS_LISTED).map((t) => t.where || t.title);
|
|
2360
2473
|
const more = cause.tests.length > tests.length ? `, … and ${cause.tests.length - tests.length} more` : '';
|
|
2361
2474
|
const signature = cause.message !== null
|
|
2362
|
-
? `\`${cause.message}\``
|
|
2475
|
+
? `\`${cause.budgetMs === undefined ? cause.message : signatureWithBudget(cause.message, cause.budgetMs)}\``
|
|
2363
2476
|
: `each after ~${cause.seconds}s (a shared duration, not a proven cause)`;
|
|
2364
2477
|
const files = new Set(cause.tests.map((t) => specFile(t.where)).filter(Boolean)).size;
|
|
2365
2478
|
// A line the signature already carries (a timeout's `waiting for` locator, say) is not printed
|
|
2366
2479
|
// twice; the rest are the values the normalised signature replaced with `<n>`.
|
|
2367
2480
|
const body = cause.evidence.filter((line) => cause.message === null || !cause.message.includes(line));
|
|
2368
2481
|
const shortened = body.map((line) => line.length > FAILURE_EVIDENCE_CHARS ? `${line.slice(0, FAILURE_EVIDENCE_CHARS - 1)}…` : line);
|
|
2369
|
-
return (` ${index + 1}. ${
|
|
2482
|
+
return (` ${index + 1}. ${cause.kind === 'connection' ? 'CONNECTION error, not the app’s — ' : ''}${signature} — ` +
|
|
2483
|
+
`${cause.tests.length} test${cause.tests.length === 1 ? '' : 's'} ` +
|
|
2370
2484
|
`in ${files} file${files === 1 ? '' : 's'}: ${tests.join(', ')}${more}` +
|
|
2371
2485
|
(shortened.length ? `\n ${shortened.join(' · ')}` : ''));
|
|
2372
2486
|
}
|
|
@@ -2430,8 +2544,8 @@ function omittedCausesRecap(omitted, run, excluded) {
|
|
|
2430
2544
|
* the same app and database, and that is exactly what is refused while the run walks
|
|
2431
2545
|
* ({@link suiteRunInTheWay}).
|
|
2432
2546
|
*
|
|
2433
|
-
* @param run - The run in flight: the command it was started with, as it was run,
|
|
2434
|
-
*
|
|
2547
|
+
* @param run - The run in flight: the command it was started with, as it was run, the directory
|
|
2548
|
+
* that command ran in, and the config it loaded when the caller knows it.
|
|
2435
2549
|
* @param locations - The failing `file:line`s to re-run, as the run's log printed them.
|
|
2436
2550
|
* @param excluded - The test titles the request carves out: the re-run inverts them out
|
|
2437
2551
|
* (`--grep-invert`) so it cannot report them again.
|
|
@@ -2440,7 +2554,7 @@ function omittedCausesRecap(omitted, run, excluded) {
|
|
|
2440
2554
|
export function rerunOfRunningSuite(run, locations, excluded = []) {
|
|
2441
2555
|
if (locations.length === 0)
|
|
2442
2556
|
return null;
|
|
2443
|
-
const scoped = scopedRerunCommand(run.command, locations, excluded);
|
|
2557
|
+
const scoped = scopedRerunCommand(run.command, locations, excluded, run.config);
|
|
2444
2558
|
if (scoped === run.command)
|
|
2445
2559
|
return null;
|
|
2446
2560
|
if (!run.cwd || commandSegments(withoutHeredocs(run.command)).some((s) => s.program === 'cd'))
|