explorbot 0.1.15 → 0.1.17

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.
@@ -122,7 +122,15 @@ addCommonOptions(program.command('start [path]').description('Start web explorat
122
122
  await startTUI(explorBot);
123
123
  });
124
124
 
125
- addCommonOptions(program.command('explore <path>').description('Explore a page autonomously and run invented scenarios').option('--max-tests <count>', 'Maximum number of tests to run').option('--focus <feature>', 'Focus area for exploration')).action(async (explorePath, options) => {
125
+ addCommonOptions(
126
+ program
127
+ .command('explore <path>')
128
+ .description('Explore a page autonomously and run invented scenarios')
129
+ .option('--max-tests <count>', 'Maximum number of tests to run')
130
+ .option('--focus <feature>', 'Focus area for exploration')
131
+ .option('--configure <spec>', 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"')
132
+ .option('--dry-run', 'Mark picked tests as skipped without executing or generating new ones')
133
+ ).action(async (explorePath, options) => {
126
134
  try {
127
135
  const explorBot = new ExplorBot(buildExplorBotOptions(explorePath, options));
128
136
  await explorBot.start();
@@ -130,8 +138,11 @@ addCommonOptions(program.command('explore <path>').description('Explore a page a
130
138
  const { ExploreCommand } = await import('../src/commands/explore-command.js');
131
139
  const cmd = new ExploreCommand(explorBot);
132
140
  if (options.maxTests) cmd.maxTests = Number.parseInt(options.maxTests, 10);
141
+ if (options.dryRun) cmd.dryRun = true;
133
142
  const execArgs: string[] = [];
134
143
  if (options.focus) execArgs.push('--focus', `"${options.focus}"`);
144
+ if (options.configure) execArgs.push('--configure', `"${options.configure}"`);
145
+ if (options.dryRun) execArgs.push('--dry-run');
135
146
  await cmd.execute(execArgs.join(' '));
136
147
  await explorBot.stop();
137
148
  await showStatsAndExit(0);
@@ -93,7 +93,13 @@ addCommonOptions(program.command('start [path]').description('Start web explorat
93
93
  await explorBot.start();
94
94
  await startTUI(explorBot);
95
95
  });
96
- addCommonOptions(program.command('explore <path>').description('Explore a page autonomously and run invented scenarios').option('--max-tests <count>', 'Maximum number of tests to run').option('--focus <feature>', 'Focus area for exploration')).action(async (explorePath, options) => {
96
+ addCommonOptions(program
97
+ .command('explore <path>')
98
+ .description('Explore a page autonomously and run invented scenarios')
99
+ .option('--max-tests <count>', 'Maximum number of tests to run')
100
+ .option('--focus <feature>', 'Focus area for exploration')
101
+ .option('--configure <spec>', 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"')
102
+ .option('--dry-run', 'Mark picked tests as skipped without executing or generating new ones')).action(async (explorePath, options) => {
97
103
  try {
98
104
  const explorBot = new ExplorBot(buildExplorBotOptions(explorePath, options));
99
105
  await explorBot.start();
@@ -102,9 +108,15 @@ addCommonOptions(program.command('explore <path>').description('Explore a page a
102
108
  const cmd = new ExploreCommand(explorBot);
103
109
  if (options.maxTests)
104
110
  cmd.maxTests = Number.parseInt(options.maxTests, 10);
111
+ if (options.dryRun)
112
+ cmd.dryRun = true;
105
113
  const execArgs = [];
106
114
  if (options.focus)
107
115
  execArgs.push('--focus', `"${options.focus}"`);
116
+ if (options.configure)
117
+ execArgs.push('--configure', `"${options.configure}"`);
118
+ if (options.dryRun)
119
+ execArgs.push('--dry-run');
108
120
  await cmd.execute(execArgs.join(' '));
109
121
  await explorBot.stop();
110
122
  await showStatsAndExit(0);
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -277,14 +277,9 @@ export class Pilot {
277
277
  - "Edit X" → updated value must be persisted (visible in list/detail). Opening edit is NOT enough; redirect after save with the new value visible IS enough.
278
278
  - Negative tests ("without a name", "invalid", "duplicate", "unauthorized") → success means the system PREVENTED the action with validation/error.
279
279
 
280
- PROVENANCE for create/edit scenarios: the task prompt instructs the tester to inject the
281
- session marker "${task.sessionName ?? ''}" into newly created or edited free-text values.
282
- When that marker COULD be injected, the entity used as proof MUST contain it. A record
283
- matching the goal by text alone but missing the marker is a stale leftover from a prior
284
- run — it is NOT evidence the current scenario produced anything. Vote \`fail\`, not \`pass\`.
285
- This does not apply when the field is restricted (numeric only, enum, etc.) or when the
286
- session_log shows no fillField/type/select actions were attempted at all (in that case
287
- the scenario clearly didn't run — also vote \`fail\`).
280
+ PROVENANCE: the entity you cite as proof must appear by name in <notes> or
281
+ <session_log> tool inputs for THIS run. Name absent from tester activity = stale
282
+ coincidence, vote \`fail\`. Same if no fillField/type/select/click on a target ran.
288
283
 
289
284
  Expected results are MILESTONES, not the goal. Never fail because a milestone (toast, icon, styling)
290
285
  didn't match if the scenario goal IS accomplished.
@@ -1,20 +1,61 @@
1
- import { detectFocusArea } from "../../utils/aria.js";
2
1
  import { mdq } from "../../utils/markdown-query.js";
3
2
  export const FOCUSED_MARKER = '> **Focused**';
4
3
  const FOCUS_SKIP_SECTIONS = new Set(['navigation', 'menu']);
5
4
  export function hasFocusedSection(text) {
6
5
  return text.includes(FOCUSED_MARKER);
7
6
  }
8
- export function detectFocusFromAria(ariaSnapshot, sections) {
9
- const focusArea = detectFocusArea(ariaSnapshot);
10
- if (!focusArea.detected)
11
- return null;
12
- if (focusArea.type === 'dialog' || focusArea.type === 'modal') {
13
- const dialogSection = sections.find((s) => s.containerCss && (s.containerCss.includes('[role="dialog"]') || s.containerCss.includes('[role="alertdialog"]') || s.containerCss.includes('[aria-modal')));
14
- if (dialogSection)
15
- return dialogSection.name;
7
+ export async function detectFocusedSection(page, sections) {
8
+ const candidates = [];
9
+ for (const section of sections) {
10
+ if (!section.containerCss)
11
+ continue;
12
+ const key = section.name.toLowerCase().replace(/^section:\s*/, '');
13
+ if (FOCUS_SKIP_SECTIONS.has(key))
14
+ continue;
15
+ try {
16
+ const locator = page.locator(section.containerCss).first();
17
+ if (!(await locator.isVisible()))
18
+ continue;
19
+ const probe = await locator.evaluate((el) => {
20
+ const dialogSelector = '[role="dialog"], [role="alertdialog"], [aria-modal="true"]';
21
+ const isDialog = el.matches(dialogSelector) || !!el.querySelector(dialogSelector);
22
+ let cur = el;
23
+ let maxZ = 0;
24
+ while (cur && cur !== document.body) {
25
+ const cs = window.getComputedStyle(cur);
26
+ if (cs.position !== 'static') {
27
+ const z = Number.parseInt(cs.zIndex, 10);
28
+ if (!Number.isNaN(z) && z > maxZ)
29
+ maxZ = z;
30
+ }
31
+ cur = cur.parentElement;
32
+ }
33
+ const shadow = window.getComputedStyle(el).boxShadow;
34
+ const hasShadow = !!shadow && shadow !== 'none';
35
+ return { isDialog, zIndex: maxZ, hasShadow };
36
+ });
37
+ candidates.push({ name: section.name, ...probe });
38
+ }
39
+ catch { }
16
40
  }
17
- return null;
41
+ if (candidates.length === 0)
42
+ return null;
43
+ const dialogs = candidates.filter((c) => c.isDialog);
44
+ const pool = dialogs.length > 0 ? dialogs : candidates;
45
+ const winner = pool.reduce((best, c) => {
46
+ if (!best)
47
+ return c;
48
+ if (c.zIndex !== best.zIndex)
49
+ return c.zIndex > best.zIndex ? c : best;
50
+ if (c.hasShadow !== best.hasShadow)
51
+ return c.hasShadow ? c : best;
52
+ return best;
53
+ }, null);
54
+ if (!winner)
55
+ return null;
56
+ if (dialogs.length === 0 && winner.zIndex === 0 && !winner.hasShadow)
57
+ return null;
58
+ return winner.name;
18
59
  }
19
60
  export function markSectionAsFocused(result, sectionName) {
20
61
  if (hasFocusedSection(result.text))
@@ -3,6 +3,8 @@ import { executionController } from "../../execution-controller.js";
3
3
  import { tag } from '../../utils/logger.js';
4
4
  import { RulesLoader } from "../../utils/rules-loader.js";
5
5
  import { locatorRule as generalLocatorRuleText } from '../rules.js';
6
+ import { markSectionAsFocused } from "./focus.js";
7
+ import { ResearchResult } from "./research-result.js";
6
8
  export function WithSections(Base) {
7
9
  return class extends Base {
8
10
  async researchBySections() {
@@ -40,10 +42,12 @@ export function WithSections(Base) {
40
42
  if (parts.length === 0) {
41
43
  throw new Error('Per-section research produced no sections — AI responses all empty or NOT_PRESENT');
42
44
  }
43
- let merged = parts.join('\n\n');
44
- if (focusCss)
45
- merged += '\n\n> Focused: Focus';
46
- return merged;
45
+ const merged = parts.join('\n\n');
46
+ if (!focusCss)
47
+ return merged;
48
+ const focused = new ResearchResult(merged, this.actionResult?.url || '');
49
+ markSectionAsFocused(focused, 'Focus');
50
+ return focused.text;
47
51
  }
48
52
  async _detectFocusCss() {
49
53
  const focusSections = this.explorer.getConfig().ai?.agents?.researcher?.focusSections;
@@ -16,7 +16,7 @@ import { ContextLengthError } from './provider.js';
16
16
  import { findSimilarResearch, getCachedResearch, saveResearch } from "./researcher/cache.js";
17
17
  import { WithCoordinates } from "./researcher/coordinates.js";
18
18
  import { WithDeepAnalysis } from "./researcher/deep-analysis.js";
19
- import { detectFocusFromAria, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from "./researcher/focus.js";
19
+ import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from "./researcher/focus.js";
20
20
  import { WithLocators } from "./researcher/locators.js";
21
21
  import { extractValidContainers, formatResearchSummary, parseResearchSections } from "./researcher/parser.js";
22
22
  import { ResearchResult } from "./researcher/research-result.js";
@@ -186,18 +186,13 @@ export class Researcher extends ResearcherBase {
186
186
  if (!interrupted() && fix && result.locators.some((l) => l.valid === false)) {
187
187
  await this.fixBrokenSections(result, activeConversation);
188
188
  }
189
- // Focused section: parse AI declaration, then ARIA fallback
190
- const focusMatch = result.text.match(/^>\s*Focused:\s*(.+)/m);
191
- if (focusMatch) {
192
- result.text = result.text.replace(focusMatch[0], '');
193
- markSectionAsFocused(result, focusMatch[1].trim());
194
- }
195
- if (!hasFocusedSection(result.text)) {
189
+ // Focused section: unified Playwright probe (HTML+CSS+visibility).
190
+ // Must run BEFORE visuallyAnnotateContainers — annotation overlays inject z-index 99998+ which would pollute the scoring.
191
+ if (!interrupted() && this.hasScreenshotToAnalyze) {
196
192
  const sections = parseResearchSections(result.text);
197
- const ariaSnapshot = this.actionResult?.getCompactARIA() || '';
198
- const focusedName = detectFocusFromAria(ariaSnapshot, sections);
199
- if (focusedName)
200
- markSectionAsFocused(result, focusedName);
193
+ const focused = await detectFocusedSection(this.explorer.playwrightHelper.page, sections);
194
+ if (focused)
195
+ markSectionAsFocused(result, focused);
201
196
  }
202
197
  // Stage 4: Visual analysis
203
198
  if (!interrupted() && this.hasScreenshotToAnalyze) {
@@ -232,8 +227,8 @@ export class Researcher extends ResearcherBase {
232
227
  await this.backfillCoordinates(result);
233
228
  await this.backfillBrokenLocators(result);
234
229
  }
235
- // Focused section: final fallback
236
- if (!hasFocusedSection(result.text)) {
230
+ // Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
231
+ if (this.hasScreenshotToAnalyze && !hasFocusedSection(result.text)) {
237
232
  const sections = parseResearchSections(result.text);
238
233
  const fallback = pickDefaultFocusedSection(sections);
239
234
  if (fallback)
@@ -388,16 +383,6 @@ export class Researcher extends ResearcherBase {
388
383
 
389
384
  | Element | ARIA | CSS | eidx |
390
385
  </section_format>
391
-
392
- <focused_section>
393
- At the end of your output, declare the primary focus area on a single line:
394
-
395
- > Focused: <exact section name>
396
-
397
- - If a dialog/modal/drawer/overlay exists, it is focused.
398
- - Otherwise pick the section where the main business action happens (list for catalog, detail for item page, content for article).
399
- - Navigation and menu/toolbar are never focused.
400
- </focused_section>
401
386
  `;
402
387
  }
403
388
  async buildResearchPrompt() {
@@ -329,10 +329,15 @@ export class Tester extends TaskAgent {
329
329
  : undefined,
330
330
  catch: async ({ error, stop }) => {
331
331
  tag('error').log(`Test execution error: ${error}`);
332
+ const message = error instanceof Error ? error.message : String(error);
332
333
  if (!task.hasFinished) {
333
- task.addNote(`Execution error: ${error instanceof Error ? error.message : String(error)}`);
334
+ task.addNote(`Execution error: ${message}`);
334
335
  }
335
- stop();
336
+ if (error instanceof Error && error.name === 'AbortError') {
337
+ stop();
338
+ return;
339
+ }
340
+ conversation.addUserText(`Previous AI call failed: ${message}. Take a different approach on the next step.`);
336
341
  },
337
342
  });
338
343
  if (task.hasFinished)
@@ -643,6 +648,7 @@ export class Tester extends TaskAgent {
643
648
  - Use pressKey() for pressing special keys (Enter, Escape, Tab, Arrow keys) or key combinations with modifiers (Ctrl+A, Shift+Delete, etc.)
644
649
  - Use container CSS locators from <page_ui_map> to interact with elements inside sections
645
650
  - Systematically use record({ notes: ["..."] }) to write your findings, planned actions, observations, etc.
651
+ - When creating/editing/deleting a named entity, include its identifier verbatim in the note — Pilot uses it to confirm provenance.
646
652
  - Call record({ notes: ["..."], status: "success" }) when you see success/info message on a page or when expected outcome is achieved
647
653
  - Call record({ notes: ["..."], status: "fail" }) when an expected outcome cannot be achieved or has failed or you see error/alert/warning message on a page
648
654
  - NEVER call record(status: "success") if your last verify() or see() call FAILED. A failed check means the outcome is NOT confirmed — use record(status: "fail") instead, or retry with a different approach.