Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,20 @@
- An `explore` run ends with an "Explore more" section suggesting `explorbot explore /` to cover the whole site, plus a link to the docs.
- Init makes it explicit how to connect the AI: the `.env` template and the missing-key warning point to the provider's key page (e.g. `https://openrouter.ai/keys`) and show the exact line to paste — `OPENROUTER_API_KEY=<your-key>`; the setup wizard shows where to create the key too.

## 2026-11-09

### Changes
- [Tester] Non-standard interaction tools — `dragAndDrop()` and `rightClick()` — are now explicit tools: off by default, so they never grow the Tester's context for ordinary scenarios. The Planner decides per scenario (via the decision model) whether a drag or a context menu is needed and arms the tools for exactly those scenarios; the choice is stored in the plan file (`tools:` line in the `<!-- test -->` comment) and survives re-runs. Mid-run, the Pilot can also arm the tools by including `ENABLE_TOOLS: <names>` in its instruction when a scenario turns out to need an interaction `click()`/`form()` cannot express.

## 2026-10-08

### Changes
- [Tester] New `dragAndDrop()` tool, so scenarios that drag something onto something else can be tested: reordering lists and trees, moving cards between columns, dropping items into folders or zones — and dragging slider handles or drawing on canvas, where nothing can be addressed by a locator. The tool takes visual descriptions of what to grab and where to drop it, the vision model locates both points on the screenshot, and the drag runs as a real mouse press between them through the Playwright API. When the drop changes nothing, the same points are retried with HTML5 drag events — covering pages whose drag libraries cancel the mouse press.
```javascript
dragAndDrop({ from: 'card labeled Bug 5 in the first column', to: 'column labeled Done' }); // move or reorder
```
Copy-drag: pass the modifier (e.g. `Control`) to the tool; the generated test records the drag as a plain CodeceptJS `I.usePlaywrightTo` mouse sequence — so it replays and heals in rerun like any other step.

## 2026-10-06

### Configuration
Expand All @@ -251,8 +265,8 @@
- OpenRouter's recommended agentic model is now Claude Haiku 5.5 (`anthropic/claude-haiku-5.5`).
- OpenAI's recommended vision and agentic model is now GPT-6 Luna (`gpt-6-luna`).
- `explorbot recommended-models` lists the decision model and its `EXPLORBOT_DECISION_MODEL` env line for providers that recommend one.
### Changes
- A `url` in the config file can now point to an app in a subfolder, such as `https://example.com/apps/todo/`. The folder becomes the base path for the run, just like `--base-url`: `explorbot start` opens the app itself, and relative paths such as `/active` resolve to `/apps/todo/active`. Previously every navigation was built as `https://example.com/apps/todo///…` and the Navigator had to guess its way back, sometimes ending on the wrong page. `--base-url` still takes precedence over the config `url`.
- Generated tests no longer break on a locator that contains quotes, such as `li:has-text("Item 2")`: executed commands are recorded with the quotes escaped, so the test file stays valid code and reruns instead of failing to load.

## 2026-10-05

Expand Down
4 changes: 2 additions & 2 deletions bin/explorbot-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import { createDocsCommands } from '../boat/doc-collector/src/cli.ts';
import { createPrimaCommands } from '../boat/prima/src/cli.ts';
import { flushTelemetry } from '../src/ai/provider.js';
import { HelpJsonCommand } from '../src/commands/help-json-command.js';
import { RecommendedModelsCommand } from '../src/commands/recommended-models-command.js';
import { authOption, decisionModelOption, knowledgeOption, wsOption } from '../src/commands/options/index.js';
import { PlanCommand } from '../src/commands/plan-command.js';
import { RecommendedModelsCommand } from '../src/commands/recommended-models-command.js';
import { App } from '../src/components/App.js';
import { StatusPane } from '../src/components/StatusPane.js';
import { authOption, decisionModelOption, knowledgeOption, wsOption } from '../src/commands/options/index.js';
import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS, outputPath } from '../src/config.js';
import { ExplorBot, type ExplorBotOptions } from '../src/explorbot.js';
import { remote } from '../src/remote.js';
Expand Down
2 changes: 1 addition & 1 deletion boat/doc-collector/src/ai/documentarian.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import dedent from 'dedent';
import { z } from 'zod';
import type { AIProvider } from '../../../../src/ai/provider.ts';
import { parseResearchSections } from '../../../../src/ai/researcher/parser.ts';
import type Explorer from '../../../../src/explorer.ts';
import type { StateManager } from '../../../../src/state-manager.ts';
import type { WebPageState } from '../../../../src/state-manager.ts';
import { parseAriaLocator } from '../../../../src/utils/aria.ts';
import { tag } from '../../../../src/utils/logger.ts';
import { parseResearchSections } from '../../../../src/ai/researcher/parser.ts';
import type { DocbotConfig } from '../config.ts';
import { type CaptureInteractionState, type DocStateTransition, collectDocInteractions } from './tools.ts';

