explorbot 0.1.13 → 0.1.16
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/package.json +3 -2
- package/dist/src/action.js +3 -2
- package/dist/src/ai/conversation.js +20 -4
- package/dist/src/ai/historian/utils.js +8 -1
- package/dist/src/ai/pilot.js +198 -260
- package/dist/src/ai/provider.js +25 -12
- package/dist/src/ai/quartermaster.js +2 -2
- package/dist/src/ai/researcher/focus.js +51 -10
- package/dist/src/ai/researcher/sections.js +8 -4
- package/dist/src/ai/researcher.js +9 -24
- package/dist/src/ai/rules.js +2 -0
- package/dist/src/ai/session-analyst.js +46 -41
- package/dist/src/ai/tester.js +63 -22
- package/dist/src/ai/tools.js +19 -4
- package/dist/src/commands/explore-command.js +8 -2
- package/dist/src/components/StatusPane.js +6 -1
- package/dist/src/experience-tracker.js +9 -0
- package/dist/src/explorer.js +2 -5
- package/dist/src/reporter.js +41 -1
- package/dist/src/stats.js +2 -1
- package/dist/src/test-plan.js +47 -3
- package/package.json +3 -2
- package/src/action.ts +3 -2
- package/src/ai/conversation.ts +21 -4
- package/src/ai/historian/utils.ts +8 -1
- package/src/ai/pilot.ts +199 -259
- package/src/ai/provider.ts +24 -12
- package/src/ai/quartermaster.ts +2 -2
- package/src/ai/researcher/focus.ts +57 -8
- package/src/ai/researcher/sections.ts +7 -3
- package/src/ai/researcher.ts +8 -23
- package/src/ai/rules.ts +2 -0
- package/src/ai/session-analyst.ts +47 -41
- package/src/ai/tester.ts +55 -20
- package/src/ai/tools.ts +18 -4
- package/src/commands/explore-command.ts +9 -2
- package/src/components/StatusPane.tsx +6 -3
- package/src/experience-tracker.ts +9 -0
- package/src/explorer.ts +1 -4
- package/src/reporter.ts +44 -1
- package/src/stats.ts +3 -1
- package/src/test-plan.ts +62 -3
package/src/ai/provider.ts
CHANGED
|
@@ -19,6 +19,15 @@ const responseLog = createDebug('explorbot:provider:in');
|
|
|
19
19
|
class AiError extends Error {}
|
|
20
20
|
export class ContextLengthError extends Error {}
|
|
21
21
|
|
|
22
|
+
function extractCachedTokens(usage: any): number {
|
|
23
|
+
if (!usage) return 0;
|
|
24
|
+
const direct = usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens;
|
|
25
|
+
if (typeof direct === 'number') return direct;
|
|
26
|
+
const raw = usage.raw;
|
|
27
|
+
const fromRaw = raw?.prompt_tokens_details?.cached_tokens ?? raw?.promptTokensDetails?.cachedTokens;
|
|
28
|
+
return typeof fromRaw === 'number' ? fromRaw : 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
function rejectAfterIdle(ms: number, signal: { cancelled: boolean }): Promise<never> {
|
|
23
32
|
return new Promise((_, reject) => {
|
|
24
33
|
const tick = () => {
|
|
@@ -265,9 +274,10 @@ export class Provider {
|
|
|
265
274
|
|
|
266
275
|
if (response.usage) {
|
|
267
276
|
Stats.recordTokens(options.agentName || 'unknown', modelName, {
|
|
268
|
-
input: response.usage.promptTokens
|
|
269
|
-
output: response.usage.completionTokens
|
|
270
|
-
total: response.usage.totalTokens
|
|
277
|
+
input: response.usage.inputTokens ?? response.usage.promptTokens ?? 0,
|
|
278
|
+
output: response.usage.outputTokens ?? response.usage.completionTokens ?? 0,
|
|
279
|
+
total: response.usage.totalTokens ?? 0,
|
|
280
|
+
cached: extractCachedTokens(response.usage),
|
|
271
281
|
});
|
|
272
282
|
}
|
|
273
283
|
|
|
@@ -355,9 +365,10 @@ export class Provider {
|
|
|
355
365
|
|
|
356
366
|
if (response.usage) {
|
|
357
367
|
Stats.recordTokens(options.agentName || 'unknown', modelName, {
|
|
358
|
-
input: response.usage.promptTokens
|
|
359
|
-
output: response.usage.completionTokens
|
|
360
|
-
total: response.usage.totalTokens
|
|
368
|
+
input: response.usage.inputTokens ?? response.usage.promptTokens ?? 0,
|
|
369
|
+
output: response.usage.outputTokens ?? response.usage.completionTokens ?? 0,
|
|
370
|
+
total: response.usage.totalTokens ?? 0,
|
|
371
|
+
cached: extractCachedTokens(response.usage),
|
|
361
372
|
});
|
|
362
373
|
}
|
|
363
374
|
|
|
@@ -428,9 +439,10 @@ export class Provider {
|
|
|
428
439
|
|
|
429
440
|
if (response.usage) {
|
|
430
441
|
Stats.recordTokens(options.agentName || 'unknown', modelName, {
|
|
431
|
-
input: response.usage.promptTokens
|
|
432
|
-
output: response.usage.completionTokens
|
|
433
|
-
total: response.usage.totalTokens
|
|
442
|
+
input: response.usage.inputTokens ?? response.usage.promptTokens ?? 0,
|
|
443
|
+
output: response.usage.outputTokens ?? response.usage.completionTokens ?? 0,
|
|
444
|
+
total: response.usage.totalTokens ?? 0,
|
|
445
|
+
cached: extractCachedTokens(response.usage),
|
|
434
446
|
});
|
|
435
447
|
}
|
|
436
448
|
|
|
@@ -625,9 +637,9 @@ export class Provider {
|
|
|
625
637
|
|
|
626
638
|
if (response.usage) {
|
|
627
639
|
Stats.recordTokens('vision', this.getModelName(this.config.visionModel), {
|
|
628
|
-
input: response.usage.promptTokens
|
|
629
|
-
output: response.usage.completionTokens
|
|
630
|
-
total: response.usage.totalTokens
|
|
640
|
+
input: response.usage.inputTokens ?? response.usage.promptTokens ?? 0,
|
|
641
|
+
output: response.usage.outputTokens ?? response.usage.completionTokens ?? 0,
|
|
642
|
+
total: response.usage.totalTokens ?? 0,
|
|
631
643
|
});
|
|
632
644
|
}
|
|
633
645
|
|
package/src/ai/quartermaster.ts
CHANGED
|
@@ -240,11 +240,11 @@ Focus on what would confuse a real user or caused the agent to make mistakes.`;
|
|
|
240
240
|
const criticalViolations = report.axeViolations.filter((v) => v.impact === 'critical' || v.impact === 'serious');
|
|
241
241
|
for (const v of criticalViolations.slice(0, 3)) {
|
|
242
242
|
const nodeHtml = v.nodes[0]?.html.slice(0, 100) || '';
|
|
243
|
-
task.
|
|
243
|
+
task.addVerificationDetail(`🔴 A11Y [${v.impact}] ${v.id}: ${v.description} — ${nodeHtml}`);
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
for (const issue of report.semanticIssues.slice(0, 3)) {
|
|
247
|
-
task.
|
|
247
|
+
task.addVerificationDetail(`💡 UX [${issue.type}] ${issue.element}: ${issue.suggestion}`);
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
250
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
2
|
import { mdq } from '../../utils/markdown-query.ts';
|
|
3
3
|
import type { ResearchSection } from './parser.ts';
|
|
4
4
|
import type { ResearchResult } from './research-result.ts';
|
|
@@ -10,16 +10,65 @@ export function hasFocusedSection(text: string): boolean {
|
|
|
10
10
|
return text.includes(FOCUSED_MARKER);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
interface FocusProbe {
|
|
14
|
+
name: string;
|
|
15
|
+
isDialog: boolean;
|
|
16
|
+
zIndex: number;
|
|
17
|
+
hasShadow: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function detectFocusedSection(page: Page, sections: ResearchSection[]): Promise<string | null> {
|
|
21
|
+
const candidates: FocusProbe[] = [];
|
|
22
|
+
|
|
23
|
+
for (const section of sections) {
|
|
24
|
+
if (!section.containerCss) continue;
|
|
25
|
+
const key = section.name.toLowerCase().replace(/^section:\s*/, '');
|
|
26
|
+
if (FOCUS_SKIP_SECTIONS.has(key)) continue;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const locator = page.locator(section.containerCss).first();
|
|
30
|
+
if (!(await locator.isVisible())) continue;
|
|
31
|
+
|
|
32
|
+
const probe = await locator.evaluate((el) => {
|
|
33
|
+
const dialogSelector = '[role="dialog"], [role="alertdialog"], [aria-modal="true"]';
|
|
34
|
+
const isDialog = el.matches(dialogSelector) || !!el.querySelector(dialogSelector);
|
|
35
|
+
|
|
36
|
+
let cur: Element | null = el;
|
|
37
|
+
let maxZ = 0;
|
|
38
|
+
while (cur && cur !== document.body) {
|
|
39
|
+
const cs = window.getComputedStyle(cur);
|
|
40
|
+
if (cs.position !== 'static') {
|
|
41
|
+
const z = Number.parseInt(cs.zIndex, 10);
|
|
42
|
+
if (!Number.isNaN(z) && z > maxZ) maxZ = z;
|
|
43
|
+
}
|
|
44
|
+
cur = cur.parentElement;
|
|
45
|
+
}
|
|
16
46
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
47
|
+
const shadow = window.getComputedStyle(el).boxShadow;
|
|
48
|
+
const hasShadow = !!shadow && shadow !== 'none';
|
|
49
|
+
|
|
50
|
+
return { isDialog, zIndex: maxZ, hasShadow };
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
candidates.push({ name: section.name, ...probe });
|
|
54
|
+
} catch {}
|
|
20
55
|
}
|
|
21
56
|
|
|
22
|
-
return null;
|
|
57
|
+
if (candidates.length === 0) return null;
|
|
58
|
+
|
|
59
|
+
const dialogs = candidates.filter((c) => c.isDialog);
|
|
60
|
+
const pool = dialogs.length > 0 ? dialogs : candidates;
|
|
61
|
+
|
|
62
|
+
const winner = pool.reduce<FocusProbe | null>((best, c) => {
|
|
63
|
+
if (!best) return c;
|
|
64
|
+
if (c.zIndex !== best.zIndex) return c.zIndex > best.zIndex ? c : best;
|
|
65
|
+
if (c.hasShadow !== best.hasShadow) return c.hasShadow ? c : best;
|
|
66
|
+
return best;
|
|
67
|
+
}, null);
|
|
68
|
+
|
|
69
|
+
if (!winner) return null;
|
|
70
|
+
if (dialogs.length === 0 && winner.zIndex === 0 && !winner.hasShadow) return null;
|
|
71
|
+
return winner.name;
|
|
23
72
|
}
|
|
24
73
|
|
|
25
74
|
export function markSectionAsFocused(result: ResearchResult, sectionName: string): void {
|
|
@@ -7,7 +7,9 @@ import { tag } from '../../utils/logger.js';
|
|
|
7
7
|
import { RulesLoader } from '../../utils/rules-loader.ts';
|
|
8
8
|
import type { Provider } from '../provider.js';
|
|
9
9
|
import { locatorRule as generalLocatorRuleText } from '../rules.js';
|
|
10
|
+
import { markSectionAsFocused } from './focus.ts';
|
|
10
11
|
import type { Constructor } from './mixin.ts';
|
|
12
|
+
import { ResearchResult } from './research-result.ts';
|
|
11
13
|
|
|
12
14
|
export interface SectionMethods {
|
|
13
15
|
researchBySections(): Promise<string>;
|
|
@@ -54,9 +56,11 @@ export function WithSections<T extends Constructor>(Base: T) {
|
|
|
54
56
|
throw new Error('Per-section research produced no sections — AI responses all empty or NOT_PRESENT');
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
if (focusCss) merged
|
|
59
|
-
|
|
59
|
+
const merged = parts.join('\n\n');
|
|
60
|
+
if (!focusCss) return merged;
|
|
61
|
+
const focused = new ResearchResult(merged, this.actionResult?.url || '');
|
|
62
|
+
markSectionAsFocused(focused, 'Focus');
|
|
63
|
+
return focused.text;
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
private async _detectFocusCss(): Promise<string | null> {
|
package/src/ai/researcher.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { ContextLengthError, type Provider } from './provider.js';
|
|
|
24
24
|
import { findSimilarResearch, getCachedResearch, saveResearch } from './researcher/cache.ts';
|
|
25
25
|
import { type CoordinateMethods, WithCoordinates } from './researcher/coordinates.ts';
|
|
26
26
|
import { type DeepAnalysisMethods, WithDeepAnalysis } from './researcher/deep-analysis.ts';
|
|
27
|
-
import {
|
|
27
|
+
import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from './researcher/focus.ts';
|
|
28
28
|
import { type LocatorMethods, WithLocators } from './researcher/locators.ts';
|
|
29
29
|
import { extractValidContainers, formatResearchSummary, parseResearchSections } from './researcher/parser.ts';
|
|
30
30
|
import { ResearchResult } from './researcher/research-result.ts';
|
|
@@ -234,17 +234,12 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
234
234
|
await this.fixBrokenSections(result, activeConversation);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
// Focused section:
|
|
238
|
-
|
|
239
|
-
if (
|
|
240
|
-
result.text = result.text.replace(focusMatch[0], '');
|
|
241
|
-
markSectionAsFocused(result, focusMatch[1].trim());
|
|
242
|
-
}
|
|
243
|
-
if (!hasFocusedSection(result.text)) {
|
|
237
|
+
// Focused section: unified Playwright probe (HTML+CSS+visibility).
|
|
238
|
+
// Must run BEFORE visuallyAnnotateContainers — annotation overlays inject z-index 99998+ which would pollute the scoring.
|
|
239
|
+
if (!interrupted() && this.hasScreenshotToAnalyze) {
|
|
244
240
|
const sections = parseResearchSections(result.text);
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
if (focusedName) markSectionAsFocused(result, focusedName);
|
|
241
|
+
const focused = await detectFocusedSection(this.explorer.playwrightHelper.page, sections);
|
|
242
|
+
if (focused) markSectionAsFocused(result, focused);
|
|
248
243
|
}
|
|
249
244
|
|
|
250
245
|
// Stage 4: Visual analysis
|
|
@@ -281,8 +276,8 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
281
276
|
await this.backfillBrokenLocators(result);
|
|
282
277
|
}
|
|
283
278
|
|
|
284
|
-
// Focused section: final fallback
|
|
285
|
-
if (!hasFocusedSection(result.text)) {
|
|
279
|
+
// Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
|
|
280
|
+
if (this.hasScreenshotToAnalyze && !hasFocusedSection(result.text)) {
|
|
286
281
|
const sections = parseResearchSections(result.text);
|
|
287
282
|
const fallback = pickDefaultFocusedSection(sections);
|
|
288
283
|
if (fallback) markSectionAsFocused(result, fallback);
|
|
@@ -451,16 +446,6 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
451
446
|
|
|
452
447
|
| Element | ARIA | CSS | eidx |
|
|
453
448
|
</section_format>
|
|
454
|
-
|
|
455
|
-
<focused_section>
|
|
456
|
-
At the end of your output, declare the primary focus area on a single line:
|
|
457
|
-
|
|
458
|
-
> Focused: <exact section name>
|
|
459
|
-
|
|
460
|
-
- If a dialog/modal/drawer/overlay exists, it is focused.
|
|
461
|
-
- Otherwise pick the section where the main business action happens (list for catalog, detail for item page, content for article).
|
|
462
|
-
- Navigation and menu/toolbar are never focused.
|
|
463
|
-
</focused_section>
|
|
464
449
|
`;
|
|
465
450
|
}
|
|
466
451
|
|
package/src/ai/rules.ts
CHANGED
|
@@ -241,6 +241,8 @@ export function multipleTabsRule(tabs: Array<{ url: string; title: string }>): s
|
|
|
241
241
|
|
|
242
242
|
export const actionRule = dedent`
|
|
243
243
|
<actions>
|
|
244
|
+
\`faker\` (from @faker-js/faker) is available inside I.* calls for generating data, e.g. I.fillField('Bio', faker.lorem.paragraphs(5)).
|
|
245
|
+
|
|
244
246
|
### I.click
|
|
245
247
|
|
|
246
248
|
clicks on the element by its locator
|
|
@@ -19,69 +19,71 @@ export class SessionAnalyst implements Agent {
|
|
|
19
19
|
const eligible = tests.filter((t) => t.startTime != null);
|
|
20
20
|
if (eligible.length === 0) return '';
|
|
21
21
|
|
|
22
|
-
const model = this.provider.
|
|
22
|
+
const model = this.provider.getAgenticModel('analyst');
|
|
23
23
|
const customPrompt = this.provider.getSystemPromptForAgent('analyst', undefined);
|
|
24
24
|
|
|
25
25
|
const systemPrompt = dedent`
|
|
26
|
-
You write a
|
|
26
|
+
You write a TERSE end-of-session report. Reader is a developer who wants to UNDERSTAND THE FEATURE — what works, what is broken, what is unclear. Every word must earn its place.
|
|
27
27
|
|
|
28
|
-
Output MARKDOWN. No JSON, no preamble, no closing
|
|
28
|
+
Output MARKDOWN. No JSON, no preamble, no closing summary.
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
Group by ROOT CAUSE, not by scenario. If three tests fail for the same dropdown, that is ONE defect listing all three test refs (#3, #5, #7). Do not produce one cluster per test.
|
|
30
|
+
NO EMOJI. No 🔴 🟡 🟢 ✅, no escape sequences like \\u2705. Use plain text severity tags: [High], [Medium], [Low] for defects.
|
|
32
31
|
|
|
33
|
-
##
|
|
34
|
-
Use the FINAL verdict (the test's \`result\` field) as the starting point. Mid-test errors that the automation recovered from do NOT make a passed test unreliable.
|
|
32
|
+
## Reporting unit
|
|
35
33
|
|
|
36
|
-
|
|
37
|
-
- **UX issue** — app works but the UI is ambiguous, controls are hidden, or labels are unclear. Worth flagging to design.
|
|
38
|
-
- **Execution issue** — the FINAL verdict is unreliable. Only two cases:
|
|
39
|
-
1. \`result: failed\` AND the failure was automation, environment, or UI/UX (locator missing, timeout, AI loop, navigation stuck, modal trapped focus, no accessible label) — i.e. the test could not conclude whether the app works.
|
|
40
|
-
2. \`result: passed\` AND clear evidence in the log shows the user-visible goal was NOT achieved (no confirmation visible, no state change verified, the assertion was vacuous).
|
|
34
|
+
Report at the level of FEATURES / FLOWS / PAGES. Tests are evidence, not the unit. Several tests covering the same flow → ONE entry citing all of them.
|
|
41
35
|
|
|
42
|
-
|
|
36
|
+
## Walk every test
|
|
43
37
|
|
|
44
|
-
|
|
45
|
-
- 🔴 critical or high — core flow blocked, data loss, security
|
|
46
|
-
- 🟡 medium — partial breakage with workaround
|
|
47
|
-
- 🟢 low — cosmetic
|
|
38
|
+
PASSED test: did all steps run, was the goal actually verified, did the user-visible goal happen? All yes → contributes to What works. Any no → Execution issue (false positive).
|
|
48
39
|
|
|
49
|
-
|
|
40
|
+
FAILED test, first match wins: (1) goal achieved but mis-verified → Execution. (2) automation failure (locator/timeout/loop/modal/a11y) → Execution. (3) bad preconditions or data → Execution. (4) wrong URL/environment → Execution. (5) app contradicted expected outcome → Defect.
|
|
41
|
+
|
|
42
|
+
Crucial distinction: "the app misbehaved" vs "the automation could not interact with the app". ONLY the first is a Defect. If the automation gives up before the app responds — timeout, retries exhausted, dead loop / loop detected, could not click or find an element — that is an Execution issue regardless of what the log calls it. Failure inside the automation ≠ failure inside the product.
|
|
43
|
+
|
|
44
|
+
A solitary failure where adjacent tests on the same feature passed → Execution, not Defect.
|
|
45
|
+
|
|
46
|
+
## Severity (defects only)
|
|
47
|
+
[High] blocks a core flow · [Medium] degrades a flow but workaround exists · [Low] cosmetic / edge case
|
|
48
|
+
|
|
49
|
+
## Format
|
|
50
50
|
|
|
51
51
|
# Session Analysis
|
|
52
52
|
|
|
53
|
-
<
|
|
53
|
+
<ONE or TWO sentences describing the FEATURE STATE — what was explored, whether the core flow holds, what the standout problem is. NO test counts, NO "N tests run". Talk about the product, not the run.>
|
|
54
|
+
|
|
55
|
+
## Coverage
|
|
56
|
+
- Pages: <paths>
|
|
57
|
+
- Features: <capabilities>
|
|
58
|
+
|
|
59
|
+
## What works
|
|
60
|
+
- **<feature>** — #2, #7, #8
|
|
54
61
|
|
|
55
62
|
## Defects
|
|
56
63
|
|
|
57
|
-
###
|
|
58
|
-
Affects: #3, #5
|
|
64
|
+
### [Medium] <plain-English bug title>
|
|
65
|
+
Affects: #3, #5
|
|
59
66
|
Reproduce:
|
|
60
|
-
1. <concrete UI step
|
|
61
|
-
2. <next
|
|
62
|
-
Evidence: <one short observation
|
|
63
|
-
|
|
64
|
-
### 🟡 <next defect>
|
|
65
|
-
...
|
|
67
|
+
1. <concrete UI step>
|
|
68
|
+
2. <next>
|
|
69
|
+
Evidence: <one short observation>
|
|
66
70
|
|
|
67
71
|
## UX issues
|
|
68
|
-
|
|
69
|
-
- **<title>** — #4
|
|
70
|
-
<one short evidence line>
|
|
72
|
+
- **<feature>** — <what's confusing> (#7)
|
|
71
73
|
|
|
72
74
|
## Execution Issues
|
|
75
|
+
- **#2 <scenario>** — <≤10 words, what was unreliable>
|
|
73
76
|
|
|
74
|
-
|
|
75
|
-
- **<…>** — <…>
|
|
77
|
+
## Brevity rules
|
|
76
78
|
|
|
77
|
-
|
|
78
|
-
-
|
|
79
|
-
- Defect title
|
|
80
|
-
- Reproduce steps are
|
|
81
|
-
- Evidence is
|
|
82
|
-
-
|
|
83
|
-
-
|
|
84
|
-
-
|
|
79
|
+
- Headline: 2 sentences MAX. About the FEATURE, not the run. No counts, no "N tests", no "this session". Banned words: "exercised", "comprehensive", "notably", "this session", "module", "targeted", "covered creation".
|
|
80
|
+
- What works: feature name + test refs. NO parentheticals, NO caveats. If there's a caveat, the entry doesn't belong here.
|
|
81
|
+
- Defect title is the BUG ("Search returns non-matching results"), never the scenario name.
|
|
82
|
+
- Reproduce steps are imperative one-liners drawn from the log.
|
|
83
|
+
- Evidence is one short factual observation. Never quote the \`result\` field.
|
|
84
|
+
- Execution Issues: ONE line per test, ≤10 words, plain. Examples: "passed vacuously, no list assertion", "no file upload step in log", "dead loop on Save click". No prefixes, no nested explanation.
|
|
85
|
+
- Omit any empty section.
|
|
86
|
+
- Section order: Coverage → What works → Defects (severity desc) → UX issues → Execution Issues.
|
|
85
87
|
|
|
86
88
|
${customPrompt || ''}
|
|
87
89
|
`;
|
|
@@ -101,7 +103,7 @@ export class SessionAnalyst implements Agent {
|
|
|
101
103
|
{ agentName: 'analyst' }
|
|
102
104
|
);
|
|
103
105
|
|
|
104
|
-
return (response?.text || '').trim();
|
|
106
|
+
return decodeEscapes((response?.text || '').trim());
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
writeReport(markdown: string): string {
|
|
@@ -131,3 +133,7 @@ export class SessionAnalyst implements Agent {
|
|
|
131
133
|
`;
|
|
132
134
|
}
|
|
133
135
|
}
|
|
136
|
+
|
|
137
|
+
function decodeEscapes(text: string): string {
|
|
138
|
+
return text.replace(/\\u\{([0-9a-fA-F]+)\}/g, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16))).replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)));
|
|
139
|
+
}
|
package/src/ai/tester.ts
CHANGED
|
@@ -64,6 +64,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
64
64
|
private pageStateHash: string | null = null;
|
|
65
65
|
private pageActionResult: ActionResult | null = null;
|
|
66
66
|
private hooksRunner: HooksRunner;
|
|
67
|
+
private seenUiMapUrls = new Set<string>();
|
|
68
|
+
private lastAnalyzedStateHash: string | null = null;
|
|
67
69
|
|
|
68
70
|
constructor(explorer: Explorer, provider: Provider, researcher: Researcher, navigator: Navigator, agentTools?: any) {
|
|
69
71
|
super();
|
|
@@ -104,7 +106,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
private get progressCheckInterval(): number {
|
|
107
|
-
return (this.explorer.getConfig().ai?.agents?.tester as any)?.progressCheckInterval ??
|
|
109
|
+
return (this.explorer.getConfig().ai?.agents?.tester as any)?.progressCheckInterval ?? 3;
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
getConversation(): Conversation | null {
|
|
@@ -123,6 +125,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
123
125
|
this.previousStateHash = null;
|
|
124
126
|
this.pageStateHash = null;
|
|
125
127
|
this.pageActionResult = null;
|
|
128
|
+
this.seenUiMapUrls.clear();
|
|
129
|
+
this.lastAnalyzedStateHash = null;
|
|
126
130
|
this.explorer.getStateManager().clearHistory();
|
|
127
131
|
this.resetFailureCount();
|
|
128
132
|
this.pilot?.reset();
|
|
@@ -147,14 +151,20 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
147
151
|
const initialState = ActionResult.fromState(state);
|
|
148
152
|
|
|
149
153
|
const conversation = this.provider.startConversation(this.getSystemMessage(), 'tester');
|
|
154
|
+
conversation.markLastMessageCacheable();
|
|
150
155
|
this.currentConversation = conversation;
|
|
151
156
|
|
|
152
157
|
const outputDir = ConfigParser.getInstance().getOutputDir();
|
|
153
158
|
this.executionLogFile = join(outputDir, `tester_${task.sessionName}.md`);
|
|
154
159
|
// Note: Markdown saving functionality removed from Conversation class
|
|
155
160
|
|
|
156
|
-
const
|
|
157
|
-
conversation.addUserText(
|
|
161
|
+
const scenarioBlock = this.buildScenarioBlock(task, initialState);
|
|
162
|
+
conversation.addUserText(scenarioBlock);
|
|
163
|
+
conversation.markLastMessageCacheable();
|
|
164
|
+
conversation.protectPrefix(conversation.messages.length);
|
|
165
|
+
|
|
166
|
+
const pageContext = await this.reinjectContextIfNeeded(1, initialState);
|
|
167
|
+
if (pageContext) conversation.addUserText(pageContext);
|
|
158
168
|
|
|
159
169
|
return await Observability.run(
|
|
160
170
|
`test: ${task.scenario}`,
|
|
@@ -177,6 +187,12 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
177
187
|
if (this.pilot) {
|
|
178
188
|
try {
|
|
179
189
|
const plan = await this.pilot.planTest(task, initialState);
|
|
190
|
+
if (task.hasFinished) {
|
|
191
|
+
offFailedRequest?.();
|
|
192
|
+
page?.off('pageerror', onPageError);
|
|
193
|
+
page?.off('console', onConsoleMessage);
|
|
194
|
+
return { success: task.isSuccessful };
|
|
195
|
+
}
|
|
180
196
|
if (plan) {
|
|
181
197
|
conversation.addUserText(`Pilot's test plan:\n${plan}\n\nFollow this plan while executing the test.`);
|
|
182
198
|
}
|
|
@@ -200,13 +216,15 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
200
216
|
debugLog(`Navigating to ${task.startUrl}`);
|
|
201
217
|
await this.explorer.visit(task.startUrl!);
|
|
202
218
|
|
|
203
|
-
const
|
|
219
|
+
const startState = this.explorer.getStateManager().getCurrentState();
|
|
220
|
+
if (startState) task.addUrlNote(startState);
|
|
221
|
+
const currentUrl = startState?.url || task.startUrl || '';
|
|
204
222
|
await this.hooksRunner.runBeforeHook('tester', currentUrl);
|
|
205
223
|
|
|
206
224
|
const offStateChange = this.explorer.getStateManager().onStateChange((event: StateTransition) => {
|
|
207
225
|
if (task.hasFinished) return;
|
|
208
226
|
if (event.toState?.url === event.fromState?.url) return;
|
|
209
|
-
task.
|
|
227
|
+
if (event.toState) task.addUrlNote(event.toState, event.fromState || undefined);
|
|
210
228
|
task.states.push(event.toState);
|
|
211
229
|
});
|
|
212
230
|
|
|
@@ -253,13 +271,13 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
253
271
|
`);
|
|
254
272
|
}
|
|
255
273
|
|
|
256
|
-
conversation.cleanupTag('page_aria', '...cleaned aria snapshot...',
|
|
274
|
+
conversation.cleanupTag('page_aria', '...cleaned aria snapshot...', 1);
|
|
257
275
|
conversation.cleanupTag('page_html', '...cleaned HTML snapshot...', 1);
|
|
258
276
|
conversation.cleanupTag('experience', '...cleaned experience...', 1);
|
|
259
277
|
conversation.cleanupTag('applied_experience', '...cleaned past experience...', 1);
|
|
260
278
|
conversation.cleanupTag('page_ui_map', '...cleaned UI map...', 1);
|
|
261
279
|
conversation.cleanupTag('page_ui_map_overlay', '...cleaned UI overlay...', 1);
|
|
262
|
-
conversation.compactToolResults(
|
|
280
|
+
conversation.compactToolResults(2);
|
|
263
281
|
|
|
264
282
|
if (iteration > 1) {
|
|
265
283
|
const isNewPage = this.previousUrl !== null && this.previousUrl !== currentState.url;
|
|
@@ -270,16 +288,17 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
270
288
|
if (isNewPage && this.pilot) {
|
|
271
289
|
const guidance = await this.pilot.reviewNewPage(task, currentState, conversation);
|
|
272
290
|
if (guidance) nextStep += `\n\n${guidance}`;
|
|
273
|
-
} else if ((iteration
|
|
291
|
+
} else if (this.shouldAnalyzeProgress(iteration, currentState) && this.pilot) {
|
|
274
292
|
const guidance = await this.pilot.analyzeProgress(task, currentState, conversation);
|
|
275
293
|
if (guidance) nextStep += `\n\n${guidance}`;
|
|
276
294
|
this.consecutiveFailures = 0;
|
|
295
|
+
this.lastAnalyzedStateHash = currentState.hash;
|
|
277
296
|
}
|
|
278
297
|
conversation.addUserText(nextStep);
|
|
279
298
|
}
|
|
280
299
|
|
|
281
300
|
const result = await this.provider.invokeConversation(conversation, tools, {
|
|
282
|
-
maxToolRoundtrips:
|
|
301
|
+
maxToolRoundtrips: 3,
|
|
283
302
|
toolChoice: 'required',
|
|
284
303
|
stopWhen: () => task.hasFinished,
|
|
285
304
|
});
|
|
@@ -368,10 +387,15 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
368
387
|
: undefined,
|
|
369
388
|
catch: async ({ error, stop }) => {
|
|
370
389
|
tag('error').log(`Test execution error: ${error}`);
|
|
390
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
371
391
|
if (!task.hasFinished) {
|
|
372
|
-
task.addNote(`Execution error: ${
|
|
392
|
+
task.addNote(`Execution error: ${message}`);
|
|
373
393
|
}
|
|
374
|
-
|
|
394
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
395
|
+
stop();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
conversation.addUserText(`Previous AI call failed: ${message}. Take a different approach on the next step.`);
|
|
375
399
|
},
|
|
376
400
|
}
|
|
377
401
|
);
|
|
@@ -421,6 +445,14 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
421
445
|
};
|
|
422
446
|
}
|
|
423
447
|
|
|
448
|
+
private shouldAnalyzeProgress(iteration: number, currentState: ActionResult): boolean {
|
|
449
|
+
if (this.consecutiveFailures >= 3) return true;
|
|
450
|
+
if (this.consecutiveEmptyResults >= 2) return true;
|
|
451
|
+
if (iteration % this.progressCheckInterval !== 0) return false;
|
|
452
|
+
if (this.lastAnalyzedStateHash === currentState.hash) return false;
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
|
|
424
456
|
private async prepareInstructionsForNextStep(task: Test): Promise<string> {
|
|
425
457
|
let outcomeStatus = dedent`
|
|
426
458
|
<task>
|
|
@@ -511,17 +543,21 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
511
543
|
}
|
|
512
544
|
|
|
513
545
|
if (isNewUrl) {
|
|
546
|
+
const alreadySeenUiMap = this.seenUiMapUrls.has(currentUrl);
|
|
514
547
|
let research = '';
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
548
|
+
if (!alreadySeenUiMap) {
|
|
549
|
+
try {
|
|
550
|
+
research = await this.researcher.research(currentState);
|
|
551
|
+
} catch (err) {
|
|
552
|
+
if (!(err instanceof ErrorPageError)) throw err;
|
|
553
|
+
tag('warning').log(`Research skipped: ${err.message}`);
|
|
554
|
+
}
|
|
520
555
|
}
|
|
521
556
|
this.pageStateHash = currentStateHash;
|
|
522
557
|
this.pageActionResult = currentState;
|
|
523
558
|
let uiMapSection = '';
|
|
524
559
|
if (research) {
|
|
560
|
+
this.seenUiMapUrls.add(currentUrl);
|
|
525
561
|
uiMapSection = dedent`
|
|
526
562
|
|
|
527
563
|
Page UI Map
|
|
@@ -530,6 +566,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
530
566
|
${research}
|
|
531
567
|
</page_ui_map>
|
|
532
568
|
`;
|
|
569
|
+
} else if (alreadySeenUiMap) {
|
|
570
|
+
uiMapSection = `\n\n<page_ui_map>UI map for ${currentUrl} was shown earlier in this session — refer to it above.</page_ui_map>`;
|
|
533
571
|
}
|
|
534
572
|
|
|
535
573
|
context += dedent`
|
|
@@ -740,9 +778,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
740
778
|
`;
|
|
741
779
|
}
|
|
742
780
|
|
|
743
|
-
private
|
|
781
|
+
private buildScenarioBlock(task: Test, actionResult: ActionResult): string {
|
|
744
782
|
const knowledge = this.getKnowledge(actionResult);
|
|
745
|
-
const pageContext = await this.reinjectContextIfNeeded(1, actionResult);
|
|
746
783
|
|
|
747
784
|
return dedent`
|
|
748
785
|
<task>
|
|
@@ -770,8 +807,6 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
770
807
|
${this.buildAvailableFiles()}
|
|
771
808
|
|
|
772
809
|
${knowledge}
|
|
773
|
-
|
|
774
|
-
${pageContext}
|
|
775
810
|
`;
|
|
776
811
|
}
|
|
777
812
|
|
package/src/ai/tools.ts
CHANGED
|
@@ -510,7 +510,7 @@ export function createAgentTools({
|
|
|
510
510
|
}
|
|
511
511
|
|
|
512
512
|
return successToolResult('see', {
|
|
513
|
-
analysis: analysisResult,
|
|
513
|
+
analysis: cap(analysisResult, ANALYSIS_OUTPUT_CAP),
|
|
514
514
|
message: `Successfully analyzed screenshot for: ${request}`,
|
|
515
515
|
suggestion: 'Visual confirmation is valid evidence for test results. Use record() to note the visual findings.',
|
|
516
516
|
});
|
|
@@ -559,8 +559,8 @@ export function createAgentTools({
|
|
|
559
559
|
url: currentState.url,
|
|
560
560
|
title: currentState.title,
|
|
561
561
|
suggestion: 'If not enough context received, call see() to visually identify elements in page contents',
|
|
562
|
-
aria,
|
|
563
|
-
html,
|
|
562
|
+
aria: cap(aria, ARIA_OUTPUT_CAP),
|
|
563
|
+
html: cap(html, HTML_OUTPUT_CAP),
|
|
564
564
|
reminder: 'Context provided. Do not call context() again until you perform actions or suspect page changed.',
|
|
565
565
|
});
|
|
566
566
|
} catch (error) {
|
|
@@ -657,7 +657,7 @@ export function createAgentTools({
|
|
|
657
657
|
|
|
658
658
|
return successToolResult('research', {
|
|
659
659
|
analysis: researchResult,
|
|
660
|
-
aria: ActionResult.fromState(currentState).getInteractiveARIA(),
|
|
660
|
+
aria: cap(ActionResult.fromState(currentState).getInteractiveARIA(), ARIA_OUTPUT_CAP),
|
|
661
661
|
message: `Successfully researched page: ${currentState.url}.`,
|
|
662
662
|
suggestion: dedent`
|
|
663
663
|
You received comprehensive UI map report. Use it to understand the page structure and navigate to the elements.
|
|
@@ -1001,6 +1001,16 @@ export function createAgentTools({
|
|
|
1001
1001
|
|
|
1002
1002
|
const PAGE_DIFF_SUGGESTION = 'Analyze page diff. htmlParts shows what changed and WHERE — each part has a container selector. Use the container as context when clicking elements from the diff.';
|
|
1003
1003
|
|
|
1004
|
+
const ARIA_OUTPUT_CAP = 4000;
|
|
1005
|
+
const HTML_OUTPUT_CAP = 6000;
|
|
1006
|
+
const ANALYSIS_OUTPUT_CAP = 2000;
|
|
1007
|
+
|
|
1008
|
+
function cap(text: string | undefined | null, max: number): string {
|
|
1009
|
+
if (!text) return '';
|
|
1010
|
+
if (text.length <= max) return text;
|
|
1011
|
+
return `${text.slice(0, max)}\n[...truncated; ${text.length - max} chars omitted...]`;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1004
1014
|
function transformContainsCommand(command: string): string {
|
|
1005
1015
|
if (!command.includes(':contains(')) return command;
|
|
1006
1016
|
|
|
@@ -1044,8 +1054,12 @@ function successToolResult(action: string, data?: Record<string, any>, source?:
|
|
|
1044
1054
|
if (data?.pageDiff) {
|
|
1045
1055
|
let suggestion = PAGE_DIFF_SUGGESTION;
|
|
1046
1056
|
const ariaChanges = data.pageDiff.ariaChanges || '';
|
|
1057
|
+
const urlChanged = data.pageDiff.urlChanged === true;
|
|
1058
|
+
const hasHtmlParts = Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
|
|
1047
1059
|
if (countAriaChanges(ariaChanges) >= 50) {
|
|
1048
1060
|
suggestion = `MAJOR PAGE CHANGE. Page entered a different mode. Check htmlParts and iframes in pageDiff before next action. ${suggestion}`;
|
|
1061
|
+
} else if (!urlChanged && !ariaChanges && !hasHtmlParts) {
|
|
1062
|
+
suggestion = 'Action ran without error but produced no observable change (URL, ARIA and HTML all unchanged). The locator likely matched a non-interactive ancestor or an element outside the intended control. Re-locate via xpathCheck() or verify with see() before treating this as success.';
|
|
1049
1063
|
} else if (ariaChanges.includes('heading') && ariaChanges.includes('added')) {
|
|
1050
1064
|
suggestion += ' WARNING: A new panel or modal may have appeared. If this was not the intended action, close it and try a different element.';
|
|
1051
1065
|
}
|