Skip to content
Merged
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
3 changes: 3 additions & 0 deletions bin/explorbot-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ interface CLIOptions {
headless?: boolean;
incognito?: boolean;
session?: string | boolean;
spec?: string;
}

function buildExplorBotOptions(from: string | undefined, options: CLIOptions): ExplorBotOptions {
Expand All @@ -52,6 +53,7 @@ function buildExplorBotOptions(from: string | undefined, options: CLIOptions): E
headless: options.headless,
incognito: options.incognito,
session: options.session,
applicationSpec: options.spec,
} as ExplorBotOptions;
}

Expand All @@ -64,6 +66,7 @@ function addCommonOptions(cmd: Command): Command {
.option('-s, --show', 'Show browser window')
.option('--headless', 'Run browser in headless mode')
.option('--incognito', 'Run without recording experiences')
.option('--spec <path>', 'Use a Docbot application spec directory or index.md')
.option('--session [file]', 'Save/restore browser session from file');
}

Expand Down
1 change: 1 addition & 0 deletions boat/doc-collector/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function createDocsCommands(name = 'docs'): Command {
console.log(`Skipped ${result.skipped.length} page(s)`);
console.log(`Spec index: ${result.indexPath}`);
console.log(`Pages dir: ${path.join(result.outputDir, 'pages')}`);
console.log(`Use in Explorbot: npx explorbot start ${startPath} --spec "${result.outputDir}"`);

await bot.stop();
process.exit(0);
Expand Down
12 changes: 9 additions & 3 deletions boat/doc-collector/src/docs-renderer.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import path from 'node:path';
import matter from 'gray-matter';
import { APPLICATION_SPEC_FORMAT, APPLICATION_SPEC_VERSION } from '../../../src/application-spec-contract.ts';
import { type WebPageState } from '../../../src/state-manager.ts';
import { normalizeInlineText } from '../../../src/utils/strings.ts';
import type { PageDocumentation, StateTransition } from './ai/documentarian.ts';
import type { DocumentationScreenshot } from './screenshots.ts';
import { buildStateGraph, renderMermaidFromGraph, renderStateMapFromGraph, type DocumentedPage, type SkippedPage } from './state-diagram.ts';
import { normalizeInlineText } from '../../../src/utils/strings.ts';
import { type DocumentedPage, type SkippedPage, buildStateGraph, renderMermaidFromGraph, renderStateMapFromGraph } from './state-diagram.ts';

function renderPageDocumentation(state: WebPageState, documentation: PageDocumentation, screenshots: DocumentationScreenshot[] = []): string {
const lines: string[] = [];
Expand Down Expand Up @@ -101,7 +103,11 @@ function renderPageDocumentation(state: WebPageState, documentation: PageDocumen
lines.push('');
}

return `${lines.join('\n').trimEnd()}\n`;
return matter.stringify(`${lines.join('\n').trimEnd()}\n`, {
url: state.url,
format: APPLICATION_SPEC_FORMAT,
version: APPLICATION_SPEC_VERSION,
});
}

function renderSpecIndex(outputDir: string, startPath: string, pages: DocumentedPage[], skipped: SkippedPage[], maxPages: number): string {
Expand Down
14 changes: 14 additions & 0 deletions docs/doc-collection/basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ output/docs/

`state-diagram.mmd` is the same state-transition map as a standalone Mermaid file (no markdown fences), so other agents can embed or post-process it without re-rendering `index.md`.

## Use the spec in Explorbot

Pass the generated directory or its `index.md` to any Explorbot web command:

```bash
npx explorbot start / --spec output/docs
npx explorbot plan /admin/users --spec output/docs
npx explorbot explore / --spec output/docs
```

You can also set `dirs.spec: 'output/docs'` in `explorbot.config.js` to use it by default. Explorbot selects documentation for the current URL instead of loading the whole site spec into every prompt. Proven capabilities and observed transitions are supporting context; possible capabilities remain explicitly unverified until the live UI confirms them.

The format is not tied to Docbot. See [Application Specs](../workflow/application-spec.md) to create or generate a compatible bundle with another tool.

Each page file follows the same shape:

```markdown
Expand Down
1 change: 1 addition & 0 deletions docs/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"description": "These pages apply to web and API testing alike",
"pages": [
{ "title": "Knowledge", "file": "workflow/knowledge.md", "description": "Teach Explorbot about your app" },
{ "title": "Application Specs", "file": "workflow/application-spec.md", "description": "Reuse versioned application documentation" },
{ "title": "Test plans", "file": "workflow/test-plans.md", "description": "The plan file format and how plans are reused" },
{ "title": "Planning styles", "file": "workflow/planning-styles.md", "description": "Normal, curious, psycho, and your own" },
{ "title": "Reporting", "file": "workflow/reporting.md", "description": "Local reports and Testomat.io" },
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ Change the paths:

```javascript
dirs: {
spec: './test/spec',
knowledge: './test/knowledge',
experience: './test/experience',
output: './test/output',
Expand Down Expand Up @@ -504,6 +505,7 @@ export default {

// Directory paths
dirs: {
spec: 'spec', // Application specification bundle
knowledge: 'knowledge', // Domain knowledge files
experience: 'experience', // Learned patterns
output: 'output', // Test results and logs
Expand All @@ -526,4 +528,5 @@ export default {
- [Researcher agent](../web-testing/researcher.md) — Researcher configuration and usage
- [Planner agent](../web-testing/planner.md) — planning styles and customization
- [Knowledge files](../workflow/knowledge.md) — domain knowledge format
- [Application specs](../workflow/application-spec.md) — reusable application documentation format
- [Observability](../contributing/observability.md) — Langfuse integration
73 changes: 73 additions & 0 deletions docs/workflow/application-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Application Specs

An application spec is a versioned Markdown bundle that gives Explorbot previously collected information about an application. It can be produced by Docbot, another documentation tool, or by hand.

Live HTML, ARIA, and screenshots remain the source of truth. Explorbot uses matching spec pages as supporting context and does not load the whole bundle into every prompt.

## Configure

Set the bundle directory in `explorbot.config.js`:

```javascript
export default {
dirs: {
spec: 'spec',
},
};
```

Paths are resolved from the project directory. Use `--spec <path>` on a web command to override the configured bundle for one run. Both the bundle directory and its `index.md` path are accepted.

## Bundle structure

```text
spec/
|-- index.md
`-- pages/
|-- home.md
`-- users.md
```

`index.md` is required and serves as a human-readable entry point. Its contents are not injected into agents. Page files may be nested anywhere below `pages/` and must use the contract below.

## Page contract

Every page is a Markdown file with YAML front matter:

```markdown
---
format: explorbot-application-spec
version: 1
url: /users
---

# Users

## Purpose

Lists the application's users.

## User Can

- user can search users by name
Proof: A search field is visible above the user list.

## User Might

- user might export the user list
Signal: An unlabeled download control is present.
```

The front matter fields are mandatory:

- `format` must be `explorbot-application-spec`.
- `version` must be `1`.
- `url` is the URL pattern used to select the page for the current browser state. It supports the same patterns as [knowledge files](./knowledge.md#url-patterns).

The Markdown body is supplied to agents as written, so headings beyond those shown above are allowed. Use `User Can` only for observed capabilities and transitions. Put inferred or unverified capabilities under `User Might`; Explorbot will require confirmation from the live UI before relying on them.

Screenshots and other relative links may be included for readers, but Explorbot currently consumes the Markdown text only.

## Validation

Explorbot rejects a bundle when `index.md` or `pages/` is missing, when it contains no page files, or when a page has an unsupported format, version, or missing URL.
12 changes: 6 additions & 6 deletions src/ai/navigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { HooksRunner } from '../utils/hooks-runner.ts';
import { createDebug, pluralize, tag } from '../utils/logger.js';
import { loop, pause } from '../utils/loop.js';
import { RulesLoader } from '../utils/rules-loader.ts';
import { extractStatePath } from '../utils/url-matcher.js';
import { extractStatePath, matchesNavigationUrl } from '../utils/url-matcher.js';
import type { Agent, AgentDeps } from './agent.js';
import type { Conversation } from './conversation.js';
import type { Provider } from './provider.js';
Expand Down Expand Up @@ -134,7 +134,7 @@ class Navigator implements Agent {
return false;
}
const currentUrl = this.getComparableCurrentUrl(stateManager, expectedUrl);
return normalizeUrl(currentUrl) === normalizeUrl(expectedUrl);
return matchesNavigationUrl(expectedUrl, currentUrl);
}

async visit(url: string): Promise<void> {
Expand Down Expand Up @@ -196,7 +196,7 @@ class Navigator implements Agent {
const action = opts?.action ?? this.explorer.action();
const expectedUrl = opts?.expectedUrl;

const knowledge = this.knowledgeTracker.renderRelevantKnowledge(actionResult);
const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
let experience = '';

if (!actionResult.isInsideIframe) {
Expand Down Expand Up @@ -373,14 +373,14 @@ class Navigator implements Agent {
if (expectedUrl) {
if (page) {
try {
await page.waitForURL((url: URL) => normalizeUrl(url.pathname) === normalizeUrl(expectedUrl), { timeout: 5000 });
await page.waitForURL((url: URL) => matchesNavigationUrl(expectedUrl, `${url.pathname}${url.search}${url.hash}`), { timeout: 5000 });
} catch {
// URL did not transition to expectedUrl within timeout
}
}
const freshState = await this.explorer.capture();
const currentUrl = /^https?:\/\//i.test(expectedUrl) ? freshState.fullUrl || freshState.url || '' : freshState.url || '';
const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && normalizeUrl(currentUrl) === normalizeUrl(expectedUrl);
const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, currentUrl);
const stateChanged = freshState.getStateHash() !== actionResult.getStateHash();
resolved = urlMatches && stateChanged;

Expand Down Expand Up @@ -625,7 +625,7 @@ class Navigator implements Agent {
return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
}

const knowledge = this.knowledgeTracker.renderRelevantKnowledge(actionResult);
const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
let experience = '';

if (!actionResult.isInsideIframe) {
Expand Down
7 changes: 7 additions & 0 deletions src/ai/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export class Planner extends PlannerBase implements Agent {
provider: Provider;
stateManager: StateManager;
private experienceTracker: ExperienceTracker;
private knowledgeTracker: AgentDeps['knowledgeTracker'];

MIN_TASKS = 3;
MAX_TASKS = 12;
Expand All @@ -70,6 +71,7 @@ export class Planner extends PlannerBase implements Agent {
this.researcher = researcher;
this.stateManager = deps.stateManager;
this.experienceTracker = deps.stateManager.getExperienceTracker();
this.knowledgeTracker = deps.knowledgeTracker;
}

setFisherman(fisherman: Fisherman): void {
Expand Down Expand Up @@ -411,6 +413,11 @@ export class Planner extends PlannerBase implements Agent {
</page_research>
`);

const applicationContext = this.knowledgeTracker.renderApplicationSpec(state);
if (applicationContext) {
conversation.addUserText(applicationContext);
}

conversation.addUserText(dedent`
${this.buildApproach(style)}

Expand Down
2 changes: 1 addition & 1 deletion src/ai/researcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ export class Researcher extends ResearcherBase implements Agent {
if (!this.actionResult) throw new Error('actionResult is not set');

const html = await this.actionResult.combinedHtml();
const knowledge = this.knowledgeTracker.renderRelevantKnowledge(this.actionResult);
const knowledge = this.knowledgeTracker.renderRelevantContext(this.actionResult);

const ariaSnapshot = this.actionResult.getCompactARIA();

Expand Down
2 changes: 1 addition & 1 deletion src/ai/task-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export abstract class TaskAgent {
}

protected getKnowledge(actionResult: ActionResult): string {
return this.getKnowledgeTracker().renderRelevantKnowledge(actionResult);
return this.getKnowledgeTracker().renderRelevantContext(actionResult);
}

protected getExperience(actionResult: ActionResult): string {
Expand Down
15 changes: 15 additions & 0 deletions src/ai/tester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export class Tester extends TaskAgent implements Agent {
private seenUiMapUrls = new Set<string>();
private lastAnalyzedStateHash: string | null = null;
private stalledIterations = 0;
private hasSuccessfulAssertion = false;
private readonly MAX_STALLED_ITERATIONS = 3;

constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any) {
Expand Down Expand Up @@ -110,6 +111,7 @@ export class Tester extends TaskAgent implements Agent {
this.seenUiMapUrls.clear();
this.lastAnalyzedStateHash = null;
this.stalledIterations = 0;
this.hasSuccessfulAssertion = false;
this.stateManager.clearHistory();
this.resetFailureCount();
this.pilot?.reset();
Expand Down Expand Up @@ -312,9 +314,17 @@ export class Tester extends TaskAgent implements Agent {
const allToolNames = result?.toolExecutions?.map((execution: any) => execution.toolName) || [];
const successfulToolNames = result?.toolExecutions?.filter((execution: any) => execution.wasSuccessful)?.map((execution: any) => execution.toolName) || [];
const actionPerformed = !!allToolNames.find((toolName: string) => this.ACTION_TOOLS.includes(toolName));
const successfulActionPerformed = !!successfulToolNames.find((toolName: string) => this.ACTION_TOOLS.includes(toolName));
assertionPerformed = !!successfulToolNames.find((toolName: string) => this.ASSERTION_TOOLS.includes(toolName));
const wasSuccessful = result?.toolExecutions?.every((execution: any) => execution.wasSuccessful);

if (successfulActionPerformed) {
this.hasSuccessfulAssertion = false;
}
if (assertionPerformed) {
this.hasSuccessfulAssertion = true;
}

this.trackToolExecutions(result?.toolExecutions || []);

if (this.consecutiveEmptyResults >= 5) {
Expand Down Expand Up @@ -464,6 +474,11 @@ export class Tester extends TaskAgent implements Agent {
this.stalledIterations++;
if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;

if (this.hasSuccessfulAssertion) {
task.addNote('No further browser progress after successful verification; requesting final review');
return true;
}

task.addNote('No browser progress after repeated attempts on unchanged page', TestResult.FAILED);
task.finish(TestResult.FAILED);
return true;
Expand Down
10 changes: 10 additions & 0 deletions src/application-spec-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { z } from 'zod';

export const APPLICATION_SPEC_FORMAT = 'explorbot-application-spec';
export const APPLICATION_SPEC_VERSION = 1;
Comment thread
DenysKuchma marked this conversation as resolved.

export const APPLICATION_SPEC_PAGE_SCHEMA = z.object({
format: z.literal(APPLICATION_SPEC_FORMAT),
version: z.literal(APPLICATION_SPEC_VERSION),
url: z.string().trim().min(1),
});
Loading
Loading