explorbot 0.1.20 → 0.1.22

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 (35) hide show
  1. package/bin/explorbot-cli.ts +54 -25
  2. package/boat/doc-collector/src/ai/documentarian.ts +259 -84
  3. package/boat/doc-collector/src/ai/tools.ts +544 -0
  4. package/boat/doc-collector/src/cli.ts +1 -0
  5. package/boat/doc-collector/src/config.ts +2 -0
  6. package/boat/doc-collector/src/docbot.ts +64 -5
  7. package/boat/doc-collector/src/docs-renderer.ts +56 -2
  8. package/dist/bin/explorbot-cli.js +30 -4
  9. package/dist/boat/doc-collector/src/ai/documentarian.js +220 -71
  10. package/dist/boat/doc-collector/src/ai/tools.js +415 -0
  11. package/dist/boat/doc-collector/src/cli.js +1 -0
  12. package/dist/boat/doc-collector/src/config.js +1 -0
  13. package/dist/boat/doc-collector/src/docbot.js +57 -5
  14. package/dist/boat/doc-collector/src/docs-renderer.js +46 -0
  15. package/dist/package.json +3 -3
  16. package/dist/src/action-result.js +12 -0
  17. package/dist/src/ai/navigator.js +179 -43
  18. package/dist/src/ai/pilot.js +6 -0
  19. package/dist/src/commands/index.js +2 -0
  20. package/dist/src/commands/init-command.js +9 -0
  21. package/dist/src/commands/plans-command.js +83 -0
  22. package/dist/src/commands/test-command.js +14 -8
  23. package/dist/src/playwright-recorder.js +19 -5
  24. package/dist/src/reporter.js +5 -7
  25. package/package.json +3 -3
  26. package/src/action-result.ts +11 -0
  27. package/src/ai/navigator.ts +183 -46
  28. package/src/ai/pilot.ts +6 -0
  29. package/src/commands/index.ts +2 -0
  30. package/src/commands/init-command.ts +9 -0
  31. package/src/commands/plans-command.ts +99 -0
  32. package/src/commands/test-command.ts +15 -8
  33. package/src/config.ts +2 -0
  34. package/src/playwright-recorder.ts +19 -5
  35. package/src/reporter.ts +4 -7
@@ -1,4 +1,6 @@
1
+ import { tool } from 'ai';
1
2
  import dedent from 'dedent';
3
+ import { z } from 'zod';
2
4
  import { ActionResult } from '../action-result.js';
3
5
  import { ExperienceTracker, renderExperienceToc } from '../experience-tracker.js';
4
6
  import { KnowledgeTracker } from '../knowledge-tracker.js';
@@ -68,6 +70,12 @@ class Navigator {
68
70
  this.experienceTracker = experienceTracker || new ExperienceTracker();
69
71
  this.hooksRunner = new HooksRunner(explorer, explorer.getConfig());
70
72
  }
