explorbot 0.1.13 → 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.
@@ -87,7 +87,7 @@ export class Reporter {
87
87
  this.client = new Client({ apiKey: process.env.TESTOMATIO || '', title: this.buildTitle() });
88
88
  const timeoutMs = Number(process.env.TESTOMATIO_TIMEOUT_MS || '15000');
89
89
  const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve('timeout'), timeoutMs));
90
- 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]);
91
91
  if (result === 'timeout') {
92
92
  debugLog('Reporter run creation timed out');
93
93
  return;
@@ -117,6 +117,7 @@ export class Reporter {
117
117
  message: note.message,
118
118
  status: note.status,
119
119
  screenshot: note.screenshot,
120
+ log: note.log,
120
121
  }))
121
122
  .sort((a, b) => a.startTime - b.startTime);
122
123
  const stepEntries = Object.entries(test.steps)
@@ -148,8 +149,16 @@ export class Reporter {
148
149
  if (noteEntry.screenshot) {
149
150
  step.artifacts = [outputPath('states', noteEntry.screenshot)];
150
151
  }
152
+ if (noteEntry.log) {
153
+ step.log = noteEntry.log;
154
+ }
151
155
  steps.push(step);
152
156
  }
157
+ const verificationStep = this.buildVerificationStep(test, lastScreenshotFile);
158
+ if (verificationStep) {
159
+ steps.push(verificationStep);
160
+ return steps;
161
+ }
153
162
  if (lastScreenshotFile && steps.length > 0) {
154
163
  const lastStep = steps[steps.length - 1];
155
164
  const screenshotPath = outputPath('states', lastScreenshotFile);
@@ -162,6 +171,37 @@ export class Reporter {
162
171
  }
163
172
  return steps;
164
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
+ }
165
205
  async reportTest(test, meta) {
166
206
  await this.startRun();
167
207
  if (!this.isRunStarted) {
package/dist/src/stats.js CHANGED
@@ -10,11 +10,12 @@ export class Stats {
10
10
  static models = {};
11
11
  static recordTokens(_agent, model, usage) {
12
12
  if (!Stats.models[model]) {
13
- Stats.models[model] = { input: 0, output: 0, total: 0 };
13
+ Stats.models[model] = { input: 0, output: 0, total: 0, cached: 0 };
14
14
  }
15
15
  Stats.models[model].input += usage.input;
16
16
  Stats.models[model].output += usage.output;
17
17
  Stats.models[model].total += usage.total;
18
+ Stats.models[model].cached = (Stats.models[model].cached ?? 0) + (usage.cached ?? 0);
18
19
  }
19
20
  static getElapsedTime() {
20
21
  const elapsed = Date.now() - Stats.startTime;
@@ -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)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -67,6 +67,7 @@
67
67
  "@ai-sdk/openai": "^3.0",
68
68
  "@axe-core/playwright": "^4.11.0",
69
69
  "@codeceptjs/reflection": "^0.5.2",
70
+ "@faker-js/faker": "^10.4.0",
70
71
  "@inkjs/ui": "^2.0.0",
71
72
  "@langfuse/otel": "^4.5.1",
72
73
  "@openrouter/ai-sdk-provider": "^2.3.3",
@@ -78,7 +79,7 @@
78
79
  "@opentelemetry/sdk-trace-base": "^2.2.0",
79
80
  "@opentelemetry/semantic-conventions": "^1.38.0",
80
81
  "@scalar/openapi-parser": "^0.25.6",
81
- "@testomatio/reporter": "^2.7.9-beta.2-markdown",
82
+ "@testomatio/reporter": "^2.7.9-beta.3-markdown",
82
83
  "ai": "^6.0.6",
83
84
  "axe-core": "^4.11.1",
84
85
  "bash-tool": "^1.3.15",
package/src/action.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { faker } from '@faker-js/faker';
3
4
  import { context, trace } from '@opentelemetry/api';
4
5
  import { highlight } from 'cli-highlight';
5
6
  import { container, recorder } from 'codeceptjs';
@@ -255,8 +256,8 @@ class Action {
255
256
  await asyncFn(page);
256
257
  await sleep(this.config.action?.delay || 500);
257
258
  } else {
258
- const codeFunction = new Function('I', 'tryTo', 'retryTo', 'within', 'hopeThat', 'step', sanitizedCode);
259
- codeFunction(this.actor, tryTo, retryTo, within, hopeThat, step);
259
+ const codeFunction = new Function('I', 'tryTo', 'retryTo', 'within', 'hopeThat', 'step', 'faker', sanitizedCode);
260
+ codeFunction(this.actor, tryTo, retryTo, within, hopeThat, step, faker);
260
261
  await recorder.add(() => sleep(this.config.action?.delay || 500));
261
262
  await recorder.promise();
262
263
  }
@@ -19,6 +19,7 @@ export class Conversation {
19
19
  messages: ModelMessage[];
20
20
  model: any;
21
21
  telemetryFunctionId?: string;
22
+ protectedPrefixCount = 0;
22
23
  private autoTrimRules: Map<string, number>;
23
24
 
24
25
  constructor(messages: ModelMessage[] = [], model?: any, telemetryFunctionId?: string) {
@@ -29,6 +30,10 @@ export class Conversation {
29
30
  this.autoTrimRules = new Map();
30
31
  }
31
32
 
33
+ protectPrefix(count: number): void {
34
+ this.protectedPrefixCount = count;
35
+ }
36
+
32
37
  addUserText(text: string): void {
33
38
  this.messages.push({
34
39
  role: 'user',
@@ -85,9 +90,11 @@ export class Conversation {
85
90
  const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
86
91
  const regex = new RegExp(`<${escapedTag}>[\\s\\S]*?<\\/${escapedTag}>`, 'g');
87
92
  const replacementText = `<${tagName}>${replacement}</${tagName}>`;
93
+ const start = this.protectedPrefixCount;
88
94
 
89
95
  if (keepLast === 0) {
90
- for (const message of this.messages) {
96
+ for (let i = start; i < this.messages.length; i++) {
97
+ const message = this.messages[i];
91
98
  if (typeof message.content === 'string') {
92
99
  message.content = message.content.replace(regex, replacementText);
93
100
  }
@@ -96,7 +103,7 @@ export class Conversation {
96
103
  }
97
104
 
98
105
  const allMatches: Array<{ messageIndex: number; startIndex: number; endIndex: number }> = [];
99
- for (let i = 0; i < this.messages.length; i++) {
106
+ for (let i = start; i < this.messages.length; i++) {
100
107
  const message = this.messages[i];
101
108
  if (typeof message.content === 'string') {
102
109
  const matches = [...message.content.matchAll(new RegExp(`<${escapedTag}>[\\s\\S]*?<\\/${escapedTag}>`, 'g'))];
@@ -112,7 +119,7 @@ export class Conversation {
112
119
  const keepMatches = allMatches.slice(-keepCount);
113
120
  const keepSet = new Set(keepMatches.map((m) => `${m.messageIndex}:${m.startIndex}`));
114
121
 
115
- for (let i = 0; i < this.messages.length; i++) {
122
+ for (let i = start; i < this.messages.length; i++) {
116
123
  const message = this.messages[i];
117
124
  if (typeof message.content === 'string') {
118
125
  const matches = [...message.content.matchAll(new RegExp(`<${escapedTag}>[\\s\\S]*?<\\/${escapedTag}>`, 'g'))];
@@ -137,7 +144,7 @@ export class Conversation {
137
144
 
138
145
  compactToolResults(keepLastN: number): void {
139
146
  const toolMessageIndexes: number[] = [];
140
- for (let i = 0; i < this.messages.length; i++) {
147
+ for (let i = this.protectedPrefixCount; i < this.messages.length; i++) {
141
148
  if (this.messages[i].role === 'tool') toolMessageIndexes.push(i);
142
149
  }
143
150
  const compactUpTo = toolMessageIndexes.length - Math.max(0, keepLastN);
@@ -169,6 +176,16 @@ export class Conversation {
169
176
  }
170
177
  }
171
178
 
179
+ markLastMessageCacheable(): void {
180
+ const last = this.messages[this.messages.length - 1];
181
+ if (!last) return;
182
+ (last as any).providerOptions = {
183
+ ...(last as any).providerOptions,
184
+ anthropic: { cacheControl: { type: 'ephemeral' } },
185
+ bedrock: { cachePoint: { type: 'default' } },
186
+ };
187
+ }
188
+
172
189
  hasTag(tagName: string, lastN?: number): boolean {
173
190
  const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
174
191
  const regex = new RegExp(`<${escapedTag}>`, 'g');
@@ -1,7 +1,14 @@
1
+ import { isDynamicId } from '../../utils/xpath.ts';
1
2
  import type { ToolExecution } from '../conversation.ts';
2
3
 
3
4
  export function isNonReusableCode(code: string): boolean {
4
- return /\bI\.clickXY\s*\(/.test(code);
5
+ if (/\bI\.clickXY\s*\(/.test(code)) return true;
6
+
7
+ for (const m of code.matchAll(/#([A-Za-z_][\w-]*)/g)) {
8
+ if (isDynamicId(m[1])) return true;
9
+ }
10
+
11
+ return false;
5
12
  }
6
13
 
7
14
  export function escapeString(str: string): string {