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.
- package/dist/esm/lib/ai/PageAi.js +65 -42
- package/dist/esm/lib/page/DonobuExtendedPage.d.ts +33 -0
- package/dist/esm/lib/page/extendPage.js +149 -101
- package/dist/esm/lib/test/testExtension.js +18 -0
- package/dist/esm/reporter/render.js +170 -16
- package/dist/lib/ai/PageAi.js +65 -42
- package/dist/lib/page/DonobuExtendedPage.d.ts +33 -0
- package/dist/lib/page/extendPage.js +149 -101
- package/dist/lib/test/testExtension.js +18 -0
- package/dist/reporter/render.js +170 -16
- package/package.json +1 -1
|
@@ -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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
}
|
package/dist/reporter/render.js
CHANGED
|
@@ -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">✓</span>'
|
|
680
|
+
: '<span class="step-status-fail">✗</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">▸</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
|
-
|
|
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
|
|
872
|
-
// [
|
|
873
|
-
|
|
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 (
|
|
879
|
-
if (!
|
|
880
|
-
n.children.push(
|
|
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 = {
|
|
889
|
-
|
|
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}
|