donobu 5.35.0 → 5.36.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.
@@ -137,6 +137,7 @@ async function extendPage(page, options) {
137
137
  clearPageAiCache: MiscUtils_1.MiscUtils.yn(envVars_1.env.data.DONOBU_PAGE_AI_CLEAR_CACHE),
138
138
  },
139
139
  tbdSessions: [],
140
+ aiInvocations: [],
140
141
  };
141
142
  const showMouse = async (p) => {
142
143
  if (interactionVisualizer.defaultMessageDurationMillis > 0) {
@@ -217,120 +218,146 @@ Valid options:
217
218
  const pageAi = Object.assign(act, {
218
219
  act,
219
220
  assert: async (assertion, options) => {
220
- const useCache = options?.cache !== false;
221
- const clearCache = sharedState.runtimeDirectives?.clearPageAiCache ?? false;
222
- const retries = options?.retries ?? 0;
223
- const retryDelaySeconds = options?.retryDelaySeconds ?? 3;
224
- // Distill env var names from `{{$.env.*}}` interpolations in the
225
- // assertion plus any explicitly provided names/overrides. Cached
226
- // Playwright steps may carry the same `{{$.env.X}}` placeholders in
227
- // their `value`/`attributeValue` fields, so we resolve env data at
228
- // replay time and let the executor interpolate before applying.
229
- const envVarNames = (0, DonobuFlowsManager_1.distillAllowedEnvVariableNames)(assertion, [
230
- ...(options?.envVars ?? []),
231
- ...Object.keys(options?.envVals ?? {}),
232
- ]);
233
- const hasEnvRefs = envVarNames.length > 0;
234
- const resolveEnvData = async () => {
235
- if (!hasEnvRefs) {
236
- return undefined;
237
- }
238
- const envData = await sharedState.donobuStack.envDataManager.getByNames(envVarNames);
239
- if (options?.envVals) {
240
- for (const [k, v] of Object.entries(options.envVals)) {
241
- if (v === undefined) {
242
- delete envData[k];
221
+ const aiInvocationStartedAt = Date.now();
222
+ let aiInvocationCacheHit = false;
223
+ let aiInvocationError = undefined;
224
+ let aiInvocationAssertSteps;
225
+ try {
226
+ const useCache = options?.cache !== false;
227
+ const clearCache = sharedState.runtimeDirectives?.clearPageAiCache ?? false;
228
+ const retries = options?.retries ?? 0;
229
+ const retryDelaySeconds = options?.retryDelaySeconds ?? 3;
230
+ // Distill env var names from `{{$.env.*}}` interpolations in the
231
+ // assertion plus any explicitly provided names/overrides. Cached
232
+ // Playwright steps may carry the same `{{$.env.X}}` placeholders in
233
+ // their `value`/`attributeValue` fields, so we resolve env data at
234
+ // replay time and let the executor interpolate before applying.
235
+ const envVarNames = (0, DonobuFlowsManager_1.distillAllowedEnvVariableNames)(assertion, [
236
+ ...(options?.envVars ?? []),
237
+ ...Object.keys(options?.envVals ?? {}),
238
+ ]);
239
+ const hasEnvRefs = envVarNames.length > 0;
240
+ const resolveEnvData = async () => {
241
+ if (!hasEnvRefs) {
242
+ return undefined;
243
+ }
244
+ const envData = await sharedState.donobuStack.envDataManager.getByNames(envVarNames);
245
+ if (options?.envVals) {
246
+ for (const [k, v] of Object.entries(options.envVals)) {
247
+ if (v === undefined) {
248
+ delete envData[k];
249
+ }
250
+ else {
251
+ envData[k] = v;
252
+ }
243
253
  }
244
- else {
245
- envData[k] = v;
254
+ }
255
+ return envData;
256
+ };
257
+ // --- Cache lookup (when enabled and not clearing) ---
258
+ if (useCache && !clearCache) {
259
+ const cache = getOrInitPageAiCache();
260
+ const pageUrl = (0, cacheLocator_1.extractCacheKeyHostname)(page.url());
261
+ const cached = await cache.getAssert({ pageUrl, assertion });
262
+ if (cached) {
263
+ aiInvocationCacheHit = true;
264
+ aiInvocationAssertSteps = cached.steps;
265
+ Logger_1.appLogger.debug(`Assert cache HIT for: "${assertion}" - running cached Playwright assertion`);
266
+ const envData = await resolveEnvData();
267
+ let lastError = null;
268
+ for (let attempt = 0; attempt <= retries; attempt++) {
269
+ if (attempt > 0) {
270
+ Logger_1.appLogger.info(`Retry ${attempt} of ${retries} for cached assert`);
271
+ await page.waitForTimeout(retryDelaySeconds * 1000);
272
+ }
273
+ try {
274
+ await cached.run({ page, envData });
275
+ return; // Assertion passed
276
+ }
277
+ catch (error) {
278
+ lastError = error;
279
+ }
246
280
  }
281
+ // All retry attempts exhausted
282
+ throw new ToolCallFailedException_1.ToolCallFailedException(AssertTool_1.AssertTool.NAME, {
283
+ isSuccessful: false,
284
+ // Strip ANSI: Playwright matchers style their messages for the
285
+ // terminal, but this string flows into JSON-stringified exception
286
+ // messages, the LLM, and HTML/markdown reports — places where the
287
+ // codes never render and just become visible junk.
288
+ forLlm: `Assertion FAILED (cached) for: ${assertion}\nPlaywright Error: ${(0, ansi_1.stripAnsi)(lastError?.message ?? '')}`,
289
+ metadata: {
290
+ cached: true,
291
+ steps: cached.steps,
292
+ },
293
+ });
247
294
  }
248
295
  }
249
- return envData;
250
- };
251
- // --- Cache lookup (when enabled and not clearing) ---
252
- if (useCache && !clearCache) {
253
- const cache = getOrInitPageAiCache();
254
- const pageUrl = (0, cacheLocator_1.extractCacheKeyHostname)(page.url());
255
- const cached = await cache.getAssert({ pageUrl, assertion });
256
- if (cached) {
257
- Logger_1.appLogger.debug(`Assert cache HIT for: "${assertion}" - running cached Playwright assertion`);
258
- const envData = await resolveEnvData();
259
- let lastError = null;
260
- for (let attempt = 0; attempt <= retries; attempt++) {
261
- if (attempt > 0) {
262
- Logger_1.appLogger.info(`Retry ${attempt} of ${retries} for cached assert`);
263
- await page.waitForTimeout(retryDelaySeconds * 1000);
264
- }
296
+ // --- Cache invalidation (when clearing) ---
297
+ if (useCache && clearCache) {
298
+ const cache = getOrInitPageAiCache();
299
+ const pageUrl = (0, cacheLocator_1.extractCacheKeyHostname)(page.url());
300
+ await cache.deleteAssert({ pageUrl, assertion });
301
+ Logger_1.appLogger.debug(`Assert cache invalidated for: "${assertion}"`);
302
+ }
303
+ // Make env vars available to runTool's envData for `{{$.env.*}}`
304
+ // interpolation inside `assertionToTestFor` and so AssertTool can
305
+ // instruct the AI to emit placeholders in cached step values. Mirrors
306
+ // PageAi.ai for `act`: metadata.envVars is set (overwriting), envVals
307
+ // is restored.
308
+ if (hasEnvRefs) {
309
+ sharedState.donobuFlowMetadata.envVars = envVarNames;
310
+ }
311
+ const previousEnvVals = sharedState.envVals;
312
+ sharedState.envVals = options?.envVals;
313
+ let result;
314
+ try {
315
+ // --- Cache miss or cache disabled: run AI assertion ---
316
+ result = await runTool(page, AssertTool_1.AssertTool.NAME, {
317
+ assertionToTestFor: assertion,
318
+ retries: options?.retries,
319
+ retryWaitSeconds: options?.retryDelaySeconds,
320
+ }, options?.gptClient);
321
+ }
322
+ finally {
323
+ sharedState.envVals = previousEnvVals;
324
+ }
325
+ if (!result.outcome.isSuccessful) {
326
+ throw new ToolCallFailedException_1.ToolCallFailedException(AssertTool_1.AssertTool.NAME, result.outcome);
327
+ }
328
+ // --- Cache the Playwright assertion for future runs ---
329
+ if (useCache) {
330
+ const steps = result.outcome.metadata?.playwrightAssertionSteps;
331
+ if (Array.isArray(steps) && steps.length > 0) {
265
332
  try {
266
- await cached.run({ page, envData });
267
- return; // Assertion passed
333
+ const cache = getOrInitPageAiCache();
334
+ const pageUrl = (0, cacheLocator_1.extractCacheKeyHostname)(page.url());
335
+ await cache.putAssert({ pageUrl, assertion, steps });
336
+ Logger_1.appLogger.debug(`Assert cache STORED for: "${assertion}"`);
268
337
  }
269
338
  catch (error) {
270
- lastError = error;
339
+ Logger_1.appLogger.debug(`Skipping assert cache for: "${assertion}" - failed to persist: ${error.message}`);
271
340
  }
272
341
  }
273
- // All retry attempts exhausted
274
- throw new ToolCallFailedException_1.ToolCallFailedException(AssertTool_1.AssertTool.NAME, {
275
- isSuccessful: false,
276
- // Strip ANSI: Playwright matchers style their messages for the
277
- // terminal, but this string flows into JSON-stringified exception
278
- // messages, the LLM, and HTML/markdown reports — places where the
279
- // codes never render and just become visible junk.
280
- forLlm: `Assertion FAILED (cached) for: ${assertion}\nPlaywright Error: ${(0, ansi_1.stripAnsi)(lastError?.message ?? '')}`,
281
- metadata: {
282
- cached: true,
283
- steps: cached.steps,
284
- },
285
- });
286
342
  }
287
343
  }
288
- // --- Cache invalidation (when clearing) ---
289
- if (useCache && clearCache) {
290
- const cache = getOrInitPageAiCache();
291
- const pageUrl = (0, cacheLocator_1.extractCacheKeyHostname)(page.url());
292
- await cache.deleteAssert({ pageUrl, assertion });
293
- Logger_1.appLogger.debug(`Assert cache invalidated for: "${assertion}"`);
294
- }
295
- // Make env vars available to runTool's envData for `{{$.env.*}}`
296
- // interpolation inside `assertionToTestFor` and so AssertTool can
297
- // instruct the AI to emit placeholders in cached step values. Mirrors
298
- // PageAi.ai for `act`: metadata.envVars is set (overwriting), envVals
299
- // is restored.
300
- if (hasEnvRefs) {
301
- sharedState.donobuFlowMetadata.envVars = envVarNames;
302
- }
303
- const previousEnvVals = sharedState.envVals;
304
- sharedState.envVals = options?.envVals;
305
- let result;
306
- try {
307
- // --- Cache miss or cache disabled: run AI assertion ---
308
- result = await runTool(page, AssertTool_1.AssertTool.NAME, {
309
- assertionToTestFor: assertion,
310
- retries: options?.retries,
311
- retryWaitSeconds: options?.retryDelaySeconds,
312
- }, options?.gptClient);
344
+ catch (e) {
345
+ aiInvocationError = e;
346
+ throw e;
313
347
  }
314
348
  finally {
315
- sharedState.envVals = previousEnvVals;
316
- }
317
- if (!result.outcome.isSuccessful) {
318
- throw new ToolCallFailedException_1.ToolCallFailedException(AssertTool_1.AssertTool.NAME, result.outcome);
319
- }
320
- // --- Cache the Playwright assertion for future runs ---
321
- if (useCache) {
322
- const steps = result.outcome.metadata?.playwrightAssertionSteps;
323
- if (Array.isArray(steps) && steps.length > 0) {
324
- try {
325
- const cache = getOrInitPageAiCache();
326
- const pageUrl = (0, cacheLocator_1.extractCacheKeyHostname)(page.url());
327
- await cache.putAssert({ pageUrl, assertion, steps });
328
- Logger_1.appLogger.debug(`Assert cache STORED for: "${assertion}"`);
329
- }
330
- catch (error) {
331
- Logger_1.appLogger.debug(`Skipping assert cache for: "${assertion}" - failed to persist: ${error.message}`);
332
- }
333
- }
349
+ sharedState.aiInvocations.push({
350
+ kind: 'assert',
351
+ description: assertion,
352
+ startedAt: aiInvocationStartedAt,
353
+ endedAt: Date.now(),
354
+ cacheHit: aiInvocationCacheHit,
355
+ passed: aiInvocationError === undefined,
356
+ error: aiInvocationError !== undefined
357
+ ? { message: aiInvocationError?.message }
358
+ : undefined,
359
+ assertSteps: aiInvocationAssertSteps,
360
+ });
334
361
  }
335
362
  },
336
363
  extract: async (schema, options) => {
@@ -405,6 +432,9 @@ Use this information to return an appropriate JSON object.`,
405
432
  return result.metadata;
406
433
  },
407
434
  locate: async (description, options) => {
435
+ const aiInvocationStartedAt = Date.now();
436
+ let aiInvocationCacheHit = false;
437
+ let aiInvocationError = undefined;
408
438
  const useCache = options?.cache !== false;
409
439
  const clearCache = sharedState.runtimeDirectives?.clearPageAiCache ?? false;
410
440
  const pageUrl = (0, cacheLocator_1.extractCacheKeyHostname)(page.url());
@@ -463,6 +493,8 @@ Use this information to return an appropriate JSON object.`,
463
493
  timeout: remaining,
464
494
  });
465
495
  Logger_1.appLogger.debug(`Locate cache HIT for: "${description}" — rebuilt locator from cache`);
496
+ Logger_1.appLogger.info(`Located: ${candidate}`);
497
+ aiInvocationCacheHit = true;
466
498
  return candidate;
467
499
  }
468
500
  catch {
@@ -499,9 +531,25 @@ Use this information to return an appropriate JSON object.`,
499
531
  Logger_1.appLogger.debug(`Skipping locate cache for: "${description}" — failed to persist: ${error.message}`);
500
532
  }
501
533
  }
534
+ Logger_1.appLogger.info(`Located: ${locator}`);
502
535
  return locator;
503
536
  }
537
+ catch (e) {
538
+ aiInvocationError = e;
539
+ throw e;
540
+ }
504
541
  finally {
542
+ sharedState.aiInvocations.push({
543
+ kind: 'locate',
544
+ description,
545
+ startedAt: aiInvocationStartedAt,
546
+ endedAt: Date.now(),
547
+ cacheHit: aiInvocationCacheHit,
548
+ passed: aiInvocationError === undefined,
549
+ error: aiInvocationError !== undefined
550
+ ? { message: aiInvocationError?.message }
551
+ : undefined,
552
+ });
505
553
  clearTimeout(timeoutId);
506
554
  }
507
555
  },
@@ -877,6 +877,24 @@ async function finalizeTest(page, testInfo, logBuffer, videoOption) {
877
877
  catch {
878
878
  // Non-fatal: native step collection failing must not affect the test result.
879
879
  }
880
+ // Attach AI invocation wrappers (page.ai / page.ai.assert / page.ai.locate)
881
+ // so the HTML reporter can render each as a parent node containing the
882
+ // tool calls and native steps that fell inside its time window. The
883
+ // `cacheHit` flag drives the `[cached]` badge on the wrapper itself —
884
+ // not on inner actions, since a cached `page.ai` may legitimately invoke
885
+ // a live `page.ai.assert` and vice versa.
886
+ try {
887
+ const aiInvocations = sharedState.aiInvocations;
888
+ if (aiInvocations.length > 0) {
889
+ await testInfo.attach('donobu-ai-invocations', {
890
+ body: JSON.stringify(aiInvocations),
891
+ contentType: 'application/json',
892
+ });
893
+ }
894
+ }
895
+ catch {
896
+ // Non-fatal.
897
+ }
880
898
  const browserState = await BrowserUtils_1.BrowserUtils.getBrowserStorageState(page.context());
881
899
  await sharedState.persistence.setBrowserState(sharedState.donobuFlowMetadata.id, browserState);
882
900
  }
@@ -310,6 +310,18 @@ function extractTests(jsonData) {
310
310
  // Ignore parse failures
311
311
  }
312
312
  }
313
+ // Parse AI invocation wrappers from donobu-ai-invocations attachment
314
+ let aiInvocations = [];
315
+ const aiInvAtt = attachments.find((a) => a.name === 'donobu-ai-invocations');
316
+ if (aiInvAtt?.body) {
317
+ try {
318
+ const decoded = Buffer.from(aiInvAtt.body, 'base64').toString('utf8');
319
+ aiInvocations = JSON.parse(decoded);
320
+ }
321
+ catch {
322
+ // Ignore parse failures
323
+ }
324
+ }
313
325
  return {
314
326
  index: i,
315
327
  status: r.status,
@@ -334,6 +346,7 @@ function extractTests(jsonData) {
334
346
  steps: parseStderrSteps(r.stderr ?? []),
335
347
  stepScreenshots,
336
348
  nativeSteps,
349
+ aiInvocations,
337
350
  };
338
351
  });
339
352
  // Extract flow ID from the test-flow-metadata.json attachment
@@ -613,6 +626,107 @@ function renderNativeStep(ns, childrenHtml) {
613
626
  html += `</details>`;
614
627
  return html;
615
628
  }
629
+ const AI_KIND_LABELS = {
630
+ act: 'page.ai',
631
+ assert: 'page.ai.assert',
632
+ locate: 'page.ai.locate',
633
+ };
634
+ /**
635
+ * Render a single structured assertion step back as the Playwright source
636
+ * line that effectively executes — e.g. `expect(page.getByRole('heading',
637
+ * { name: 'Create an account' })).toBeVisible()`. Used to surface in the
638
+ * report what a cached `page.ai.assert` actually checked.
639
+ */
640
+ function formatAssertionStep(step) {
641
+ const quote = (s) => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
642
+ const matcher = step.valueIsRegex ? `/${step.value}/` : quote(step.value);
643
+ // Page-level assertions (no element locator)
644
+ if (step.locator === null) {
645
+ return `expect(page).${step.assertion}(${matcher})`;
646
+ }
647
+ let locatorExpr;
648
+ if (step.locator === 'role' && step.role) {
649
+ locatorExpr = `page.getByRole(${quote(step.role)}, { name: ${matcher} })`;
650
+ }
651
+ else if (step.locator === 'label') {
652
+ locatorExpr = `page.getByLabel(${matcher})`;
653
+ }
654
+ else {
655
+ locatorExpr = `page.getByText(${matcher})`;
656
+ }
657
+ locatorExpr += '.first()';
658
+ const attrValue = step.attributeValue ?? '';
659
+ switch (step.assertion) {
660
+ case 'toBeVisible':
661
+ case 'toBeEnabled':
662
+ case 'toBeDisabled':
663
+ case 'toBeChecked':
664
+ return `expect(${locatorExpr}).${step.assertion}()`;
665
+ case 'toBeHidden':
666
+ // Executor uses `not.toBeVisible()` for `toBeHidden`; mirror that here.
667
+ return `expect(${locatorExpr}).not.toBeVisible()`;
668
+ case 'toHaveValue':
669
+ case 'toContainText':
670
+ return `expect(${locatorExpr}).${step.assertion}(${quote(attrValue)})`;
671
+ case 'toHaveAttribute':
672
+ return `expect(${locatorExpr}).toHaveAttribute(${quote(step.value)}, ${quote(attrValue)})`;
673
+ default:
674
+ return `expect(${locatorExpr}).${step.assertion}(${matcher})`;
675
+ }
676
+ }
677
+ function renderAiInvocation(inv, childrenHtml) {
678
+ const statusIcon = inv.passed
679
+ ? '<span class="step-status-ok">&#10003;</span>'
680
+ : '<span class="step-status-fail">&#10007;</span>';
681
+ const kindBadge = `<span class="ai-invocation-badge ai-invocation-badge--${inv.kind}">${esc(AI_KIND_LABELS[inv.kind])}</span>`;
682
+ const cachedBadge = inv.cacheHit
683
+ ? '<span class="ai-cached-badge">cached</span>'
684
+ : '';
685
+ const hasError = !inv.passed && !!inv.error?.message;
686
+ const hasAssertSteps = !!inv.assertSteps && inv.assertSteps.length > 0;
687
+ const hasBody = hasError || !!childrenHtml || hasAssertSteps;
688
+ const renderHeader = (tag) => {
689
+ let header = `<${tag} class="filmstrip-header">`;
690
+ header += statusIcon;
691
+ header += `<span class="ai-invocation-title">${esc(inv.description)}</span>`;
692
+ header += kindBadge;
693
+ header += cachedBadge;
694
+ if (tag === 'summary') {
695
+ header +=
696
+ '<span class="native-step-chevron" aria-hidden="true">&#9656;</span>';
697
+ }
698
+ header += `</${tag}>`;
699
+ return header;
700
+ };
701
+ if (!hasBody) {
702
+ // Leaf row — no children, no error. Common for `page.ai.locate` cache
703
+ // hits and for any other invocation whose internal work didn't surface
704
+ // any captured tool calls or native steps.
705
+ return `<div class="filmstrip-step ai-invocation">${renderHeader('div')}</div>`;
706
+ }
707
+ // Failures always render expanded; passing wrappers with children open
708
+ // by default so the contents are visible without an extra click.
709
+ const defaultOpen = !inv.passed || !!childrenHtml || hasAssertSteps;
710
+ const passClass = inv.passed
711
+ ? 'ai-invocation--passed'
712
+ : 'ai-invocation--failed';
713
+ let html = `<details class="filmstrip-step ai-invocation ${passClass}"${defaultOpen ? ' open' : ''}>`;
714
+ html += renderHeader('summary');
715
+ if (hasError) {
716
+ html += `<pre class="native-step-error">${ansiToHtml(inv.error.message)}</pre>`;
717
+ }
718
+ if (hasAssertSteps) {
719
+ const lines = inv
720
+ .assertSteps.map((s) => esc(formatAssertionStep(s)))
721
+ .join('\n');
722
+ html += `<pre class="ai-assert-steps">${lines}</pre>`;
723
+ }
724
+ if (childrenHtml) {
725
+ html += childrenHtml;
726
+ }
727
+ html += `</details>`;
728
+ return html;
729
+ }
616
730
  const AUDIT_CHECK_DEFS = [
617
731
  {
618
732
  key: 'pageLoad',
@@ -852,14 +966,15 @@ function renderFilmstripStep(ss, outputDir) {
852
966
  html += `</div>`;
853
967
  return html;
854
968
  }
855
- function renderSteps(steps, stepScreenshots, nativeSteps, outputDir) {
969
+ function renderSteps(steps, stepScreenshots, nativeSteps, aiInvocations, outputDir) {
856
970
  const meaningful = steps.filter((s) => s.type === 'action' || s.type === 'result');
857
971
  const hasScreenshots = stepScreenshots.length > 0;
858
972
  const hasNative = nativeSteps.length > 0;
859
- if (!meaningful.length && !hasScreenshots && !hasNative) {
973
+ const hasAi = aiInvocations.length > 0;
974
+ if (!meaningful.length && !hasScreenshots && !hasNative && !hasAi) {
860
975
  return '';
861
976
  }
862
- if (hasScreenshots || hasNative) {
977
+ if (hasScreenshots || hasNative || hasAi) {
863
978
  const buildNativeTree = (nss) => nss.map((ns) => ({
864
979
  kind: 'native',
865
980
  ns,
@@ -868,32 +983,53 @@ function renderSteps(steps, stepScreenshots, nativeSteps, outputDir) {
868
983
  children: buildNativeTree(ns.children),
869
984
  }));
870
985
  const roots = buildNativeTree(nativeSteps);
871
- // Place each Donobu screenshot under the deepest native step whose
872
- // [start, end] window contains it. Falls back to top level if none.
873
- const placeDonobu = (nodes, d) => {
986
+ // Place a node into the deepest container whose [t, tEnd] window
987
+ // contains its [tStart, tEnd]. Returns true on placement. Both native
988
+ // steps and AI invocations are eligible parents.
989
+ const placeNode = (nodes, leaf, tStart, tEnd) => {
874
990
  for (const n of nodes) {
875
- if (n.kind !== 'native') {
991
+ if (n.kind !== 'native' && n.kind !== 'ai') {
876
992
  continue;
877
993
  }
878
- if (d.ss.startedAt >= n.t && d.ss.completedAt <= n.tEnd) {
879
- if (!placeDonobu(n.children, d)) {
880
- n.children.push(d);
994
+ if (tStart >= n.t && tEnd <= n.tEnd) {
995
+ if (!placeNode(n.children, leaf, tStart, tEnd)) {
996
+ n.children.push(leaf);
881
997
  }
882
998
  return true;
883
999
  }
884
1000
  }
885
1001
  return false;
886
1002
  };
1003
+ // AI invocations placed first, longer-window first so an outer cached
1004
+ // `page.ai` is in place before its inner `page.ai.assert` lands.
1005
+ const sortedInvocations = [...aiInvocations].sort((a, b) => b.endedAt - b.startedAt - (a.endedAt - a.startedAt));
1006
+ for (const inv of sortedInvocations) {
1007
+ const node = {
1008
+ kind: 'ai',
1009
+ inv,
1010
+ t: inv.startedAt,
1011
+ tEnd: inv.endedAt,
1012
+ children: [],
1013
+ };
1014
+ if (!placeNode(roots, node, inv.startedAt, inv.endedAt)) {
1015
+ roots.push(node);
1016
+ }
1017
+ }
887
1018
  for (const ss of stepScreenshots) {
888
- const d = { kind: 'donobu', ss, t: ss.startedAt };
889
- if (!placeDonobu(roots, d)) {
1019
+ const d = {
1020
+ kind: 'donobu',
1021
+ ss,
1022
+ t: ss.startedAt,
1023
+ tEnd: ss.completedAt,
1024
+ };
1025
+ if (!placeNode(roots, d, ss.startedAt, ss.completedAt)) {
890
1026
  roots.push(d);
891
1027
  }
892
1028
  }
893
1029
  const sortTree = (nodes) => {
894
1030
  nodes.sort((a, b) => a.t - b.t);
895
1031
  for (const n of nodes) {
896
- if (n.kind === 'native') {
1032
+ if (n.kind === 'native' || n.kind === 'ai') {
897
1033
  sortTree(n.children);
898
1034
  }
899
1035
  }
@@ -903,7 +1039,7 @@ function renderSteps(steps, stepScreenshots, nativeSteps, outputDir) {
903
1039
  let c = 0;
904
1040
  for (const n of nodes) {
905
1041
  c += 1;
906
- if (n.kind === 'native') {
1042
+ if (n.kind === 'native' || n.kind === 'ai') {
907
1043
  c += countNodes(n.children);
908
1044
  }
909
1045
  }
@@ -913,6 +1049,12 @@ function renderSteps(steps, stepScreenshots, nativeSteps, outputDir) {
913
1049
  if (node.kind === 'donobu') {
914
1050
  return renderFilmstripStep(node.ss, outputDir);
915
1051
  }
1052
+ if (node.kind === 'ai') {
1053
+ const childrenHtml = node.children.length > 0
1054
+ ? `<div class="native-step-children">${node.children.map(renderNode).join('')}</div>`
1055
+ : '';
1056
+ return renderAiInvocation(node.inv, childrenHtml);
1057
+ }
916
1058
  const childrenHtml = node.children.length > 0
917
1059
  ? `<div class="native-step-children">${node.children.map(renderNode).join('')}</div>`
918
1060
  : '';
@@ -1227,7 +1369,7 @@ function renderResultTimeline(results, outputDir) {
1227
1369
  html += `<div class="timeline-errors">${renderErrors(r.errors)}</div>`;
1228
1370
  }
1229
1371
  html += renderAttachments(r.attachments, outputDir, r.stepScreenshots);
1230
- html += renderSteps(r.steps, r.stepScreenshots, r.nativeSteps, outputDir);
1372
+ html += renderSteps(r.steps, r.stepScreenshots, r.nativeSteps, r.aiInvocations, outputDir);
1231
1373
  html += '</div></div>';
1232
1374
  }
1233
1375
  html += '</div>';
@@ -1385,7 +1527,7 @@ function renderHtml(report, triage, outputDir) {
1385
1527
  }
1386
1528
  // 6. Steps — detailed forensics
1387
1529
  if (!hasMultipleResults && lastResult) {
1388
- detailsHtml += renderSteps(lastResult.steps, lastResult.stepScreenshots, lastResult.nativeSteps, outputDir);
1530
+ detailsHtml += renderSteps(lastResult.steps, lastResult.stepScreenshots, lastResult.nativeSteps, lastResult.aiInvocations, outputDir);
1389
1531
  }
1390
1532
  // 7. Triage details — remediation steps (expandable)
1391
1533
  if (test.plan) {
@@ -1659,6 +1801,18 @@ details.native-step[open]>summary .native-step-chevron{transform:rotate(90deg)}
1659
1801
  .native-step-snippet{font-size:11px;font-family:var(--mono);margin:4px 0 2px 22px;overflow:hidden}
1660
1802
  .native-step-children{display:flex;flex-direction:column;margin:4px 0 0 10px;border-left:1px solid var(--border-subtle);padding-left:8px}
1661
1803
  .native-step-children>.filmstrip-step{padding-left:8px}
1804
+
1805
+ /* AI invocation wrappers — page.ai / page.ai.assert / page.ai.locate */
1806
+ details.ai-invocation>summary{list-style:none;cursor:pointer}
1807
+ details.ai-invocation>summary::-webkit-details-marker{display:none}
1808
+ .ai-invocation-title{font-size:12px;font-weight:500;color:var(--text);font-family:var(--mono);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1809
+ .ai-invocation-badge{font-size:10px;font-weight:600;padding:1px 5px;border-radius:3px;white-space:nowrap;flex-shrink:0;font-family:var(--mono)}
1810
+ .ai-invocation-badge--act{background:rgba(168,85,247,.12);color:#c084fc}
1811
+ .ai-invocation-badge--assert{background:rgba(236,72,153,.12);color:#f472b6}
1812
+ .ai-invocation-badge--locate{background:rgba(59,130,246,.12);color:#60a5fa}
1813
+ .ai-cached-badge{font-size:10px;font-weight:600;padding:1px 5px;border-radius:3px;white-space:nowrap;flex-shrink:0;background:rgba(245,158,11,.12);color:#fbbf24}
1814
+ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)}
1815
+ .ai-assert-steps{font-size:11px;font-family:var(--mono);background:var(--bg);border:1px solid var(--border-subtle);border-radius:var(--radius);padding:8px 12px;margin:6px 0 2px 22px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;overflow-x:auto;max-height:240px;overflow-y:auto}
1662
1816
  .snippet-line{display:flex;padding:1px 8px;white-space:pre}
1663
1817
  .snippet-line--target{background:rgba(239,68,68,.10)}
1664
1818
  .snippet-linenum{color:var(--text-dim);min-width:40px;user-select:none}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "donobu",
3
- "version": "5.35.0",
3
+ "version": "5.36.0",
4
4
  "description": "Create browser automations with an LLM agent and replay them as Playwright scripts.",
5
5
  "main": "dist/main.js",
6
6
  "module": "dist/esm/main.js",