Expand Down
2 changes: 1 addition & 1 deletion boat/doc-collector/src/docbot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { ExplorBot, type ExplorBotOptions } from '../../../src/explorbot.ts';
import type { Link, WebPageState } from '../../../src/state-manager.ts';
import { normalizeUrl } from '../../../src/state-manager.ts';
import { tag } from '../../../src/utils/logger.ts';
import { sanitizeFilename } from '../../../src/utils/strings.ts';
import { keepSiteUrls } from '../../../src/utils/sitemap.ts';
import { sanitizeFilename } from '../../../src/utils/strings.ts';
import { Documentarian, type PageDocumentation } from './ai/documentarian.ts';
import type { DocStateTransition } from './ai/tools.ts';
import { type DocbotConfig, DocbotConfigParser } from './config.ts';
Expand Down
2 changes: 1 addition & 1 deletion boat/doc-collector/src/path-filter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { normalizeUrl } from '../../../src/state-manager.ts';
import { matchesUrl, generalizeUrl } from '../../../src/utils/url-matcher.ts';
import { generalizeUrl, matchesUrl } from '../../../src/utils/url-matcher.ts';
import type { DocbotConfig } from './config.ts';

const DEFAULT_DENIED_PATH_SEGMENTS = ['callback', 'callbacks', 'logout', 'signout', 'sign_out', 'destroy', 'delete', 'remove'];
Expand Down
2 changes: 1 addition & 1 deletion boat/doc-collector/src/research-navigation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type ResearchElement, parseResearchSections } from '../../../src/ai/researcher/parser.ts';
import type { WebPageState } from '../../../src/state-manager.ts';
import { parseResearchSections, type ResearchElement } from '../../../src/ai/researcher/parser.ts';

const OPEN_API_TAG_SELECTOR_PATTERN = /api-\d+\/tag\/([a-z0-9-]+)(?:["'#/\]\s]|$)/i;
const OPEN_API_NAVIGATION_SECTION_KEYWORDS = ['navigation', 'menu'];
Expand Down
2 changes: 1 addition & 1 deletion boat/doc-collector/src/screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import path from 'node:path';
import { PNG } from 'pngjs';
import { removeVisualAnnotations } from '../../../src/ai/researcher/coordinates.ts';
import { parseResearchSections } from '../../../src/ai/researcher/parser.ts';
import { parseAriaLocator } from '../../../src/utils/aria.ts';
import type Explorer from '../../../src/explorer.ts';
import { type WebPageState, normalizeUrl } from '../../../src/state-manager.ts';
import { parseAriaLocator } from '../../../src/utils/aria.ts';
import { safeFilename, sanitizeFilename } from '../../../src/utils/strings.ts';
import type { PageDocumentation } from './ai/documentarian.ts';
import type { DocStateTransition } from './ai/tools.ts';
Expand Down
2 changes: 1 addition & 1 deletion docs/web-testing/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ flowchart LR

## Navigator Agent

Handles browser interactions: clicks, form fills, and navigation.
Handles browser interactions: clicks, drag and drop, form fills, and navigation.

The Navigator runs CodeceptJS commands in the browser. When a selector fails, it tries other locator strategies and resolves the interaction without stopping the run. It remembers what worked and what didn't, so failed selectors don't keep killing your runs and tests survive UI changes.

Expand Down
9 changes: 9 additions & 0 deletions docs/web-testing/page-interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ The agent starts with short semantic locators from ARIA or visible text. When a

When a locator fails, the agent tries the next strategy, then a visual click. Locators that worked are saved to experience and preferred on the next run.

## Explicit tools: drag and drop, right-click

Some interactions — dragging an element, opening a context menu with a right-click — need dedicated tools (`dragAndDrop`, `rightClick` in `src/ai/explicit-tools.ts`). They are **off by default**, so they never grow the Tester's context for ordinary scenarios. Two gates turn them on:

- **Planner gate** — during planning, each scenario is checked with the decision model (`provider.oracle()`, with the agentic model as fallback). A scenario that needs a drag or a context menu gets the tools armed; the choice is stored in the plan (`tools:` in the `<!-- test -->` comment) and survives re-runs.
- **Pilot gate** — mid-run, the Pilot can request the tools for the Tester by including `ENABLE_TOOLS: <names>` in its instruction, when the scenario turns out to need an interaction `click()`/`form()` cannot express.

The `dragAndDrop()` tool takes visual descriptions of what to grab and where to drop it. The vision model locates both points on the screenshot, and the drag runs as a real mouse press between them through the Playwright API — which also covers sliders and canvas drawing, where there is nothing to attach a locator to. When the drop changes nothing, the tool retries the same points with HTML5 drag events on its own; that covers pages whose drag libraries cancel the mouse press, so a real mouse can never start the drag. A modifier key can be held during the drag for copy-drags. The drag is recorded as a plain CodeceptJS `I.usePlaywrightTo` mouse sequence, so generated tests replay unchanged.

## What happens after each action

After every action, Explorbot captures the new page state and compares it with the previous one. The resulting diff tells the agent what changed — the URL, the accessibility tree, or the HTML — so it can confirm the action worked and decide what to do next. The Researcher turns a page into a structured UI map of sections and elements; see [Researcher](./researcher.md).
Expand Down
24 changes: 19 additions & 5 deletions src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,11 +583,7 @@ export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Arra
}
if (batchFailed) return;

const executed: ExecutedStep = { command: step.toCode(), success: !error };
if (Object.keys(step.opts || {}).length) {
const args = [step.humanizeArgs(), `step.opts(${JSON.stringify(step.opts)})`].filter(Boolean).join(', ');
executed.command = `${step.prefix}${step.actor}.${step.title}(${args})${step.suffix}`;
}
const executed: ExecutedStep = { command: stepCode(step), success: !error };
if (error) {
executed.error = errorToString(error);
batchFailed = true;
Expand All @@ -612,6 +608,24 @@ export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Arra
};
};

