explorbot 0.5.8 → 0.5.10

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.
Files changed (83) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +25 -4
  3. package/boat/api-tester/src/commands/init-command.ts +2 -0
  4. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -1
  5. package/boat/prima/README.md +3 -0
  6. package/boat/prima/bin/prima-cli.ts +2 -1
  7. package/boat/prima/src/cli.ts +7 -1
  8. package/boat/prima/src/envelope.ts +18 -4
  9. package/boat/prima/src/help.ts +15 -4
  10. package/boat/prima/src/prima.ts +181 -25
  11. package/dist/bin/explorbot-cli.js +31 -4
  12. package/dist/boat/api-tester/bin/apibot-cli.js +2 -1
  13. package/dist/boat/api-tester/src/commands/init-command.js +2 -0
  14. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -1
  15. package/dist/boat/prima/bin/prima-cli.js +2 -1
  16. package/dist/boat/prima/src/cli.js +6 -1
  17. package/dist/boat/prima/src/envelope.js +16 -3
  18. package/dist/boat/prima/src/help.js +14 -4
  19. package/dist/boat/prima/src/prima.js +191 -27
  20. package/dist/package.json +1 -1
  21. package/dist/rules/navigator/verification-actions.md +5 -3
  22. package/dist/rules/planner/ignored-pages.md +6 -0
  23. package/dist/rules/quartermaster/ux/accessibility.md +8 -0
  24. package/dist/rules/quartermaster/ux/charts.md +6 -0
  25. package/dist/rules/quartermaster/ux/copy.md +9 -0
  26. package/dist/rules/quartermaster/ux/forms.md +7 -0
  27. package/dist/rules/quartermaster/ux/interaction.md +7 -0
  28. package/dist/rules/quartermaster/ux/motion.md +5 -0
  29. package/dist/rules/quartermaster/ux/navigation.md +7 -0
  30. package/dist/rules/quartermaster/ux/readability.md +6 -0
  31. package/dist/rules/quartermaster/ux/responsive.md +7 -0
  32. package/dist/src/ai/judge.d.ts +3 -1
  33. package/dist/src/ai/judge.js +13 -3
  34. package/dist/src/ai/navigator.js +3 -2
  35. package/dist/src/ai/pilot.js +10 -4
  36. package/dist/src/ai/planner.js +9 -1
  37. package/dist/src/ai/provider.d.ts +4 -0
  38. package/dist/src/ai/provider.js +27 -5
  39. package/dist/src/ai/quartermaster.d.ts +31 -3
  40. package/dist/src/ai/quartermaster.js +262 -17
  41. package/dist/src/ai/tester.js +6 -3
  42. package/dist/src/commands/init-command.js +4 -0
  43. package/dist/src/commands/options/auth-option.d.ts +6 -0
  44. package/dist/src/commands/options/auth-option.js +14 -0
  45. package/dist/src/commands/options/index.d.ts +2 -0
  46. package/dist/src/commands/options/index.js +2 -0
  47. package/dist/src/config.js +1 -0
  48. package/dist/src/explorbot.d.ts +1 -1
  49. package/dist/src/explorbot.js +8 -13
  50. package/dist/src/explorer.js +1 -0
  51. package/dist/src/global-config.js +6 -2
  52. package/dist/src/utils/ux-probe.d.ts +25 -0
  53. package/dist/src/utils/ux-probe.js +155 -0
  54. package/docs/reference/commands.md +12 -0
  55. package/docs/workflow/agentic-usage.md +7 -0
  56. package/docs/workflow/knowledge.md +6 -0
  57. package/package.json +1 -1
  58. package/rules/navigator/verification-actions.md +5 -3
  59. package/rules/planner/ignored-pages.md +6 -0
  60. package/rules/quartermaster/ux/accessibility.md +8 -0
  61. package/rules/quartermaster/ux/charts.md +6 -0
  62. package/rules/quartermaster/ux/copy.md +9 -0
  63. package/rules/quartermaster/ux/forms.md +7 -0
  64. package/rules/quartermaster/ux/interaction.md +7 -0
  65. package/rules/quartermaster/ux/motion.md +5 -0
  66. package/rules/quartermaster/ux/navigation.md +7 -0
  67. package/rules/quartermaster/ux/readability.md +6 -0
  68. package/rules/quartermaster/ux/responsive.md +7 -0
  69. package/src/ai/judge.ts +11 -3
  70. package/src/ai/navigator.ts +2 -2
  71. package/src/ai/pilot.ts +10 -4
  72. package/src/ai/planner.ts +10 -1
  73. package/src/ai/provider.ts +29 -6
  74. package/src/ai/quartermaster.ts +300 -18
  75. package/src/ai/tester.ts +5 -3
  76. package/src/commands/init-command.ts +5 -0
  77. package/src/commands/options/auth-option.ts +15 -0
  78. package/src/commands/options/index.ts +2 -0
  79. package/src/config.ts +1 -0
  80. package/src/explorbot.ts +9 -13
  81. package/src/explorer.ts +1 -0
  82. package/src/global-config.ts +5 -2
  83. package/src/utils/ux-probe.ts +163 -0
