donobu 5.41.3 → 5.42.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.
@@ -150,6 +150,7 @@ class PageAi {
150
150
  async ai(page, instruction, options) {
151
151
  const startedAt = Date.now();
152
152
  let cacheHit = false;
153
+ let cacheStored = false;
153
154
  let thrownError = undefined;
154
155
  try {
155
156
  const descriptor = this.buildDescriptor(page, instruction, options);
@@ -197,6 +198,7 @@ class PageAi {
197
198
  }, this.donobu.toolRegistry);
198
199
  const cacheEntry = cacheEntryBuilder_1.PageAiCacheEntryBuilder.fromMetadata(descriptor.key.pageUrl, runResult.donobuFlow.metadata, preparedToolCalls);
199
200
  await this.cache.put(cacheEntry);
201
+ cacheStored = true;
200
202
  }
201
203
  return runResult.parsedResult;
202
204
  }
@@ -212,6 +214,7 @@ class PageAi {
212
214
  startedAt,
213
215
  endedAt: Date.now(),
214
216
  cacheHit,
217
+ cacheStored,
215
218
  passed: thrownError === undefined,
216
219
  error: thrownError !== undefined
217
220
  ? { message: thrownError?.message }
@@ -488,10 +488,40 @@ export interface AiInvocationRecord {
488
488
  startedAt: number;
489
489
  endedAt: number;
490
490
  cacheHit: boolean;
491
+ /**
492
+ * For live (non-replay) invocations: `true` once this run successfully
493
+ * wrote an entry into the relevant page-AI cache, `false` if a write was
494
+ * attempted (or would have been) but didn't land. Combined with
495
+ * `cacheHit`, this gives the reporter a tri-state cache outcome — hit
496
+ * (replayed), stored (live + recorded), or miss (live + nothing cached).
497
+ * Always `false` when `cacheHit` is `true`.
498
+ */
499
+ cacheStored: boolean;
491
500
  passed: boolean;
492
501
  error?: {
493
502
  message?: string;
494
503
  };
504
+ /**
505
+ * For live `page.ai.assert` runs: metadata about the post-pass structured
506
+ * step verification. After the AI judges the assertion passed against a
507
+ * screenshot, AssertTool re-executes the AI-emitted Playwright `expect()`
508
+ * calls against the page to decide whether those structured steps are
509
+ * cache-worthy. When `failed: true`, the AI's visual verdict still stands
510
+ * — the tool returns success — but one of the structured `expect()` calls
511
+ * underneath threw. The reporter uses this to surface the divergence as a
512
+ * labelled signal rather than render the inner expect failure as a regular
513
+ * assertion failure.
514
+ *
515
+ * Undefined when verification didn't run (no structured steps emitted, AI
516
+ * verdict was failed, cached replay path, or AssertTool invoked outside
517
+ * the page.ai.assert wrapper).
518
+ */
519
+ verification?: {
520
+ startedAt: number;
521
+ endedAt: number;
522
+ failed: boolean;
523
+ errorMessage?: string;
524
+ };
495
525
  /**
496
526
  * For cached `page.ai.assert` invocations: the structured Playwright
497
527
  * assertion steps that were replayed. The reporter formats these back
@@ -220,8 +220,10 @@ Valid options:
220
220
  assert: async (assertion, options) => {
221
221
  const aiInvocationStartedAt = Date.now();
222
222
  let aiInvocationCacheHit = false;
223
+ let aiInvocationCacheStored = false;
223
224
  let aiInvocationError = undefined;
224
225
  let aiInvocationAssertSteps;
226
+ let aiInvocationVerification;
225
227
  try {
226
228
  const useCache = options?.cache !== false;
227
229
  const clearCache = sharedState.runtimeDirectives?.clearPageAiCache ?? false;
@@ -322,6 +324,7 @@ Valid options:
322
324
  finally {
323
325
  sharedState.envVals = previousEnvVals;
324
326
  }
327
+ aiInvocationVerification = result.outcome.metadata?.verification;
325
328
  if (!result.outcome.isSuccessful) {
326
329
  throw new ToolCallFailedException_1.ToolCallFailedException(AssertTool_1.AssertTool.NAME, result.outcome);
327
330
  }
@@ -333,6 +336,7 @@ Valid options:
333
336
  const cache = getOrInitPageAiCache();
334
337
  const pageUrl = (0, cacheLocator_1.extractCacheKeyHostname)(page.url());
335
338
  await cache.putAssert({ pageUrl, assertion, steps });
339
+ aiInvocationCacheStored = true;
336
340
  Logger_1.appLogger.debug(`Assert cache STORED for: "${assertion}"`);
337
341
  }
338
342
  catch (error) {
@@ -352,11 +356,13 @@ Valid options:
352
356
  startedAt: aiInvocationStartedAt,
353
357
  endedAt: Date.now(),
354
358
  cacheHit: aiInvocationCacheHit,
359
+ cacheStored: aiInvocationCacheStored,
355
360
  passed: aiInvocationError === undefined,
356
361
  error: aiInvocationError !== undefined
357
362
  ? { message: aiInvocationError?.message }
358
363
  : undefined,
359
364
  assertSteps: aiInvocationAssertSteps,
365
+ verification: aiInvocationVerification,
360
366
  });
361
367
  }
362
368
  },
@@ -434,6 +440,7 @@ Use this information to return an appropriate JSON object.`,
434
440
  locate: async (description, options) => {
435
441
  const aiInvocationStartedAt = Date.now();
436
442
  let aiInvocationCacheHit = false;
443
+ let aiInvocationCacheStored = false;
437
444
  let aiInvocationError = undefined;
438
445
  const useCache = options?.cache !== false;
439
446
  const clearCache = sharedState.runtimeDirectives?.clearPageAiCache ?? false;
@@ -525,6 +532,7 @@ Use this information to return an appropriate JSON object.`,
525
532
  try {
526
533
  const cache = getOrInitPageAiCache();
527
534
  await cache.putLocate({ pageUrl, description, result });
535
+ aiInvocationCacheStored = true;
528
536
  Logger_1.appLogger.debug(`Locate cache STORED for: "${description}"`);
529
537
  }
530
538
  catch (error) {
@@ -545,6 +553,7 @@ Use this information to return an appropriate JSON object.`,
545
553
  startedAt: aiInvocationStartedAt,
546
554
  endedAt: Date.now(),
547
555
  cacheHit: aiInvocationCacheHit,
556
+ cacheStored: aiInvocationCacheStored,
548
557
  passed: aiInvocationError === undefined,
549
558
  error: aiInvocationError !== undefined
550
559
  ? { message: aiInvocationError?.message }
@@ -48,6 +48,10 @@ function buildDonobuReport(resultsByTest, rootDir) {
48
48
  const testEntries = tests.map((test) => {
49
49
  const results = resultsByTest.get(test) ?? [];
50
50
  return {
51
+ // Playwright's stable per-(file, project, title) ID — used by the
52
+ // merge step's `byId` index and by the HTML renderer for permalink
53
+ // anchors (`index.html#?testId=<id>`) and the per-test redirect stubs.
54
+ testId: test.id,
51
55
  annotations: test.annotations,
52
56
  tags: test.tags,
53
57
  projectName: getProjectName(test),
@@ -51,5 +51,16 @@ export default class DonobuHtmlReporter implements Reporter {
51
51
  onTestEnd(test: TestCase, result: TestResult): void;
52
52
  onEnd(_result: FullResult): Promise<void>;
53
53
  printsToStdio(): boolean;
54
+ /**
55
+ * Drop a tiny redirect `index.html` into each Playwright-managed per-test
56
+ * directory under `outputDir`. The stub points back at the combined report's
57
+ * `#?testId=<id>` deep link, giving every test a stable URL inside its own
58
+ * directory without duplicating any of the rendered HTML.
59
+ *
60
+ * Directories are discovered from `dirname(attachment.path)` — Donobu does
61
+ * not invent any directory naming or layout. Tests with no attachments (and
62
+ * therefore no Playwright-created directory) get no stub.
63
+ */
64
+ private writePerTestStubs;
54
65
  }
55
66
  //# sourceMappingURL=html.d.ts.map
@@ -82,10 +82,65 @@ class DonobuHtmlReporter {
82
82
  (0, fs_1.mkdirSync)(outputDir, { recursive: true });
83
83
  (0, fs_1.writeFileSync)(outputFile, html, 'utf8');
84
84
  Logger_1.appLogger.info(`Donobu report written to ${outputFile}`);
85
+ this.writePerTestStubs(outputFile, outputDir);
85
86
  }
86
87
  printsToStdio() {
87
88
  return false;
88
89
  }
90
+ /**
91
+ * Drop a tiny redirect `index.html` into each Playwright-managed per-test
92
+ * directory under `outputDir`. The stub points back at the combined report's
93
+ * `#?testId=<id>` deep link, giving every test a stable URL inside its own
94
+ * directory without duplicating any of the rendered HTML.
95
+ *
96
+ * Directories are discovered from `dirname(attachment.path)` — Donobu does
97
+ * not invent any directory naming or layout. Tests with no attachments (and
98
+ * therefore no Playwright-created directory) get no stub.
99
+ */
100
+ writePerTestStubs(outputFile, outputDir) {
101
+ for (const [test, results] of this.resultsByTest) {
102
+ if (!test.id) {
103
+ continue;
104
+ }
105
+ const testDirs = new Set();
106
+ for (const result of results) {
107
+ for (const att of result.attachments) {
108
+ if (!att.path) {
109
+ continue;
110
+ }
111
+ const attDir = (0, path_1.dirname)((0, path_1.resolve)(att.path));
112
+ const rel = (0, path_1.relative)(outputDir, attDir);
113
+ if (!rel || rel.startsWith('..')) {
114
+ // Attachment lives outside the report's output directory — skip;
115
+ // we shouldn't be writing into arbitrary paths.
116
+ continue;
117
+ }
118
+ testDirs.add(attDir);
119
+ }
120
+ }
121
+ if (testDirs.size === 0) {
122
+ continue;
123
+ }
124
+ const fileBase = test.location.file.split('/').pop() ?? test.location.file;
125
+ const title = `${fileBase} › ${test.title}`;
126
+ for (const testDir of testDirs) {
127
+ const stubPath = (0, path_1.resolve)(testDir, 'index.html');
128
+ const relPathToReport = (0, path_1.relative)(testDir, outputFile);
129
+ const stub = (0, render_1.renderPerTestStub)({
130
+ testId: test.id,
131
+ title,
132
+ relPathToReport,
133
+ });
134
+ try {
135
+ (0, fs_1.mkdirSync)(testDir, { recursive: true });
136
+ (0, fs_1.writeFileSync)(stubPath, stub, 'utf8');
137
+ }
138
+ catch (err) {
139
+ Logger_1.appLogger.warn(`Failed to write per-test redirect stub at ${stubPath}: ${err.message}`);
140
+ }
141
+ }
142
+ }
143
+ }
89
144
  }
90
145
  exports.default = DonobuHtmlReporter;
91
146
  //# sourceMappingURL=html.js.map
@@ -140,5 +140,20 @@ export interface TriageData {
140
140
  }
141
141
  export declare function loadTriageData(triageDir: string): TriageData;
142
142
  export declare function renderHtml(report: DonobuReport, triage: TriageData, outputDir: string | null): string;
143
+ /**
144
+ * Render the tiny redirect HTML that Donobu drops into each Playwright-managed
145
+ * per-test directory under `test-results/`. The stub bounces straight to the
146
+ * combined report's `#?testId=<id>` deep link — meta-refresh + JS replace +
147
+ * visible fallback link, so it works with or without JS, online or `file://`.
148
+ *
149
+ * Strictly additive: Donobu does not create or rename Playwright's per-test
150
+ * directories — the caller in `html.ts` only writes this file into directories
151
+ * Playwright already created for the test's attachments.
152
+ */
153
+ export declare function renderPerTestStub(params: {
154
+ testId: string;
155
+ title: string;
156
+ relPathToReport: string;
157
+ }): string;
143
158
  export {};
144
159
  //# sourceMappingURL=render.d.ts.map