@molecule/api-ai-tools 1.8.3 → 1.9.1

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/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({ message, seconds: null, tests, evidence: group.evidence });
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({ message: null, seconds: g.seconds, tests: g.tests, evidence: [] });
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 "left out"
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 ? ` Ignored (excluded by the request): ${ignoredTitles.join(', ')}.` : '');
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}. ${signature} — ${cause.tests.length} test${cause.tests.length === 1 ? '' : 's'} ` +
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, and the
2434
- * directory that command ran in.
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'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-tools",
3
- "version": "1.8.3",
3
+ "version": "1.9.1",
4
4
  "description": "Shared AI agent tools with backend abstraction for sandbox and local execution",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",