function stepCode(step: any): string {
const needsEscaping = (step.args || []).some((arg: any) => typeof arg === 'string' && arg.includes('"'));
if (!needsEscaping) {
if (!Object.keys(step.opts || {}).length) return step.toCode();
const args = [step.humanizeArgs(), `step.opts(${JSON.stringify(step.opts)})`].filter(Boolean).join(', ');
return `${step.prefix}${step.actor}.${step.title}(${args})${step.suffix}`;
}
const args = (step.args || [])
.filter((arg: any) => arg !== undefined)
.map((arg: any) => {
if (typeof arg === 'function') return arg.toString();
if (arg && typeof arg.getMasked === 'function') return JSON.stringify(arg.getMasked());
return JSON.stringify(arg);
});
if (Object.keys(step.opts || {}).length) args.push(`step.opts(${JSON.stringify(step.opts)})`);
return `${step.prefix}${step.actor}.${step.name}(${args.join(', ')})${step.suffix}`;
}

const readFocusedElement = () => {
const el = document.activeElement as any;
if (!el || el === document.body) return null;
Expand Down
2 changes: 1 addition & 1 deletion src/ai/captain/idle-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import dedent from 'dedent';
import { z } from 'zod';
import { ConfigParser } from '../../config.ts';
import { Plan, Test } from '../../test-plan.ts';
import { getStyles } from '../planner/styles.ts';
import { PlanningBlockedError } from '../../utils/error-page.ts';
import { getStyles } from '../planner/styles.ts';
import { listRecentArtifacts, readCaptainFile } from './file-tools.ts';
import { type Constructor, type ModeContext, resolveProjectRoot } from './mixin.ts';

Expand Down
2 changes: 1 addition & 1 deletion src/ai/captain/web-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function WithWebMode<T extends Constructor>(Base: T) {
webModeTools(ctx: ModeContext): Record<string, any> {
const explorer = ctx.explorBot.getExplorer();
const toolDeps = { explorer, stateManager: ctx.explorBot.stateManager(), ai: ctx.explorBot.getProvider() };
const codeceptTools = createCodeceptJSTools(toolDeps, ctx.task);
const codeceptTools = createCodeceptJSTools({ ...toolDeps, researcher: ctx.explorBot.agentResearcher() }, ctx.task);
const agentTools = createAgentTools({
...toolDeps,
researcher: ctx.explorBot.agentResearcher(),
Expand Down
Loading
Loading