73
+ get verifyAttempts() {
74
+ return this.explorer.getConfig().ai?.agents?.navigator?.verifyAttempts ?? 3;
75
+ }
76
+ get verifyTimeout() {
77
+ return this.explorer.getConfig().ai?.agents?.navigator?.verifyTimeout ?? 1500;
78
+ }
71
79
  getBaseOrigin() {
72
80
  const baseUrl = this.explorer.getConfig().playwright.url;
73
81
  try {
@@ -213,7 +221,27 @@ class Navigator {
213
221
  `;
214
222
  const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
215
223
  conversation.addUserText(prompt);
216
- const tools = undefined;
224
+ let stopReason = null;
225
+ const tools = {
226
+ stop: tool({
227
+ description: dedent `
228
+ Stop the navigation because no locator or strategy change can reach the goal.
229
+ Use this when reaching the goal requires something only the user can supply or that the
230
+ page cannot grant from the current state — for example: an authentication failure you
231
+ cannot guess past, a captcha or human-verification step, a permission the test cannot
232
+ satisfy, a piece of data not present in the available knowledge / hint context, or a
233
+ blocking error or dialog you cannot dismiss.
234
+ Do NOT use this for locator or strategy problems — for those, emit new code blocks instead.
235
+ `,
236
+ inputSchema: z.object({
237
+ reason: z.string().describe('Short user-facing explanation. Quote what you observed (alert text, dialog title, status message, validation note) and name what is missing or required.'),
238
+ }),
239
+ execute: async ({ reason }) => {
240
+ stopReason = reason;
241
+ return { success: true, message: 'Recorded. Navigator will stop and surface the reason.' };
242
+ },
243
+ }),
244
+ };
217
245
  let codeBlocks = [];
218
246
  let htmlContextAdded = false;
219
247
  let codeBlockIndex = 0;
@@ -226,6 +254,12 @@ class Navigator {
226
254
  const result = await this.provider.invokeConversation(conversation, tools);
227
255
  if (!result)
228
256
  return;
257
+ if (stopReason) {
258
+ tag('error').log(`Navigator stopped: ${stopReason}`);
259
+ resolved = false;
260
+ stop();
261
+ return;
262
+ }
229
263
  const aiResponse = result?.response?.text;
230
264
  debugLog('AI:', aiResponse?.split('\n')[0]);
231
265
  debugLog('Received AI response:', aiResponse?.length ?? 0, 'characters');
@@ -245,14 +279,40 @@ class Navigator {
245
279
  tag('substep').log('Feeding failures back to AI for a new batch...');
246
280
  let contextMsg = 'Previous solutions did not work. Analyze the failures and try DIFFERENT strategies (not syntactic variants of the same locator).\n\n';
247
281
  if (batchFailures.length > 0) {
248
- const lines = batchFailures.map((f) => `- \`${f.code.split('\n')[0]}\` → ${f.error}`).join('\n');
282
+ const lines = batchFailures
283
+ .map((f) => {
284
+ const head = `- \`${f.code.split('\n')[0]}\` → ${f.error}`;
285
+ if (!f.ariaChanges)
286
+ return head;
287
+ const trimmed = f.ariaChanges.split('\n').slice(0, 12).join('\n ');
288
+ return `${head}\n • ARIA changes after the action:\n ${trimmed}`;
289
+ })
290
+ .join('\n');
249
291
  contextMsg += `<previous_failures>\n${lines}\n</previous_failures>\n\n`;
250
292
  }
251
293
  if (!htmlContextAdded) {
252
294
  htmlContextAdded = true;
253
295
  contextMsg += `Full HTML context:\n\n<page_html>\n${await actionResult.combinedHtml()}\n</page_html>\n\n`;
254
296
  }
255
- contextMsg += 'Propose new solutions. If errors mention "intercepts pointer events" or timeouts on visible elements, an overlay is blocking — dismiss it first (Escape, click outside, Close button) before retrying the original action.';
297
+ const pageReacted = batchFailures.some((f) => f.ariaChanges);
298
+ if (pageReacted) {
299
+ contextMsg += dedent `
300
+ Some steps in the previous batch did not throw, but the URL did not change to the expected target and the page changed in other ways — the ARIA diff for each such step is listed in <previous_failures> above.
301
+
302
+ Read those diffs and judge what each step actually triggered. Different action types produce different reactions; the diff is your only evidence of what happened. A diff might show, for example: a new alert / alertdialog / status / validation message appearing near a field or at page level; a modal, dialog, or wizard step opening; a banner, toast, or notification region appearing; a section expanding or collapsing; a tab or accordion switching content. A diff might also be empty or unrelated to the step — that is also a signal.
303
+
304
+ Choose exactly ONE path based on what the diffs actually show — do not assume the previous step submitted any particular kind of data:
305
+
306
+ A. The diff indicates the application requires something only the user can supply — for example: an authentication failure you cannot guess past, a captcha, a permission the test cannot satisfy, or knowledge that is not present in the provided context. Call the stop() tool and quote what you saw in the diff and what is needed.
307
+
308
+ B. The diff indicates the next step is something you can perform from the existing knowledge / hint context — for example: re-emit a step with a value that exists in the knowledge but was used incorrectly; dismiss an unexpected modal; accept a confirmation; take a follow-up step the page now requires. Emit code blocks for that next step. Do NOT change the locator of a step that already produced a reaction.
309
+
310
+ C. The diff is empty or unrelated to your step — the action likely missed its target. Propose a different locator strategy.
311
+ `;
312
+ }
313
+ else {
314
+ contextMsg += 'Propose new solutions. If errors mention "intercepts pointer events" or timeouts on visible elements, an overlay is blocking — dismiss it first (Escape, click outside, Close button) before retrying the original action.';
315
+ }
256
316
  conversation.addUserText(contextMsg);
257
317
  codeBlocks = [];
258
318
  batchFailures.length = 0;
@@ -261,7 +321,8 @@ class Navigator {
261
321
  codeBlockIndex++;
262
322
  totalAttempts++;
263
323
  await this.explorer.switchToMainFrame();
264
- const prevHash = action.actionResult?.getStateHash() ?? actionResult.getStateHash();
324
+ const prevActionResult = action.actionResult ?? actionResult;
325
+ const prevHash = prevActionResult.getStateHash();
265
326
  debugLog(`Attempting resolution: ${codeBlock}`);
266
327
  const attemptOk = await action.attempt(codeBlock, message);
267
328
  const page = action.playwrightHelper?.page;
@@ -294,6 +355,23 @@ class Navigator {
294
355
  const stateChanged = freshState.getStateHash() !== actionResult.getStateHash();
295
356
  resolved = urlMatches && stateChanged;
296
357
  if (!resolved && attemptOk) {
358
+ let ariaChanges = null;
359
+ if (freshState.getStateHash() !== prevHash) {
360
+ try {
361
+ const diff = await freshState.diff(prevActionResult);
362
+ await diff.calculate();
363
+ ariaChanges = diff.ariaChanged;
364
+ }
365
+ catch (err) {
366
+ debugLog('Failed to compute pageDiff for failed URL verification:', err);
367
+ }
368
+ }
369
+ batchFailures.push({
370
+ code: codeBlock,
371
+ error: `URL did not change (still ${freshState.url})`,
372
+ ariaChanges,
373
+ urlAfter: freshState.url,
374
+ });
297
375
  tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${freshState.url}`);
298
376
  }
299
377
  if (freshState.getStateHash() !== prevHash && (attemptOk || urlMatches)) {
@@ -343,11 +421,15 @@ class Navigator {
343
421
  tag('success').log('Navigation resolved after delayed redirect');
344
422
  }
345
423
  }
346
- if (!resolved && totalAttempts > 0) {
424
+ if (!resolved && stopReason) {
425
+ tag('error').log(`Navigator stopped: ${stopReason}`);
426
+ }
427
+ else if (!resolved && totalAttempts > 0) {
347
428
  tag('error').log(`Navigation failed after ${totalAttempts} attempts`);
348
429
  }
349
430
  if (!resolved && isInteractive()) {
350
- const userInput = await pause(`Navigator failed to resolve. Current: ${action.stateManager.getCurrentState()?.url}\n` + `Target: ${expectedUrl ?? '(none)'}\nEnter CodeceptJS commands (or press Enter to skip):`);
431
+ const stopLine = stopReason ? `Navigator stopped: ${stopReason}\n` : '';
432
+ const userInput = await pause(`${stopLine}Navigator failed to resolve. Current: ${action.stateManager.getCurrentState()?.url}\n` + `Target: ${expectedUrl ?? '(none)'}\nEnter CodeceptJS commands (or press Enter to skip):`);
351
433
  if (userInput?.trim()) {
352
434
  resolved = await action.attempt(userInput, message);
353
435
  if (resolved && expectedUrl) {
@@ -489,6 +571,11 @@ class Navigator {
489
571
  async verifyState(message, actionResult) {
490
572
  tag('info').log('AI Navigator verifying state at', actionResult.url);
491
573
  debugLog('Verification message:', message);
574
+ const cachedVerification = actionResult.getVerification(message);
575
+ if (cachedVerification !== null) {
576
+ tag('substep').log(`Reusing cached verification: ${cachedVerification ? 'PASS' : 'FAIL'}`);
577
+ return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
578
+ }
492
579
  let knowledge = '';
493
580
  let experience = '';
494
581
  const relevantKnowledge = this.knowledgeTracker.getRelevantKnowledge(actionResult);
@@ -508,6 +595,20 @@ class Navigator {
508
595
  experience = renderExperienceToc(toc);
509
596
  }
510
597
  }
598
+ const priorVerifications = Object.entries(actionResult.verifications ?? {});
599
+ let verificationContext = '';
600
+ if (priorVerifications.length > 0) {
601
+ const lines = priorVerifications.map(([claim, passed]) => `- "${claim}" → ${passed ? 'passed' : 'failed'}`).join('\n');
602
+ verificationContext = dedent `
603
+ <already_verified>
604
+ These claims were already checked on this page:
605
+ ${lines}
606
+
607
+ If the claim to verify has the same meaning as one above (even if worded differently), do NOT write any assertion code.
608
+ Respond with a single line and nothing else: ALREADY_VERIFIED: <exact text of the matching claim>
609
+ </already_verified>
610
+ `;
611
+ }
511
612
  const prompt = dedent `
512
613
  <message>
513
614
  ${message}
@@ -521,11 +622,13 @@ class Navigator {
521
622
  </page_html>
522
623
  </page>
523
624
 
625
+ ${verificationContext}
626
+
524
627
  <task>
525
628
  Identify what assertion the user wants to verify on the page.
526
- Propose different CodeceptJS assertion code blocks to verify the expected state.
629
+ Propose 2-3 strong, distinct CodeceptJS assertion code blocks that each directly prove the claim.
527
630
  Use only data from the <page> context to plan the verification.
528
- Try various locators and approaches to verify the assertion.
631
+ Prefer the fewest, most specific assertions over many variants of the same locator.
529
632
 
530
633
  IMPORTANT: Each code block must verify the SPECIFIC claim in the message, not just a generic aspect of it.
531
634
  Bad: I.seeElement({"role":"button","aria-pressed":"true"}) — matches ANY button, not the specific one
@@ -544,50 +647,83 @@ class Navigator {
544
647
  tag('debug').log('Prompt:', prompt);
545
648
  const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
546
649
  conversation.addUserText(prompt);
650
+ let alreadyVerified = false;
547
651
  const tools = this.buildExperienceTools();
548
652
  let codeBlocks = [];
549
653
  const successfulCodes = [];
550
654
  const assertionSteps = [];
551
655
  const action = this.explorer.createAction();
552
- await loop(async ({ stop, iteration }) => {
553
- if (codeBlocks.length === 0) {
554
- const result = await this.provider.invokeConversation(conversation, tools);
555
- if (!result)
656
+ let failures = 0;
657
+ const page = this.explorer.playwrightHelper?.page;
658
+ const originalTimeout = this.explorer.playwrightHelper?.options?.timeout ?? 3000;
659
+ page?.setDefaultTimeout(this.verifyTimeout);
660
+ try {
661
+ await loop(async ({ stop, iteration }) => {
662
+ if (codeBlocks.length === 0) {
663
+ const result = await this.provider.invokeConversation(conversation, tools);
664
+ if (!result)
665
+ return;
666
+ const aiResponse = result?.response?.text ?? '';
667
+ debugLog('Received AI response:', aiResponse.length, 'characters');
668
+ tag('step').log('Verifying assertion...');
669
+ if (this.checkAlreadyVerified(aiResponse, actionResult)) {
670
+ alreadyVerified = true;
671
+ stop();
672
+ return;
673
+ }
674
+ codeBlocks = extractCodeBlocks(aiResponse);
675
+ }
676
+ if (codeBlocks.length === 0) {
556
677
  return;
557
- const aiResponse = result?.response?.text;
558
- debugLog('Received AI response:', aiResponse?.length ?? 0, 'characters');
559
- tag('step').log('Verifying assertion...');
560
- codeBlocks = extractCodeBlocks(aiResponse ?? '');
561
- }
562
- if (codeBlocks.length === 0) {
563
- return;
564
- }
565
- const codeBlock = codeBlocks[iteration - 1];
566
- if (!codeBlock) {
567
- stop();
568
- return;
569
- }
570
- await this.explorer.switchToMainFrame();
571
- const verified = await action.attempt(codeBlock, message, false);
572
- if (verified) {
573
- tag('success').log('Verification passed');
574
- successfulCodes.push(codeBlock);
575
- assertionSteps.push(...action.assertionSteps);
576
- }
577
- }, {
578
- maxAttempts: this.MAX_ATTEMPTS,
579
- observability: {
580
- agent: 'navigator',
581
- },
582
- catch: async (error) => {
583
- debugLog(error);
584
- },
585
- });
586
- const totalAttempted = Math.min(codeBlocks.length, this.MAX_ATTEMPTS);
587
- const verified = totalAttempted <= 1 ? successfulCodes.length > 0 : successfulCodes.length > totalAttempted / 2;
678
+ }
679
+ const codeBlock = codeBlocks[iteration - 1];
680
+ if (!codeBlock) {
681
+ stop();
682
+ return;
683
+ }
684
+ await this.explorer.switchToMainFrame();
685
+ const verified = await action.attempt(codeBlock, message, false);
686
+ if (verified) {
687
+ tag('success').log('Verification passed');
688
+ successfulCodes.push(codeBlock);
689
+ assertionSteps.push(...action.assertionSteps);
690
+ }
691
+ else {
692
+ failures++;
693
+ }
694
+ const target = Math.min(codeBlocks.length, this.verifyAttempts);
695
+ const majorityNeeded = Math.floor(target / 2) + 1;
696
+ if (successfulCodes.length >= majorityNeeded || failures > target - majorityNeeded) {
697
+ stop();
698
+ }
699
+ }, {
700
+ maxAttempts: this.verifyAttempts,
701
+ observability: {
702
+ agent: 'navigator',
703
+ },
704
+ catch: async (error) => {
705
+ debugLog(error);
706
+ },
707
+ });
708
+ }
709
+ finally {
710
+ page?.setDefaultTimeout(originalTimeout);
711
+ }
712
+ const totalAttempted = Math.min(codeBlocks.length, this.verifyAttempts);
713
+ const majorityNeeded = Math.floor(totalAttempted / 2) + 1;
714
+ let verified = successfulCodes.length >= majorityNeeded;
715
+ if (alreadyVerified)
716
+ verified = true;
588
717
  actionResult.addVerification(message, verified);
589
718
  this.explorer.getStateManager().updateState(actionResult);
590
719
  return { verified, successfulCodes, assertionSteps, totalAttempted };
591
720
  }
721
+ checkAlreadyVerified(aiResponse, actionResult) {
722
+ const verifiedMatch = aiResponse.match(/ALREADY_VERIFIED:\s*(.+)/i);
723
+ if (!verifiedMatch)
724
+ return false;
725
+ const claim = verifiedMatch[1].trim().replace(/^["']|["']$/g, '');
726
+ return actionResult.getVerification(claim) === true;
727
+ }
592
728
  }
593
729
  export { Navigator };
@@ -279,6 +279,12 @@ export class Pilot {
279
279
  - "Delete X" → X must be gone. Clicking delete is NOT enough.
280
280
  - "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.
281
281
  - Negative tests ("without a name", "invalid", "duplicate", "unauthorized") → success means the system PREVENTED the action with validation/error.
282
+ - Navigation-prefixed titles ("Access/Open/Go to X to <do Y>") → the goal is <do Y>; reaching X is
283
+ only a milestone. A satisfied milestone (tab active, panel/prompt visible, list shown) is NEVER a pass
284
+ if <do Y> did not occur this run.
285
+ - If the page reveals the goal cannot be performed here — required control absent, integration not
286
+ connected, or only a setup/connect/empty-state prompt is shown — vote "skipped" (prerequisites unmet),
287
+ never "pass".
282
288
 
283
289
  PROVENANCE: the entity you cite as proof must appear by name in <notes> or
284
290
  <session_log> tool inputs for THIS run. Name absent from tester activity = stale
@@ -24,6 +24,7 @@ import { PlanEditCommand } from './plan-edit-command.js';
24
24
  import { PlanLoadCommand } from './plan-load-command.js';
25
25
  import { PlanReloadCommand } from './plan-reload-command.js';
26
26
  import { PlanSaveCommand } from './plan-save-command.js';
27
+ import { PlansCommand } from './plans-command.js';
27
28
  import { RerunCommand } from './rerun-command.js';
28
29
  import { ResearchCommand } from './research-command.js';
29
30
  import { RunsCommand } from './runs-command.js';
@@ -42,6 +43,7 @@ const commandClasses = [
42
43
  PlanCommand,
43
44
  PlanSaveCommand,
44
45
  PlanLoadCommand,
46
+ PlansCommand,
45
47
  PlanReloadCommand,
46
48
  PlanClearCommand,
47
49
  PlanEditCommand,
@@ -29,6 +29,15 @@ const config = {
29
29
  // agentic model for decision making
30
30
  agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
31
31
  },
32
+
33
+ reporter: {
34
+ // Save a local HTML report after each run.
35
+ html: true,
36
+ // Save a local markdown report after each run.
37
+ markdown: true,
38
+ // Group runs by title in Testomat.io / HTML reports. Defaults to today's date — customize or remove.
39
+ runGroup: new Date().toISOString().slice(0, 10),
40
+ },
32
41
  };
33
42
 
34
43
  export default config;
@@ -0,0 +1,83 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Plan } from '../test-plan.js';
4
+ import { getCliName } from '../utils/cli-name.js';
5
+ import { tag } from '../utils/logger.js';
6
+ import { relativeToCwd } from '../utils/next-steps.js';
7
+ import { BaseCommand } from './base-command.js';
8
+ export class PlansCommand extends BaseCommand {
9
+ name = 'plans';
10
+ description = 'List saved plans and show their test scenarios';
11
+ options = [{ flags: '--from-plan <file>', description: 'Plan file to show' }];
12
+ async execute(args) {
13
+ const { opts, args: remaining } = this.parseArgs(args);
14
+ const files = this.getPlanFiles();
15
+ const target = String(opts.fromPlan || remaining[0] || '').trim();
16
+ if (!target) {
17
+ this.printPlans(files);
18
+ return;
19
+ }
20
+ const file = this.resolvePlanFile(target, files);
21
+ const plan = Plan.fromMarkdown(file.path);
22
+ this.printPlanDetails(plan, file);
23
+ }
24
+ getPlanFiles() {
25
+ const plansDir = this.explorBot.getPlansDir();
26
+ if (!existsSync(plansDir))
27
+ return [];
28
+ return readdirSync(plansDir)
29
+ .filter((file) => file.endsWith('.md'))
30
+ .map((file) => {
31
+ const filePath = path.join(plansDir, file);
32
+ const stat = statSync(filePath);
33
+ return {
34
+ name: file,
35
+ path: filePath,
36
+ modifiedAt: stat.mtimeMs,
37
+ };
38
+ })
39
+ .sort((left, right) => right.modifiedAt - left.modifiedAt);
40
+ }
41
+ printPlans(files) {
42
+ if (files.length === 0) {
43
+ tag('info').log(`No saved plans found in ${relativeToCwd(this.explorBot.getPlansDir())}`);
44
+ return;
45
+ }
46
+ tag('info').log('Saved plans:');
47
+ for (let i = 0; i < files.length; i++) {
48
+ const file = files[i];
49
+ const plan = Plan.fromMarkdown(file.path);
50
+ tag('info').log(`${i + 1}. ${plan.title} (${plan.tests.length} tests) - ${file.name}`);
51
+ }
52
+ tag('info').log('');
53
+ tag('info').log(`View plan tests: ${getCliName()} plans <number>`);
54
+ }
55
+ printPlanDetails(plan, file) {
56
+ tag('info').log(`${plan.title} (${plan.tests.length} tests)`);
57
+ for (let i = 0; i < plan.tests.length; i++) {
58
+ const test = plan.tests[i];
59
+ tag('info').log(`${i + 1}. ${test.scenario}`);
60
+ }
61
+ tag('info').log('');
62
+ tag('info').log('Run test from this plan as:');
63
+ tag('info').log(`${getCliName()} test 1 --from-plan ${file.name}`);
64
+ }
65
+ resolvePlanFile(target, files) {
66
+ const index = Number.parseInt(target, 10);
67
+ if (!Number.isNaN(index) && String(index) === target) {
68
+ const file = files[index - 1];
69
+ if (!file)
70
+ throw new Error(`Plan #${target} not found. Available: 1-${files.length}`);
71
+ return file;
72
+ }
73
+ const resolved = this.explorBot.resolvePlanPath(target);
74
+ if (!existsSync(resolved)) {
75
+ throw new Error(`Plan file not found: ${resolved}`);
76
+ }
77
+ return {
78
+ name: path.basename(resolved),
79
+ path: resolved,
80
+ modifiedAt: statSync(resolved).mtimeMs,
81
+ };
82
+ }
83
+ }
@@ -5,12 +5,18 @@ import { BaseCommand } from './base-command.js';
5
5
  export class TestCommand extends BaseCommand {
6
6
  name = 'test';
7
7
  description = 'Launch tester agent to execute test scenarios';
8
+ options = [{ flags: '--from-plan <file>', description: 'Load plan file before selecting tests' }];
8
9
  suggestions = [
9
10
  { command: 'test', hint: 'run next test' },
10
11
  { command: 'plan', hint: 'create new plan' },
11
12
  ];
12
13
  async execute(args) {
14
+ const { opts, args: remaining } = this.parseArgs(args);
15
+ if (opts.fromPlan) {
16
+ this.explorBot.loadPlan(String(opts.fromPlan));
17
+ }
13
18
  const plan = this.explorBot.getCurrentPlan();
19
+ const selector = remaining.join(' ');
14
20
  Stats.mode = 'test';
15
21
  Stats.focus = plan?.title;
16
22
  const toExecute = [];
@@ -19,25 +25,25 @@ export class TestCommand extends BaseCommand {
19
25
  throw new Error('No plan found. Please run /plan first to create test scenarios.');
20
26
  return plan;
21
27
  };
22
- if (!args) {
28
+ if (!selector) {
23
29
  const pending = requirePlan().getPendingTests();
24
30
  if (pending.length === 0) {
25
31
  throw new Error('All tests are already complete. Please run /plan to create new test scenarios.');
26
32
  }
27
33
  toExecute.push(pending[0]);
28
34
  }
29
- else if (args === '*' || args === 'all') {
35
+ else if (selector === '*' || selector === 'all') {
30
36
  toExecute.push(...requirePlan().getPendingTests());
31
37
  }
32
- else if (args.match(/^[\d,\-\s]+$/)) {
38
+ else if (selector.match(/^[\d,\-\s]+$/)) {
33
39
  const visible = requirePlan().tests.filter((t) => t.enabled);
34
- const indices = parseTestIndices(args, visible.length);
40
+ const indices = parseTestIndices(selector, visible.length);
35
41
  for (const idx of indices) {
36
42
  toExecute.push(visible[idx]);
37
43
  }
38
44
  }
39
45
  else {
40
- const matching = plan?.getPendingTests().filter((test) => test.scenario.toLowerCase().includes(args.toLowerCase())) || [];
46
+ const matching = plan?.getPendingTests().filter((test) => test.scenario.toLowerCase().includes(selector.toLowerCase())) || [];
41
47
  if (matching.length > 0) {
42
48
  toExecute.push(...matching);
43
49
  }
@@ -46,13 +52,13 @@ export class TestCommand extends BaseCommand {
46
52
  if (!state) {
47
53
  throw new Error('No page loaded. Please navigate to a page first.');
48
54
  }
49
- const newTest = new Test(args, 'unknown', [], state.url);
55
+ const newTest = new Test(selector, 'unknown', [], state.url);
50
56
  if (plan) {
51
57
  plan.addTest(newTest);
52
- tag('info').log(`Created new test: "${args}" and added to current plan.`);
58
+ tag('info').log(`Created new test: "${selector}" and added to current plan.`);
53
59
  }
54
60
  else {
55
- tag('info').log(`Created ad-hoc test: "${args}"`);
61
+ tag('info').log(`Created ad-hoc test: "${selector}"`);
56
62
  }
57
63
  toExecute.push(newTest);
58
64
  }
@@ -1,6 +1,5 @@
1
+ import { createRequire } from 'node:module';
1
2
  import { readFile } from 'node:fs/promises';
2
- // @ts-ignore — package ships a .js re-export without typings for this sub-path
3
- import * as playwrightUtils from 'playwright-core/lib/utils';
4
3
  import { createDebug } from "./utils/logger.js";
5
4
  const debugLog = createDebug('explorbot:playwright-recorder');
6
5
  const RECORDABLE = {
@@ -8,11 +7,26 @@ const RECORDABLE = {
8
7
  Page: new Set(['goBack', 'goForward', 'reload', 'keyboardPress', 'keyboardType', 'keyboardDown', 'keyboardUp', 'keyboardInsertText', 'mouseClick', 'mouseDblclick', 'mouseMove', 'mouseDown', 'mouseUp', 'mouseWheel']),
9
8
  };
10
9
  const PLAYWRIGHT_INCOMPATIBLE = "Playwright output is not compatible with this Playwright version (playwright-core/lib/utils does not expose asLocator). Use output.framework: 'codeceptjs' instead, or pin Playwright to a version shipping lib/utils/isomorphic/locatorGenerators.js.";
10
+ let cachedAsLocator = null;
11
+ let asLocatorLoadAttempted = false;
12
+ const nodeRequire = typeof require === 'function' ? require : createRequire(import.meta.url);
11
13
  function getAsLocator() {
12
- const fn = playwrightUtils?.asLocator;
13
- if (typeof fn !== 'function')
14
+ if (cachedAsLocator)
15
+ return cachedAsLocator;
16
+ if (asLocatorLoadAttempted)
14
17
  throw new Error(PLAYWRIGHT_INCOMPATIBLE);
15
- return fn;
18
+ asLocatorLoadAttempted = true;
19
+ try {
20
+ const mod = nodeRequire('playwright-core/lib/utils');
21
+ if (typeof mod?.asLocator === 'function') {
22
+ cachedAsLocator = mod.asLocator;
23
+ return cachedAsLocator;
24
+ }
25
+ }
26
+ catch {
27
+ // Module not exported or not found
28
+ }
29
+ throw new Error(PLAYWRIGHT_INCOMPATIBLE);
16
30
  }
17
31
  export class PlaywrightRecorder {
18
32
  context = null;
@@ -11,7 +11,7 @@ export class Reporter {
11
11
  constructor(config, stateManager) {
12
12
  this.reporterEnabled = Reporter.resolveEnabled(config);
13
13
  this.stateManager = stateManager;
14
- if (this.reporterEnabled && (!process.env.TESTOMATIO || config?.html)) {
14
+ if (this.reporterEnabled && config?.html) {
15
15
  this.configureHtmlPipe();
16
16
  }
17
17
  if (this.reporterEnabled && config?.markdown) {
@@ -45,6 +45,8 @@ export class Reporter {
45
45
  return true;
46
46
  if (config?.enabled === false)
47
47
  return false;
48
+ if (config?.html || config?.markdown)
49
+ return true;
48
50
  return Boolean(process.env.TESTOMATIO);
49
51
  }
50
52
  configureHtmlPipe() {
@@ -68,13 +70,9 @@ export class Reporter {
68
70
  configureRunGroup(runGroup) {
69
71
  if (process.env.TESTOMATIO_RUNGROUP_TITLE)
70
72
  return;
71
- if (runGroup === null)
72
- return;
73
- if (runGroup) {
74
- process.env.TESTOMATIO_RUNGROUP_TITLE = runGroup;
73
+ if (!runGroup)
75
74
  return;
76
- }
77
- process.env.TESTOMATIO_RUNGROUP_TITLE = `Explorbot ${new Date().toISOString().slice(0, 10)}`;
75
+ process.env.TESTOMATIO_RUNGROUP_TITLE = runGroup;
78
76
  }
79
77
  async startRun() {
80
78
  if (this.isRunStarted) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -82,7 +82,7 @@
82
82
  "@opentelemetry/sdk-trace-base": "^2.2.0",
83
83
  "@opentelemetry/semantic-conventions": "^1.38.0",
84
84
  "@scalar/openapi-parser": "^0.25.6",
85
- "@testomatio/reporter": "^2.7.9-beta.3-markdown",
85
+ "@testomatio/reporter": "^2.8.4",
86
86
  "ai": "^6.0.6",
87
87
  "axe-core": "^4.11.1",
88
88
  "bash-tool": "^1.3.15",
@@ -108,7 +108,7 @@
108
108
  "micromatch": "^4.0.8",
109
109
  "ora-classic": "^5.4.2",
110
110
  "parse5": "^8.0.0",
111
- "playwright": "^1.59.0",
111
+ "playwright": "^1.60",
112
112
  "react": "^19.1.1",
113
113
  "strip-ansi": "^7.1.2",
114
114
  "turndown": "^7.2.1",
@@ -207,6 +207,17 @@ export class ActionResult implements ActionResultData {
207
207
  this.verifications[assertion] = passed;
208
208
  }
209
209
 
210
+ getVerification(message: string | RegExp): boolean | null {
211
+ if (!this.verifications) return null;
212
+ if (typeof message === 'string') {
213
+ return this.verifications[message] ?? null;
214
+ }
215
+ for (const [assertion, passed] of Object.entries(this.verifications)) {
216
+ if (message.test(assertion)) return passed;
217
+ }
218
+ return null;
219
+ }
220
+
210
221
  isSameUrl(state: WebPageState): boolean {
211
222
  if (!this.url || this.url === '') {
212
223
  return false;