@@ -1,36 +1,53 @@
1
1
  import { mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import dedent from 'dedent';
4
+ import type { Locator, Page } from 'playwright';
3
5
  import { z } from 'zod';
4
6
  import type { ActionResult } from '../action-result.ts';
5
7
  import { outputPath } from '../config.ts';
6
8
  import type Explorer from '../explorer.ts';
7
9
  import type { StateManager, StateTransition, WebPageState } from '../state-manager.ts';
8
10
  import type { Task } from '../test-plan.ts';
11
+ import { ariaRefSelector, ariaRefSnapshot, parseAriaRefs } from '../utils/aria-ref.ts';
12
+ import { compactAriaSnapshot } from '../utils/aria.ts';
9
13
  import { createDebug, tag } from '../utils/logger.ts';
14
+ import { RulesLoader } from '../utils/rules-loader.ts';
10
15
  import { isCodeceptToolName } from '../utils/step-analyzer.ts';
16
+ import { UX_FOCUS_ATTR, type UxMeasurements, countRunningAnimations, measureUx, renderUxMeasurements, uxProbeArgs } from '../utils/ux-probe.ts';
11
17
  import type { Conversation, ToolExecution } from './conversation.ts';
12
18
  import type { Provider } from './provider.ts';
13
19
 
14
20
  const debugLog = createDebug('explorbot:quartermaster');
15
21
 
22
+ const UX_RULES = ['ux/accessibility', 'ux/interaction', 'ux/responsive', 'ux/navigation', 'ux/copy', 'ux/forms', 'ux/readability', 'ux/motion', 'ux/charts'];
23
+ const UX_BREAKPOINTS = [768, 375];
24
+ const UX_FOCUS_SELECTOR = `[${UX_FOCUS_ATTR}]`;
25
+ const UX_SETTLE_MS = 400;
26
+ const UX_ARIA_CAP = 8000;
27
+ const UX_SCREENSHOT_TIMEOUT = 5000;
28
+ const UX_MAX_FINDINGS = 6;
29
+ const AGENT_NAME = 'quartermaster';
30
+ const FOCUS_CONTAINER_ROLES = ['main', 'region', 'form', 'dialog', 'alertdialog', 'navigation', 'complementary', 'article', 'table', 'grid', 'tabpanel', 'list', 'group', 'banner', 'contentinfo', 'search'];
31
+
16
32
  export class Quartermaster {
17
33
  private provider: Provider;
18
34
  private model?: any;
19
35
  private outputDir: string;
20
36
  private pageAnalyses: Map<string, PageAnalysis> = new Map();
21
37
  private unsubscribe: (() => void) | null = null;
22
- private explorer: Explorer | null = null;
38
+ private explorer: Explorer;
23
39
  private pendingAnalyses: Promise<void>[] = [];
24
40
 
25
- constructor(provider: Provider, options?: { model?: any }) {
41
+ constructor(provider: Provider, explorer: Explorer, options?: { model?: any }) {
26
42
  this.provider = provider;
43
+ this.explorer = explorer;
27
44
  this.model = options?.model;
28
45
 
29
46
  this.outputDir = outputPath('a11y');
30
47
  }
31
48
 
32
- start(explorer: Explorer, stateManager: StateManager): void {
33
- this.explorer = explorer;
49
+ start(stateManager: StateManager): void {
50
+ if (this.unsubscribe) return;
34
51
  mkdirSync(this.outputDir, { recursive: true });
35
52
 
36
53
  this.unsubscribe = stateManager.onStateChange((event) => {
@@ -61,26 +78,14 @@ export class Quartermaster {
61
78
  }
62
79
 
63
80
  private async runAxeAnalysis(state: WebPageState): Promise<void> {
64
- const page = this.explorer?.page;
81
+ const page = this.explorer.page;
65
82
  if (!page || !state.hash) {
66
83
  debugLog('No page available for axe analysis');
67
84
  return;
68
85
  }
69
86
 
70
87
  try {
71
- const { AxeBuilder } = await import('@axe-core/playwright');
72
- const results = await new AxeBuilder({ page }).analyze();
73
-
74
- const violations: AxeViolation[] = results.violations.map((v) => ({
75
- id: v.id,
76
- impact: v.impact as AxeViolation['impact'],
77
- description: v.description,
78
- helpUrl: v.helpUrl,
79
- nodes: v.nodes.map((n) => ({
80
- html: n.html,
81
- target: n.target as string[],
82
- })),
83
- }));
88
+ const violations = await this.axeViolations(page);
84
89
 
85
90
  this.pageAnalyses.set(state.hash, {
86
91
  url: state.url,
@@ -138,6 +143,24 @@ export class Quartermaster {
138
143
  }
139
144
  }
140
145
 
146
+ async auditUx(result: ActionResult, context: UxAuditContext): Promise<string> {
147
+ const focus = await this.markUxFocus(result, context);
148
+ tag('step').log(`Auditing UX of ${focus.area}`);
149
+
150
+ try {
151
+ const evidence = await this.explorer.withPage((page) => this.collectUxEvidence(page));
152
+ return await this.writeUxReport(result, focus, evidence, context);
153
+ } finally {
154
+ await this.explorer
155
+ .withPage((page) =>
156
+ page.evaluate((attr) => {
157
+ for (const el of Array.from(document.querySelectorAll(`[${attr}]`))) el.removeAttribute(attr);
158
+ }, UX_FOCUS_ATTR)
159
+ )
160
+ .catch(() => {});
161
+ }
162
+ }
163
+
141
164
  private async generateSemanticAnalysis(axeViolations: AxeViolation[], executions: ToolExecution[], initialState: ActionResult): Promise<SemanticIssue[]> {
142
165
  const failedExecs = executions.filter((e) => !e.wasSuccessful);
143
166
  if (failedExecs.length === 0 && axeViolations.length === 0) return [];
@@ -247,6 +270,265 @@ Focus on what would confuse a real user or caused the agent to make mistakes.`;
247
270
 
248
271
  return content;
249
272
  }
273
+
274
+ private async axeViolations(page: Page, include?: string): Promise<AxeViolation[]> {
275
+ const { AxeBuilder } = await import('@axe-core/playwright');
276
+ let builder = new AxeBuilder({ page });
277
+ if (include) builder = builder.include(include);
278
+ const results = await builder.analyze();
279
+
280
+ return results.violations.map((v) => ({
281
+ id: v.id,
282
+ impact: v.impact as AxeViolation['impact'],
283
+ description: v.description,
284
+ helpUrl: v.helpUrl,
285
+ nodes: v.nodes.map((n) => ({
286
+ html: n.html,
287
+ target: n.target as string[],
288
+ })),
289
+ }));
290
+ }
291
+
292
+ private async markUxFocus(result: ActionResult, context: UxAuditContext): Promise<UxFocus> {
293
+ const mark = (locator: Locator) =>
294
+ locator
295
+ .first()
296
+ .evaluate((el, attr) => el.setAttribute(attr, ''), UX_FOCUS_ATTR)
297
+ .then(() => true)
298
+ .catch(() => false);
299
+
300
+ const region = result.overlay;
301
+ const locateRegion = (page: Page) => {
302
+ if (region.root) return page.locator(region.root);
303
+ if (region.xpath) return page.locator(`xpath=${region.xpath}`);
304
+ const name = region.name || undefined;
305
+ return page.getByRole('dialog', { name }).or(page.getByRole('alertdialog', { name }));
306
+ };
307
+ const regionFocus = { area: region.name || `open ${region.type}`, reason: `it is the ${region.type} open on the page` };
308
+
309
+ if (!context.focus && region.isOpen && (await this.explorer.withPage((page) => mark(locateRegion(page))))) return regionFocus;
310
+
311
+ if (context.focus || context.commands.length) {
312
+ const picked = await this.pickUxFocus(context, mark);
313
+ if (picked) return picked;
314
+ }
315
+
316
+ if (region.isOpen && (await this.explorer.withPage((page) => mark(locateRegion(page))))) return regionFocus;
317
+
318
+ let reason = 'nothing narrower is open or was touched by the session on this page';
319
+ if (context.focus) reason = `"${context.focus}" matched no single container`;
320
+ return this.explorer.withPage(async (page) => {
321
+ if (await mark(page.locator('main, [role="main"]'))) return { area: 'main content', reason };
322
+ await mark(page.locator('body'));
323
+ return { area: 'whole page', reason: `${reason} and the page has no main landmark` };
324
+ });
325
+ }
326
+
327
+ private async pickUxFocus(context: UxAuditContext, mark: (locator: Locator) => Promise<boolean>): Promise<UxFocus | null> {
328
+ const snapshot = await this.explorer.withPage(ariaRefSnapshot).catch(() => '');
329
+ const candidates = parseAriaRefs(snapshot).filter((entry) => FOCUS_CONTAINER_ROLES.includes(entry.role));
330
+ if (!candidates.length) return null;
331
+
332
+ const hints: string[] = [];
333
+ if (context.focus) hints.push(`The developer asked to review: ${context.focus}`);
334
+ if (context.commands.length) hints.push('Commands the session ran on this page, latest last:', ...context.commands.map((command) => `- ${command}`));
335
+
336
+ const schema = z.object({
337
+ ref: z.enum(['none', ...candidates.map((entry) => entry.ref)]).describe('ref of the chosen container, or none'),
338
+ area: z.string().describe('the area named as a user would call it, a few words'),
339
+ });
340
+
341
+ const prompt = dedent`
342
+ Pick the page area a developer is working on, so a UX review covers it and nothing else.
343
+ Choose the smallest container that holds everything the hints point at.
344
+ Answer none when no listed container fits the hints.
345
+
346
+ <hints>
347
+ ${hints.join('\n')}
348
+ </hints>
349
+
350
+ <containers>
351
+ ${candidates.map((entry) => `- [${entry.ref}] ${entry.role} "${entry.name}"`).join('\n')}
352
+ </containers>
353
+
354
+ <page_aria>
355
+ ${snapshot.slice(0, UX_ARIA_CAP)}
356
+ </page_aria>
357
+ `;
358
+
359
+ const response = await this.provider.generateObject([{ role: 'user', content: prompt }], schema, this.model, { agentName: AGENT_NAME }).catch(() => null);
360
+ const ref = response?.object?.ref;
361
+ if (!ref || ref === 'none') return null;
362
+ if (!(await this.explorer.withPage((page) => mark(page.locator(ariaRefSelector(ref)))))) return null;
363
+
364
+ let reason = 'the session commands on this page act inside it';
365
+ if (context.focus) reason = `it holds "${context.focus}"`;
366
+ return { area: response.object.area, reason };
367
+ }
368
+
369
+ private async collectUxEvidence(page: Page): Promise<UxEvidence> {
370
+ const evidence: UxEvidence = { breakpoints: [], axe: [], notes: [] };
371
+ evidence.axe = await this.axeViolations(page, UX_FOCUS_SELECTOR).catch((error) => {
372
+ evidence.notes.push(`axe could not run: ${error.message}`);
373
+ return [];
374
+ });
375
+
376
+ const ignoredMotion = await this.animationsIgnoringReducedMotion(page);
377
+ if (ignoredMotion) evidence.notes.push(`${ignoredMotion} animations in the area keep running when reduced motion is requested`);
378
+
379
+ const width = await page.evaluate(() => window.innerWidth);
380
+ evidence.breakpoints.push(await this.captureUxBreakpoint(page));
381
+
382
+ const narrower = UX_BREAKPOINTS.filter((breakpoint) => breakpoint < width);
383
+ if (!narrower.length) return evidence;
384
+
385
+ const viewport = page.viewportSize();
386
+ if (!viewport) {
387
+ evidence.notes.push('the browser window has no fixed viewport, so narrower widths were not measured');
388
+ return evidence;
389
+ }
390
+
391
+ try {
392
+ for (const breakpoint of narrower) {
393
+ await page.setViewportSize({ width: breakpoint, height: viewport.height });
394
+ await page.waitForTimeout(UX_SETTLE_MS);
395
+ if ((await page.evaluate(() => window.innerWidth)) !== breakpoint) {
396
+ evidence.notes.push(`resizing to ${breakpoint}px had no effect, so that width was not measured`);
397
+ continue;
398
+ }
399
+ evidence.breakpoints.push(await this.captureUxBreakpoint(page));
400
+ }
401
+ } finally {
402
+ await page.setViewportSize(viewport);
403
+ }
404
+
405
+ return evidence;
406
+ }
407
+
408
+ private async animationsIgnoringReducedMotion(page: Page): Promise<number> {
409
+ if (!(await page.evaluate(countRunningAnimations, UX_FOCUS_SELECTOR))) return 0;
410
+
411
+ try {
412
+ await page.emulateMedia({ reducedMotion: 'reduce' });
413
+ await page.waitForTimeout(UX_SETTLE_MS);
414
+ return await page.evaluate(countRunningAnimations, UX_FOCUS_SELECTOR);
415
+ } finally {
416
+ await page.emulateMedia({ reducedMotion: null });
417
+ }
418
+ }
419
+
420
+ private async captureUxBreakpoint(page: Page): Promise<UxBreakpoint> {
421
+ const focus = page.locator(UX_FOCUS_SELECTOR).first();
422
+ const measurements = await page.evaluate(measureUx, uxProbeArgs(UX_FOCUS_SELECTOR));
423
+ const aria = await focus.ariaSnapshot().catch(() => '');
424
+
425
+ const breakpoint: UxBreakpoint = { measurements, aria: compactAriaSnapshot(aria, true).slice(0, UX_ARIA_CAP) };
426
+ if (!this.provider.hasVision()) return breakpoint;
427
+
428
+ const screenshot = await focus.screenshot({ timeout: UX_SCREENSHOT_TIMEOUT }).catch(() => null);
429
+ if (screenshot) breakpoint.screenshot = screenshot.toString('base64');
430
+ return breakpoint;
431
+ }
432
+
433
+ private async writeUxReport(result: ActionResult, focus: UxFocus, evidence: UxEvidence, context: UxAuditContext): Promise<string> {
434
+ const url = result.url || '';
435
+ const rules = [RulesLoader.loadRules(AGENT_NAME, UX_RULES, url), this.provider.getSystemPromptForAgent(AGENT_NAME, url)].filter(Boolean).join('\n\n');
436
+
437
+ const system = dedent`
438
+ <role>
439
+ You review the UX of one area of a web page a developer is building right now.
440
+ The report goes back to that developer, so every finding must point at something they can find in that area.
441
+ </role>
442
+
443
+ <focus>
444
+ Area under review: ${focus.area}, chosen because ${focus.reason}.
445
+ Report only on elements inside it. Page-wide header, navigation and footer are out of scope unless they are the area under review.
446
+ </focus>
447
+
448
+ <scope>
449
+ You see one page of one app, not the app.
450
+ - Report that a text fails and why. Never propose replacement wording, terminology, tone or language: the app's vocabulary is unknown to you.
451
+ - Never recommend colors, fonts, spacing values, design tokens or visual style: the project owns its design system.
452
+ - Sizes, gaps, overflow and contrast come only from <measurements> and <axe>. Never estimate them from a screenshot.
453
+ - Report only what the evidence shows. A check the evidence cannot settle is skipped, not guessed.
454
+ </scope>
455
+
456
+ <rules>
457
+ Each rule set carries the priority its findings are reported under.
458
+
459
+ ${rules}
460
+ </rules>
461
+
462
+ <output>
463
+ Markdown, no preamble.
464
+
465
+ ## Focus
466
+ One line: the area reviewed and the widths covered.
467
+
468
+ Then one section per priority that has findings, in this order: ## Critical, ## High, ## Medium, ## Low. Omit empty sections.
469
+ Each finding is one bullet of one sentence, at most 30 words:
470
+ - **<element as the user sees it>** (<rule set>, <width or "all widths">): <evidence>, <what it costs the user>.
471
+
472
+ One bullet per cause: elements failing the same check share a bullet naming at most three of them.
473
+ Write at most ${UX_MAX_FINDINGS} bullets in total across all sections, most severe first; drop the rest.
474
+ No fix wording, no rule ids, no list of skipped checks, no summary, no tables.
475
+ When nothing fails, write the Focus section and the line: No UX issues found in this area.
476
+ </output>
477
+ `;
478
+
479
+ const sections = [`<page url="${url}" title="${result.title || ''}"></page>`];
480
+ if (context.commands.length) sections.push(`<session_commands>\n${context.commands.join('\n')}\n</session_commands>`);
481
+ sections.push(`<axe>\n${evidence.axe.map((v) => `[${v.impact}] ${v.id}: ${v.description} — ${v.nodes[0]?.html.slice(0, 100) || ''}`).join('\n') || 'no violations'}\n</axe>`);
482
+ if (evidence.notes.length) sections.push(`<notes>\n${evidence.notes.join('\n')}\n</notes>`);
483
+ for (const breakpoint of evidence.breakpoints) {
484
+ const aria = breakpoint.aria || 'the area is not rendered at this width';
485
+ sections.push(`<breakpoint width="${breakpoint.measurements.width}">\n<measurements>\n${renderUxMeasurements(breakpoint.measurements)}\n</measurements>\n<aria>\n${aria}\n</aria>\n</breakpoint>`);
486
+ }
487
+
488
+ const shots = evidence.breakpoints.filter((breakpoint) => breakpoint.screenshot);
489
+ if (!shots.length) sections.push('No screenshots are attached: judge from ARIA and measurements only, and say in Focus that visual checks were not possible.');
490
+
491
+ const content: any[] = [{ type: 'text', text: sections.join('\n\n') }];
492
+ for (const shot of shots) {
493
+ content.push({ type: 'text', text: `Screenshot of the area at ${shot.measurements.width}px:` });
494
+ content.push({ type: 'file', mediaType: 'image/png', data: shot.screenshot });
495
+ }
496
+
497
+ let model = this.model || this.provider.getModelForAgent(AGENT_NAME);
498
+ if (shots.length) model = this.provider.getVisionModel();
499
+
500
+ const response = await this.provider.chat(
501
+ [
502
+ { role: 'system', content: system },
503
+ { role: 'user', content },
504
+ ],
505
+ model,
506
+ { agentName: AGENT_NAME }
507
+ );
508
+ return response.text;
509
+ }
510
+ }
511
+
512
+ export interface UxAuditContext {
513
+ focus?: string;
514
+ commands: string[];
515
+ }
516
+
517
+ interface UxFocus {
518
+ area: string;
519
+ reason: string;
520
+ }
521
+
522
+ interface UxBreakpoint {
523
+ measurements: UxMeasurements;
524
+ aria: string;
525
+ screenshot?: string;
526
+ }
527
+
528
+ interface UxEvidence {
529
+ breakpoints: UxBreakpoint[];
530
+ axe: AxeViolation[];
531
+ notes: string[];
250
532
  }
251
533
 
252
534
  interface AxeViolation {
package/src/ai/tester.ts CHANGED
@@ -366,8 +366,9 @@ export class Tester extends TaskAgent implements Agent {
366
366
  }
367
367
 
368
368
  if (actionPerformed && !wasSuccessful) {
369
+ const noted = new Set(Object.values(task.notes).map((note) => note.message));
369
370
  result?.toolExecutions
370
- ?.filter((execution: any) => !execution.wasSuccessful && execution.input?.explanation)
371
+ ?.filter((execution: any) => !execution.wasSuccessful && execution.input?.explanation && !noted.has(execution.input.explanation))
371
372
  .forEach((execution: any) => {
372
373
  task.addNote(`Failed to ${execution.input.explanation} (${execution.toolName})`, TestResult.FAILED);
373
374
  });
@@ -379,8 +380,9 @@ export class Tester extends TaskAgent implements Agent {
379
380
  }
380
381
 
381
382
  if (assertionPerformed) {
382
- const message = result?.toolExecutions?.find((execution: any) => execution.toolName === 'verify')?.output?.message || '';
383
- task.addNote(message, wasSuccessful ? TestResult.PASSED : TestResult.FAILED);
383
+ const passedVerifications = result?.toolExecutions?.filter((execution: any) => this.ASSERTION_TOOLS.includes(execution.toolName) && execution.wasSuccessful) || [];
384
+ for (const execution of passedVerifications) task.addNote(execution.output?.message || '', TestResult.PASSED);
385
+ const message = passedVerifications.at(-1)?.output?.message || '';
384
386
  if (wasSuccessful) {
385
387
  conversation.addUserText(dedent`
386
388
  Assertion "${message}" successfully passed!
@@ -327,6 +327,11 @@ function printInitNextSteps(options: { url?: string; siteCommands?: NextStepComm
327
327
 
328
328
  if (options.siteCommands) sections.push({ label: 'Explore any site', commands: options.siteCommands });
329
329
 
330
+ sections.push({
331
+ label: 'Log in for one run (kept in memory, never saved)',
332
+ commands: [{ command: `${cli} explore /dashboard --auth 'login: admin@example.com password: secret123'` }],
333
+ });
334
+
330
335
  sections.push(
331
336
  {
332
337
  label: 'Run it from VS Code',
@@ -0,0 +1,15 @@
1
+ import { KnowledgeTracker } from '../../knowledge-tracker.js';
2
+ import { registerSecret } from '../../utils/secrets.js';
3
+ import { BaseOption } from './base-option.js';
4
+
5
+ export class AuthOption extends BaseOption {
6
+ flags = '--auth <text>';
7
+ description = 'Auth details for this run only, not saved to disk, in free form (e.g. "login: admin password: secret" or "Bearer: <token>"). Falls back to EXPLORBOT_AUTH';
8
+
9
+ protected apply(options: Record<string, any>): void {
10
+ const auth = options.auth || process.env.EXPLORBOT_AUTH;
11
+ if (!auth) return;
12
+ registerSecret(auth);
13
+ KnowledgeTracker.appendSessionKnowledge(auth);
14
+ }
15
+ }
@@ -1,9 +1,11 @@
1
+ import { AuthOption } from './auth-option.js';
1
2
  import { DecisionModelOption } from './decision-model-option.js';
2
3
  import { KnowledgeOption } from './knowledge-option.js';
3
4
  import { WsOption } from './ws-option.js';
4
5
 
5
6
  export { BaseOption } from './base-option.js';
6
7
 
8
+ export const authOption = new AuthOption();
7
9
  export const decisionModelOption = new DecisionModelOption();
8
10
  export const knowledgeOption = new KnowledgeOption();
9
11
  export const wsOption = new WsOption();
package/src/config.ts CHANGED
@@ -284,6 +284,7 @@ export const EXPLORBOT_ENV_VARS: EnvVar[] = [
284
284
  { name: 'EXPLORBOT_EPHEMERAL', description: 'Keep no state between runs — output goes to a fresh temp directory instead of the site dir' },
285
285
  { name: 'EXPLORBOT_KNOWLEDGE', description: 'Inline knowledge text, applied to every page' },
286
286
  { name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
287
+ { name: 'EXPLORBOT_AUTH', description: 'Auth details in free form (credentials, a token), kept in memory for the run and never written to disk; same as --auth', secret: true },
287
288
  { name: 'EXPLORBOT_SPEC', description: 'Docbot application spec directory or index.md, used as page knowledge' },
288
289
  { name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
289
290
  { name: 'EXPLORBOT_API_HEADERS', description: 'Headers sent with every API request, one "Name: value" per line', secret: true },
package/src/explorbot.ts CHANGED
@@ -137,6 +137,7 @@ export class ExplorBot {
137
137
  if (typeof this.options.session === 'string') this.options.session = path.resolve(this.options.path || '.', this.options.session);
138
138
  if (this.options.optionalAi) return this.bootstrapOptionalProvider();
139
139
  this.provider = new AIProvider(this.config.ai);
140
+ this.provider.setJudge(this.judge());
140
141
  await this.provider.validateConnection();
141
142
  }
142
143
 
@@ -266,8 +267,11 @@ export class ExplorBot {
266
267
  return new Tester(deps, researcher, navigator, tools);
267
268
  });
268
269
 
269
- const qm = this.agentQuartermaster();
270
- if (qm) this.agents.tester.setQuartermaster(qm);
270
+ if (this.config.ai?.agents?.quartermaster?.enabled === true) {
271
+ const quartermaster = this.agentQuartermaster();
272
+ quartermaster.start(this.stateManager());
273
+ this.agents.tester.setQuartermaster(quartermaster);
274
+ }
271
275
  if (this.isHistorianEnabled()) this.agents.tester.setHistorian(this.agentHistorian());
272
276
  this.agents.tester.setPilot(this.agentPilot());
273
277
  this.agents.tester.setCaptain(this.agentCaptain());
@@ -289,17 +293,8 @@ export class ExplorBot {
289
293
  return (this.agents.experienceCompactor ||= new ExperienceCompactor(this.provider, this.experienceTracker()));
290
294
  }
291
295
 
292
- agentQuartermaster(): Quartermaster | null {
293
- const config = this.config.ai?.agents?.quartermaster;
294
- if (config?.enabled !== true) return null;
295
-
296
- if (!this.agents.quartermaster) {
297
- this.agents.quartermaster = new Quartermaster(this.provider, {
298
- model: config?.model,
299
- });
300
- this.agents.quartermaster.start(this.explorer, this.stateManager());
301
- }
302
- return this.agents.quartermaster;
296
+ agentQuartermaster(): Quartermaster {
297
+ return (this.agents.quartermaster ||= new Quartermaster(this.provider, this.explorer, { model: this.config.ai?.agents?.quartermaster?.model }));
303
298
  }
304
299
 
305
300
  agentHistorian(): Historian {
@@ -571,6 +566,7 @@ export class ExplorBot {
571
566
  private async bootstrapOptionalProvider(): Promise<void> {
572
567
  try {
573
568
  const provider = new AIProvider(this.config.ai);
569
+ provider.setJudge(this.judge());
574
570
  await provider.validateConnection();
575
571
  this.provider = provider;
576
572
  } catch (error) {
package/src/explorer.ts CHANGED
@@ -104,6 +104,7 @@ class Explorer {
104
104
  throw new Error('Playwright helper not available');
105
105
  }
106
106
  this.applyBasePathToHelper();
107
+ codeceptjs.store.visibleLocator = this.playwrightHelper.options?.visibleLocator;
107
108
  await this.connectOrLaunchBrowser();
108
109
  const hasSession = !this.options?.attachedBrowser && this.options?.session && existsSync(this.options.session);
109
110
  await this.openContextPage();
@@ -6,6 +6,7 @@ import dedent from 'dedent';
6
6
 
7
7
  const GLOBAL_CONFIG_NAMES = ['config.js', 'config.mjs', 'config.ts'];
8
8
  const SITE_DIRS = ['knowledge', 'experience', 'output'];
9
+ const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
9
10
 
10
11
  export const EXPLORBOT_CONFIG_PATHS = ['explorbot.config.js', 'explorbot.config.mjs', 'explorbot.config.ts'];
11
12
 
@@ -34,7 +35,9 @@ export function sitesDir(): string {
34
35
  }
35
36
 
36
37
  export function siteFolderName(url: string): string {
37
- return new URL(url).host.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
38
+ const parsed = new URL(url);
39
+ if (LOOPBACK_HOSTS.includes(parsed.hostname)) parsed.hostname = 'localhost';
40
+ return parsed.host.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
38
41
  }
39
42
 
40
43
  export function listSites(): SiteRecord[] {
@@ -81,7 +84,7 @@ function validateSiteConfig(config: any, configPath: string, baseUrl: string): v
81
84
  }
82
85
 
83
86
  const declared = URL.parse(url)?.origin;
84
- if (declared === baseUrl) return;
87
+ if (declared && siteFolderName(declared) === siteFolderName(baseUrl)) return;
85
88
 
86
89
  throw new Error(dedent`
87
90
  Site config declares a different site.