explorbot 0.1.12 → 0.1.15
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/bin/explorbot-cli.ts +21 -21
- package/dist/bin/explorbot-cli.js +3 -3
- package/dist/package.json +4 -2
- package/dist/rules/researcher/container-rules.md +2 -0
- package/dist/src/action-result.js +2 -1
- package/dist/src/action.js +3 -8
- package/dist/src/ai/captain.js +0 -2
- package/dist/src/ai/conversation.js +20 -4
- package/dist/src/ai/driller.js +1108 -0
- package/dist/src/ai/historian/utils.js +8 -1
- package/dist/src/ai/pilot.js +214 -267
- package/dist/src/ai/provider.js +25 -12
- package/dist/src/ai/quartermaster.js +2 -2
- package/dist/src/ai/rules.js +5 -5
- package/dist/src/ai/session-analyst.js +122 -0
- package/dist/src/ai/tester.js +69 -22
- package/dist/src/ai/tools.js +19 -4
- package/dist/src/commands/base-command.js +6 -6
- package/dist/src/commands/drill-command.js +3 -2
- package/dist/src/commands/exit-command.js +1 -0
- package/dist/src/commands/explore-command.js +9 -2
- package/dist/src/components/AddRule.js +1 -1
- package/dist/src/components/StatusPane.js +6 -1
- package/dist/src/experience-tracker.js +9 -0
- package/dist/src/explorbot.js +48 -8
- package/dist/src/explorer.js +11 -13
- package/dist/src/reporter.js +105 -4
- package/dist/src/state-manager.js +4 -3
- package/dist/src/stats.js +7 -1
- package/dist/src/test-plan.js +47 -3
- package/dist/src/utils/aria.js +354 -529
- package/dist/src/utils/hooks-runner.js +2 -8
- package/dist/src/utils/html.js +371 -0
- package/dist/src/utils/unique-names.js +12 -1
- package/dist/src/utils/url-matcher.js +6 -1
- package/dist/src/utils/web-element.js +27 -24
- package/dist/src/utils/xpath.js +1 -1
- package/package.json +4 -2
- package/rules/researcher/container-rules.md +2 -0
- package/src/action-result.ts +2 -1
- package/src/action.ts +3 -10
- package/src/ai/captain.ts +0 -2
- package/src/ai/conversation.ts +21 -4
- package/src/ai/driller.ts +1194 -0
- package/src/ai/historian/utils.ts +8 -1
- package/src/ai/pilot.ts +215 -265
- package/src/ai/provider.ts +24 -12
- package/src/ai/quartermaster.ts +2 -2
- package/src/ai/rules.ts +5 -5
- package/src/ai/session-analyst.ts +139 -0
- package/src/ai/tester.ts +63 -20
- package/src/ai/tools.ts +18 -4
- package/src/commands/base-command.ts +6 -6
- package/src/commands/drill-command.ts +3 -2
- package/src/commands/exit-command.ts +1 -0
- package/src/commands/explore-command.ts +10 -2
- package/src/components/AddRule.tsx +1 -1
- package/src/components/StatusPane.tsx +6 -3
- package/src/config.ts +4 -0
- package/src/experience-tracker.ts +9 -0
- package/src/explorbot.ts +55 -10
- package/src/explorer.ts +10 -12
- package/src/reporter.ts +108 -4
- package/src/state-manager.ts +4 -3
- package/src/stats.ts +10 -1
- package/src/test-plan.ts +62 -3
- package/src/utils/aria.ts +367 -537
- package/src/utils/hooks-runner.ts +2 -6
- package/src/utils/html.ts +381 -0
- package/src/utils/unique-names.ts +13 -0
- package/src/utils/url-matcher.ts +5 -1
- package/src/utils/web-element.ts +31 -28
- package/src/utils/xpath.ts +1 -1
- package/dist/src/ai/bosun.js +0 -456
- package/src/ai/bosun.ts +0 -571
|
@@ -32,5 +32,10 @@ export const StatusPane = ({ onComplete }) => {
|
|
|
32
32
|
React.createElement(Box, { marginTop: 1, marginBottom: 1 },
|
|
33
33
|
React.createElement(Text, { bold: true }, "Usage")),
|
|
34
34
|
React.createElement(Row, { label: "Time", value: Stats.getElapsedTime() }),
|
|
35
|
-
tokenRows.map(([model, tokens]) =>
|
|
35
|
+
tokenRows.map(([model, tokens]) => {
|
|
36
|
+
const cached = tokens.cached ?? 0;
|
|
37
|
+
const cachePct = tokens.input > 0 ? Math.round((cached / tokens.input) * 100) : 0;
|
|
38
|
+
const suffix = cached > 0 ? ` (${Stats.humanizeTokens(cached)} cached, ${cachePct}%)` : '';
|
|
39
|
+
return React.createElement(Row, { key: model, label: model, value: `${Stats.humanizeTokens(tokens.total)} tokens${suffix}` });
|
|
40
|
+
})))));
|
|
36
41
|
};
|
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSy
|
|
|
2
2
|
import { basename, dirname, join } from 'node:path';
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
4
|
import { marked } from 'marked';
|
|
5
|
+
import { isNonReusableCode } from "./ai/historian/utils.js";
|
|
5
6
|
import { ConfigParser } from './config.js';
|
|
6
7
|
import { KnowledgeTracker } from './knowledge-tracker.js';
|
|
7
8
|
import { createDebug, tag } from './utils/logger.js';
|
|
@@ -145,6 +146,10 @@ export class ExperienceTracker {
|
|
|
145
146
|
return;
|
|
146
147
|
if (!action.code?.trim())
|
|
147
148
|
return;
|
|
149
|
+
if (isNonReusableCode(action.code)) {
|
|
150
|
+
debugLog('Skipping action with non-reusable code: %s', action.code);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
148
153
|
this.ensureExperienceFile(state);
|
|
149
154
|
const stateHash = state.getStateHash();
|
|
150
155
|
const { content, data } = this.readExperienceFile(stateHash);
|
|
@@ -166,6 +171,10 @@ export class ExperienceTracker {
|
|
|
166
171
|
return;
|
|
167
172
|
if (!body?.trim())
|
|
168
173
|
return;
|
|
174
|
+
if (isNonReusableCode(body)) {
|
|
175
|
+
debugLog('Skipping flow body with non-reusable code');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
169
178
|
this.ensureExperienceFile(state);
|
|
170
179
|
const stateHash = state.getStateHash();
|
|
171
180
|
const { content, data } = this.readExperienceFile(stateHash);
|
package/dist/src/explorbot.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { ActionResult } from "./action-result.js";
|
|
4
|
-
import { Bosun } from "./ai/bosun.js";
|
|
5
4
|
import { Captain } from "./ai/captain.js";
|
|
5
|
+
import { Driller } from "./ai/driller.js";
|
|
6
6
|
import { ExperienceCompactor } from "./ai/experience-compactor.js";
|
|
7
7
|
import { Fisherman } from "./ai/fisherman.js";
|
|
8
8
|
import { Historian } from "./ai/historian.js";
|
|
@@ -13,6 +13,7 @@ import { AIProvider } from "./ai/provider.js";
|
|
|
13
13
|
import { Quartermaster } from "./ai/quartermaster.js";
|
|
14
14
|
import { Rerunner } from "./ai/rerunner.js";
|
|
15
15
|
import { Researcher } from "./ai/researcher.js";
|
|
16
|
+
import { SessionAnalyst } from "./ai/session-analyst.js";
|
|
16
17
|
import { Tester } from "./ai/tester.js";
|
|
17
18
|
import { createAgentTools } from "./ai/tools.js";
|
|
18
19
|
import { ApiClient } from "./api/api-client.js";
|
|
@@ -24,6 +25,7 @@ import Explorer from "./explorer.js";
|
|
|
24
25
|
import { KnowledgeTracker } from "./knowledge-tracker.js";
|
|
25
26
|
import { Plan } from "./test-plan.js";
|
|
26
27
|
import { setVerboseMode, tag } from "./utils/logger.js";
|
|
28
|
+
import { relativeToCwd } from "./utils/next-steps.js";
|
|
27
29
|
import { sanitizeFilename } from "./utils/strings.js";
|
|
28
30
|
export class ExplorBot {
|
|
29
31
|
configParser;
|
|
@@ -38,6 +40,8 @@ export class ExplorBot {
|
|
|
38
40
|
lastPlanError = null;
|
|
39
41
|
lastSavedPlanPath = null;
|
|
40
42
|
agents = {};
|
|
43
|
+
sessionPlans = [];
|
|
44
|
+
lastReportedTestCount = 0;
|
|
41
45
|
constructor(options = {}) {
|
|
42
46
|
this.options = options;
|
|
43
47
|
this.configParser = ConfigParser.getInstance();
|
|
@@ -239,14 +243,15 @@ export class ExplorBot {
|
|
|
239
243
|
}
|
|
240
244
|
return this.agents.rerunner;
|
|
241
245
|
}
|
|
242
|
-
|
|
243
|
-
return (this.agents.
|
|
244
|
-
const researcher = this.agentResearcher();
|
|
246
|
+
agentDriller() {
|
|
247
|
+
return (this.agents.driller ||= this.createAgent(({ ai, explorer }) => {
|
|
245
248
|
const navigator = this.agentNavigator();
|
|
246
|
-
|
|
247
|
-
return new Bosun(explorer, ai, researcher, navigator, tools);
|
|
249
|
+
return new Driller(explorer, ai, navigator);
|
|
248
250
|
}));
|
|
249
251
|
}
|
|
252
|
+
agentSessionAnalyst() {
|
|
253
|
+
return (this.agents.sessionAnalyst ||= this.createAgent(({ ai }) => new SessionAnalyst(ai)));
|
|
254
|
+
}
|
|
250
255
|
agentFisherman() {
|
|
251
256
|
const fishermanConfig = this.config.ai?.agents?.fisherman;
|
|
252
257
|
const hasApiConfig = !!this.config.api;
|
|
@@ -309,7 +314,7 @@ export class ExplorBot {
|
|
|
309
314
|
}
|
|
310
315
|
this.lastPlanError = null;
|
|
311
316
|
try {
|
|
312
|
-
this.
|
|
317
|
+
this.setCurrentPlan(await planner.plan(feature, opts.style, opts.extend, opts.completedPlans));
|
|
313
318
|
}
|
|
314
319
|
catch (err) {
|
|
315
320
|
this.lastPlanError = err instanceof Error ? err : new Error(String(err));
|
|
@@ -376,10 +381,45 @@ export class ExplorBot {
|
|
|
376
381
|
if (!existsSync(planPath)) {
|
|
377
382
|
throw new Error(`Plan file not found: ${planPath}`);
|
|
378
383
|
}
|
|
379
|
-
this.
|
|
384
|
+
this.setCurrentPlan(Plan.fromMarkdown(planPath));
|
|
380
385
|
return this.currentPlan;
|
|
381
386
|
}
|
|
382
387
|
setCurrentPlan(plan) {
|
|
383
388
|
this.currentPlan = plan;
|
|
389
|
+
if (plan && !this.sessionPlans.includes(plan)) {
|
|
390
|
+
this.sessionPlans.push(plan);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
getSessionTests() {
|
|
394
|
+
return this.sessionPlans.flatMap((p) => p.tests.filter((t) => t.startTime != null));
|
|
395
|
+
}
|
|
396
|
+
async printSessionAnalysis() {
|
|
397
|
+
const analystConfig = this.config.ai?.agents?.analyst;
|
|
398
|
+
if (analystConfig?.enabled === false)
|
|
399
|
+
return;
|
|
400
|
+
const tests = this.getSessionTests();
|
|
401
|
+
if (tests.length === 0)
|
|
402
|
+
return;
|
|
403
|
+
if (tests.length === this.lastReportedTestCount)
|
|
404
|
+
return;
|
|
405
|
+
try {
|
|
406
|
+
const markdown = await this.agentSessionAnalyst().analyze(tests);
|
|
407
|
+
if (!markdown) {
|
|
408
|
+
this.lastReportedTestCount = tests.length;
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
tag('multiline').log(markdown);
|
|
412
|
+
const filePath = this.agentSessionAnalyst().writeReport(markdown);
|
|
413
|
+
tag('info').log(`Session report saved: ${relativeToCwd(filePath)}`);
|
|
414
|
+
const reporter = this.explorer?.getReporter();
|
|
415
|
+
if (reporter?.isEnabled()) {
|
|
416
|
+
await reporter.setRunDescription(markdown);
|
|
417
|
+
}
|
|
418
|
+
this.lastReportedTestCount = tests.length;
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
422
|
+
tag('warning').log(`Session analysis failed: ${message}`);
|
|
423
|
+
}
|
|
384
424
|
}
|
|
385
425
|
}
|
package/dist/src/explorer.js
CHANGED
|
@@ -10,13 +10,14 @@ import Action from './action.js';
|
|
|
10
10
|
import { visuallyAnnotateContainers } from "./ai/researcher/coordinates.js";
|
|
11
11
|
import { RequestStore } from "./api/request-store.js";
|
|
12
12
|
import { XhrCapture } from "./api/xhr-capture.js";
|
|
13
|
-
import { ConfigParser
|
|
13
|
+
import { ConfigParser } from './config.js';
|
|
14
14
|
import { KnowledgeTracker } from './knowledge-tracker.js';
|
|
15
15
|
import { PlaywrightRecorder } from "./playwright-recorder.js";
|
|
16
16
|
import { Reporter } from "./reporter.js";
|
|
17
17
|
import { StateManager } from './state-manager.js';
|
|
18
|
+
import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from "./utils/html.js";
|
|
18
19
|
import { createDebug, log, tag } from './utils/logger.js';
|
|
19
|
-
import { WebElement
|
|
20
|
+
import { WebElement } from "./utils/web-element.js";
|
|
20
21
|
const debugLog = createDebug('explorbot:explorer');
|
|
21
22
|
const FATAL_BROWSER_ERRORS = /Frame was detached|Target closed|Execution context was destroyed|Protocol error|Session closed/i;
|
|
22
23
|
const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
|
|
@@ -270,11 +271,11 @@ class Explorer {
|
|
|
270
271
|
async getEidxInContainer(containerCss) {
|
|
271
272
|
const page = this.playwrightHelper.page;
|
|
272
273
|
try {
|
|
273
|
-
const selector = containerCss ? `${containerCss} [
|
|
274
|
+
const selector = containerCss ? `${containerCss} [${ELEMENT_EXTRACTION_CONFIG.attrs.eidx}]` : `[${ELEMENT_EXTRACTION_CONFIG.attrs.eidx}]`;
|
|
274
275
|
const elements = await page.locator(selector).all();
|
|
275
276
|
const result = [];
|
|
276
277
|
for (const el of elements) {
|
|
277
|
-
const attr = await el.getAttribute(
|
|
278
|
+
const attr = await el.getAttribute(ELEMENT_EXTRACTION_CONFIG.attrs.eidx);
|
|
278
279
|
if (attr)
|
|
279
280
|
result.push(attr);
|
|
280
281
|
}
|
|
@@ -293,7 +294,7 @@ class Explorer {
|
|
|
293
294
|
const page = this.playwrightHelper.page;
|
|
294
295
|
const base = container ? page.locator(container) : page;
|
|
295
296
|
const el = locator.startsWith('//') ? base.locator(`xpath=${locator}`) : base.locator(locator);
|
|
296
|
-
return await el.first().getAttribute(
|
|
297
|
+
return await el.first().getAttribute(ELEMENT_EXTRACTION_CONFIG.attrs.eidx);
|
|
297
298
|
}
|
|
298
299
|
catch (error) {
|
|
299
300
|
if (this.isFatalBrowserError(error)) {
|
|
@@ -466,10 +467,7 @@ class Explorer {
|
|
|
466
467
|
if (!this.stateManager.getCurrentState())
|
|
467
468
|
return;
|
|
468
469
|
const lastScreenshot = ActionResult.fromState(this.stateManager.getCurrentState()).screenshotFile;
|
|
469
|
-
|
|
470
|
-
return;
|
|
471
|
-
const screenshotPath = outputPath('states', lastScreenshot);
|
|
472
|
-
test.addArtifact(screenshotPath);
|
|
470
|
+
test.setActiveNoteScreenshot(lastScreenshot);
|
|
473
471
|
};
|
|
474
472
|
const dialogHandler = (dialog) => {
|
|
475
473
|
const dialogType = dialog.type();
|
|
@@ -644,21 +642,21 @@ export async function annotatePageElements(page) {
|
|
|
644
642
|
const elements = [];
|
|
645
643
|
for (const [role, entries] of byRole) {
|
|
646
644
|
try {
|
|
647
|
-
const rawList = await page.getByRole(role).evaluateAll((domElements, [data, extractFnStr]) => {
|
|
645
|
+
const rawList = await page.getByRole(role).evaluateAll((domElements, [data, extractFnStr, config]) => {
|
|
648
646
|
const extract = new Function(`return ${extractFnStr}`)();
|
|
649
647
|
const results = [];
|
|
650
648
|
let ariaIdx = 0;
|
|
651
649
|
for (const el of domElements) {
|
|
652
650
|
if (ariaIdx >= data.length)
|
|
653
651
|
break;
|
|
654
|
-
el.setAttribute(
|
|
655
|
-
const elData = extract(el);
|
|
652
|
+
el.setAttribute(config.attrs.eidx, data[ariaIdx].ref);
|
|
653
|
+
const elData = extract(el, config);
|
|
656
654
|
if (elData)
|
|
657
655
|
results.push(elData);
|
|
658
656
|
ariaIdx++;
|
|
659
657
|
}
|
|
660
658
|
return results;
|
|
661
|
-
}, [entries,
|
|
659
|
+
}, [entries, getElementDataExtractorSource(), ELEMENT_EXTRACTION_CONFIG]);
|
|
662
660
|
for (const raw of rawList) {
|
|
663
661
|
elements.push(WebElement.fromRawData(raw, role));
|
|
664
662
|
}
|
package/dist/src/reporter.js
CHANGED
|
@@ -14,8 +14,19 @@ export class Reporter {
|
|
|
14
14
|
if (this.reporterEnabled && (!process.env.TESTOMATIO || config?.html)) {
|
|
15
15
|
this.configureHtmlPipe();
|
|
16
16
|
}
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
if (this.reporterEnabled && config?.markdown) {
|
|
18
|
+
this.configureMarkdownPipe();
|
|
19
|
+
}
|
|
20
|
+
if (this.reporterEnabled) {
|
|
21
|
+
this.configureRunGroup(config?.runGroup);
|
|
22
|
+
}
|
|
23
|
+
debugLog('Reporter initialized', {
|
|
24
|
+
enabled: this.reporterEnabled,
|
|
25
|
+
testomatio: Boolean(process.env.TESTOMATIO),
|
|
26
|
+
html: Boolean(process.env.TESTOMATIO_HTML_REPORT_SAVE),
|
|
27
|
+
markdown: Boolean(process.env.TESTOMATIO_MARKDOWN_REPORT_SAVE),
|
|
28
|
+
runGroup: process.env.TESTOMATIO_RUNGROUP_TITLE || null,
|
|
29
|
+
});
|
|
19
30
|
}
|
|
20
31
|
buildTitle() {
|
|
21
32
|
if (process.env.TESTOMATIO_TITLE)
|
|
@@ -39,7 +50,31 @@ export class Reporter {
|
|
|
39
50
|
configureHtmlPipe() {
|
|
40
51
|
process.env.TESTOMATIO_HTML_REPORT_SAVE = '1';
|
|
41
52
|
process.env.TESTOMATIO_HTML_REPORT_FOLDER = outputPath('reports');
|
|
42
|
-
|
|
53
|
+
process.env.TESTOMATIO_HTML_FILENAME = `${Stats.sessionLabel()}.html`;
|
|
54
|
+
debugLog('HTML report pipe configured', {
|
|
55
|
+
folder: process.env.TESTOMATIO_HTML_REPORT_FOLDER,
|
|
56
|
+
filename: process.env.TESTOMATIO_HTML_FILENAME,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
configureMarkdownPipe() {
|
|
60
|
+
process.env.TESTOMATIO_MARKDOWN_REPORT_SAVE = '1';
|
|
61
|
+
process.env.TESTOMATIO_MARKDOWN_REPORT_FOLDER = outputPath('reports');
|
|
62
|
+
process.env.TESTOMATIO_MARKDOWN_FILENAME = `${Stats.sessionLabel()}-tests.md`;
|
|
63
|
+
debugLog('Markdown report pipe configured', {
|
|
64
|
+
folder: process.env.TESTOMATIO_MARKDOWN_REPORT_FOLDER,
|
|
65
|
+
filename: process.env.TESTOMATIO_MARKDOWN_FILENAME,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
configureRunGroup(runGroup) {
|
|
69
|
+
if (process.env.TESTOMATIO_RUNGROUP_TITLE)
|
|
70
|
+
return;
|
|
71
|
+
if (runGroup === null)
|
|
72
|
+
return;
|
|
73
|
+
if (runGroup) {
|
|
74
|
+
process.env.TESTOMATIO_RUNGROUP_TITLE = runGroup;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
process.env.TESTOMATIO_RUNGROUP_TITLE = `Explorbot ${new Date().toISOString().slice(0, 10)}`;
|
|
43
78
|
}
|
|
44
79
|
async startRun() {
|
|
45
80
|
if (this.isRunStarted) {
|
|
@@ -52,7 +87,7 @@ export class Reporter {
|
|
|
52
87
|
this.client = new Client({ apiKey: process.env.TESTOMATIO || '', title: this.buildTitle() });
|
|
53
88
|
const timeoutMs = Number(process.env.TESTOMATIO_TIMEOUT_MS || '15000');
|
|
54
89
|
const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve('timeout'), timeoutMs));
|
|
55
|
-
const result = await Promise.race([this.client.createRun().then(() => 'success'), timeoutPromise]);
|
|
90
|
+
const result = await Promise.race([this.client.createRun({ configuration: { exploratory: true } }).then(() => 'success'), timeoutPromise]);
|
|
56
91
|
if (result === 'timeout') {
|
|
57
92
|
debugLog('Reporter run creation timed out');
|
|
58
93
|
return;
|
|
@@ -82,6 +117,7 @@ export class Reporter {
|
|
|
82
117
|
message: note.message,
|
|
83
118
|
status: note.status,
|
|
84
119
|
screenshot: note.screenshot,
|
|
120
|
+
log: note.log,
|
|
85
121
|
}))
|
|
86
122
|
.sort((a, b) => a.startTime - b.startTime);
|
|
87
123
|
const stepEntries = Object.entries(test.steps)
|
|
@@ -113,8 +149,16 @@ export class Reporter {
|
|
|
113
149
|
if (noteEntry.screenshot) {
|
|
114
150
|
step.artifacts = [outputPath('states', noteEntry.screenshot)];
|
|
115
151
|
}
|
|
152
|
+
if (noteEntry.log) {
|
|
153
|
+
step.log = noteEntry.log;
|
|
154
|
+
}
|
|
116
155
|
steps.push(step);
|
|
117
156
|
}
|
|
157
|
+
const verificationStep = this.buildVerificationStep(test, lastScreenshotFile);
|
|
158
|
+
if (verificationStep) {
|
|
159
|
+
steps.push(verificationStep);
|
|
160
|
+
return steps;
|
|
161
|
+
}
|
|
118
162
|
if (lastScreenshotFile && steps.length > 0) {
|
|
119
163
|
const lastStep = steps[steps.length - 1];
|
|
120
164
|
const screenshotPath = outputPath('states', lastScreenshotFile);
|
|
@@ -127,6 +171,37 @@ export class Reporter {
|
|
|
127
171
|
}
|
|
128
172
|
return steps;
|
|
129
173
|
}
|
|
174
|
+
buildVerificationStep(test, lastScreenshotFile) {
|
|
175
|
+
const v = test.verification;
|
|
176
|
+
if (!v)
|
|
177
|
+
return undefined;
|
|
178
|
+
const subSteps = [];
|
|
179
|
+
if (v.message)
|
|
180
|
+
subSteps.push({ category: 'framework', title: v.message, duration: 0 });
|
|
181
|
+
if (v.url) {
|
|
182
|
+
subSteps.push({
|
|
183
|
+
category: 'framework',
|
|
184
|
+
title: v.pageLabel ? `Navigated to ${v.pageLabel}` : 'Final page',
|
|
185
|
+
log: v.url,
|
|
186
|
+
duration: 0,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
for (const detail of v.details) {
|
|
190
|
+
subSteps.push({ category: 'framework', title: detail, duration: 0 });
|
|
191
|
+
}
|
|
192
|
+
const screenshotFile = v.screenshot || lastScreenshotFile;
|
|
193
|
+
const step = {
|
|
194
|
+
category: 'user',
|
|
195
|
+
title: 'Verification',
|
|
196
|
+
duration: 0,
|
|
197
|
+
status: v.status || 'none',
|
|
198
|
+
steps: subSteps.length > 0 ? subSteps : undefined,
|
|
199
|
+
};
|
|
200
|
+
if (screenshotFile) {
|
|
201
|
+
step.artifacts = [outputPath('states', screenshotFile)];
|
|
202
|
+
}
|
|
203
|
+
return step;
|
|
204
|
+
}
|
|
130
205
|
async reportTest(test, meta) {
|
|
131
206
|
await this.startRun();
|
|
132
207
|
if (!this.isRunStarted) {
|
|
@@ -190,6 +265,32 @@ export class Reporter {
|
|
|
190
265
|
isEnabled() {
|
|
191
266
|
return this.isRunStarted;
|
|
192
267
|
}
|
|
268
|
+
async setRunDescription(text) {
|
|
269
|
+
if (!this.isRunStarted)
|
|
270
|
+
return;
|
|
271
|
+
if (!process.env.TESTOMATIO)
|
|
272
|
+
return;
|
|
273
|
+
const runId = this.client.runId;
|
|
274
|
+
if (!runId)
|
|
275
|
+
return;
|
|
276
|
+
const baseUrl = process.env.TESTOMATIO_URL || 'https://app.testomat.io';
|
|
277
|
+
const url = `${baseUrl}/api/reporter/${runId}`;
|
|
278
|
+
try {
|
|
279
|
+
const response = await fetch(url, {
|
|
280
|
+
method: 'PUT',
|
|
281
|
+
headers: { 'Content-Type': 'application/json' },
|
|
282
|
+
body: JSON.stringify({ api_key: process.env.TESTOMATIO, description: text }),
|
|
283
|
+
});
|
|
284
|
+
if (!response.ok) {
|
|
285
|
+
debugLog('Run description update failed:', response.status, response.statusText);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
debugLog('Run description updated');
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
debugLog('Failed to update run description:', error);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
193
294
|
extractLastNoteMessage(test) {
|
|
194
295
|
const notes = Object.values(test.notes);
|
|
195
296
|
if (notes.length === 0)
|
|
@@ -70,8 +70,8 @@ export class StateManager {
|
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
72
|
* Extract state path from full URL
|
|
73
|
-
* Removes domain, port, protocol
|
|
74
|
-
* Keeps path and hash: /path/to/page#section
|
|
73
|
+
* Removes domain, port, protocol
|
|
74
|
+
* Keeps path, query, and hash: /path/to/page?tab=users#section
|
|
75
75
|
*/
|
|
76
76
|
/**
|
|
77
77
|
* Update current state from ActionResult and record transition if state changed
|
|
@@ -418,7 +418,8 @@ export class StateManager {
|
|
|
418
418
|
export function normalizeUrl(url) {
|
|
419
419
|
try {
|
|
420
420
|
const parsed = new URL(url, 'http://localhost');
|
|
421
|
-
|
|
421
|
+
const path = parsed.pathname.replace(/^\/+|\/+$/g, '');
|
|
422
|
+
return `${path}${parsed.search}${parsed.hash}`;
|
|
422
423
|
}
|
|
423
424
|
catch {
|
|
424
425
|
return url.replace(/^\/+|\/+$/g, '');
|
package/dist/src/stats.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { uniqExplorationName } from "./utils/unique-names.js";
|
|
1
2
|
export class Stats {
|
|
2
3
|
static startTime = Date.now();
|
|
4
|
+
static sessionName = uniqExplorationName();
|
|
3
5
|
static researches = 0;
|
|
4
6
|
static tests = 0;
|
|
5
7
|
static plans = 0;
|
|
@@ -8,11 +10,12 @@ export class Stats {
|
|
|
8
10
|
static models = {};
|
|
9
11
|
static recordTokens(_agent, model, usage) {
|
|
10
12
|
if (!Stats.models[model]) {
|
|
11
|
-
Stats.models[model] = { input: 0, output: 0, total: 0 };
|
|
13
|
+
Stats.models[model] = { input: 0, output: 0, total: 0, cached: 0 };
|
|
12
14
|
}
|
|
13
15
|
Stats.models[model].input += usage.input;
|
|
14
16
|
Stats.models[model].output += usage.output;
|
|
15
17
|
Stats.models[model].total += usage.total;
|
|
18
|
+
Stats.models[model].cached = (Stats.models[model].cached ?? 0) + (usage.cached ?? 0);
|
|
16
19
|
}
|
|
17
20
|
static getElapsedTime() {
|
|
18
21
|
const elapsed = Date.now() - Stats.startTime;
|
|
@@ -42,4 +45,7 @@ export class Stats {
|
|
|
42
45
|
const totalTokens = Object.values(Stats.models).reduce((sum, m) => sum + m.total, 0);
|
|
43
46
|
return totalTokens > 0;
|
|
44
47
|
}
|
|
48
|
+
static sessionLabel() {
|
|
49
|
+
return `${Stats.mode || 'session'}-${Stats.sessionName}`;
|
|
50
|
+
}
|
|
45
51
|
}
|
package/dist/src/test-plan.js
CHANGED
|
@@ -17,6 +17,7 @@ export class ActiveNote {
|
|
|
17
17
|
message;
|
|
18
18
|
status;
|
|
19
19
|
screenshot;
|
|
20
|
+
log;
|
|
20
21
|
constructor(task, message, status) {
|
|
21
22
|
this.task = task;
|
|
22
23
|
this.startTime = performance.now();
|
|
@@ -41,6 +42,7 @@ export class Task {
|
|
|
41
42
|
steps;
|
|
42
43
|
states;
|
|
43
44
|
startUrl;
|
|
45
|
+
verification;
|
|
44
46
|
timestampCounter = 0;
|
|
45
47
|
activeNote;
|
|
46
48
|
constructor(description, startUrl = '') {
|
|
@@ -67,6 +69,7 @@ export class Task {
|
|
|
67
69
|
startTime: activeNote.getStartTime(),
|
|
68
70
|
endTime,
|
|
69
71
|
screenshot: activeNote.screenshot,
|
|
72
|
+
log: activeNote.log,
|
|
70
73
|
};
|
|
71
74
|
this.activeNote = undefined;
|
|
72
75
|
}
|
|
@@ -80,13 +83,30 @@ export class Task {
|
|
|
80
83
|
.map((n) => `- ${n}`)
|
|
81
84
|
.join('\n');
|
|
82
85
|
}
|
|
83
|
-
addNote(message, status = null, screenshot) {
|
|
84
|
-
const isDuplicate = Object.values(this.notes).some((note) => note.message === message && note.status === status);
|
|
86
|
+
addNote(message, status = null, screenshot, log) {
|
|
87
|
+
const isDuplicate = Object.values(this.notes).some((note) => note.message === message && note.status === status && note.log === log);
|
|
85
88
|
if (isDuplicate)
|
|
86
89
|
return;
|
|
87
90
|
const now = performance.now();
|
|
88
91
|
const timestamp = `${now}_${this.timestampCounter++}`;
|
|
89
|
-
this.notes[timestamp] = { message, status, startTime: now, endTime: now, screenshot };
|
|
92
|
+
this.notes[timestamp] = { message, status, startTime: now, endTime: now, screenshot, log };
|
|
93
|
+
}
|
|
94
|
+
addUrlNote(state, prevState) {
|
|
95
|
+
const fullUrl = state.fullUrl || state.url;
|
|
96
|
+
if (!fullUrl)
|
|
97
|
+
return;
|
|
98
|
+
let label;
|
|
99
|
+
if (state.title && state.title !== prevState?.title)
|
|
100
|
+
label = state.title;
|
|
101
|
+
else if (state.h1 && state.h1 !== prevState?.h1)
|
|
102
|
+
label = state.h1;
|
|
103
|
+
else if (state.h2 && state.h2 !== prevState?.h2)
|
|
104
|
+
label = state.h2;
|
|
105
|
+
else
|
|
106
|
+
label = state.title || state.h1 || state.h2;
|
|
107
|
+
if (!label)
|
|
108
|
+
return;
|
|
109
|
+
this.addNote(`Navigated to ${label}`, TestResult.PASSED, state.screenshotFile, fullUrl);
|
|
90
110
|
}
|
|
91
111
|
addState(state) {
|
|
92
112
|
this.states.push(state);
|
|
@@ -95,6 +115,30 @@ export class Task {
|
|
|
95
115
|
const timestamp = `${performance.now()}_${this.timestampCounter++}`;
|
|
96
116
|
this.steps[timestamp] = { text, duration, status, error, log, artifacts, noteStartTime: this.activeNote?.getStartTime() };
|
|
97
117
|
}
|
|
118
|
+
setActiveNoteScreenshot(screenshotFile) {
|
|
119
|
+
if (!this.activeNote || !screenshotFile)
|
|
120
|
+
return;
|
|
121
|
+
this.activeNote.screenshot = screenshotFile;
|
|
122
|
+
}
|
|
123
|
+
setVerification(message, status, state) {
|
|
124
|
+
this.verification ||= { message: '', status: null, details: [] };
|
|
125
|
+
this.verification.message = message;
|
|
126
|
+
this.verification.status = status;
|
|
127
|
+
if (!state)
|
|
128
|
+
return;
|
|
129
|
+
if (state.screenshotFile)
|
|
130
|
+
this.verification.screenshot = state.screenshotFile;
|
|
131
|
+
const fullUrl = state.fullUrl || state.url;
|
|
132
|
+
if (fullUrl)
|
|
133
|
+
this.verification.url = fullUrl;
|
|
134
|
+
this.verification.pageLabel = state.title || state.h1 || state.h2 || undefined;
|
|
135
|
+
}
|
|
136
|
+
addVerificationDetail(detail) {
|
|
137
|
+
if (!detail)
|
|
138
|
+
return;
|
|
139
|
+
this.verification ||= { message: '', status: null, details: [] };
|
|
140
|
+
this.verification.details.push(detail);
|
|
141
|
+
}
|
|
98
142
|
getLog() {
|
|
99
143
|
const merged = {};
|
|
100
144
|
for (const [key, stepData] of Object.entries(this.steps)) {
|