explorbot 0.1.24 → 0.1.26

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 (56) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/ai/historian/codeceptjs.js +1 -1
  3. package/dist/src/ai/historian/experience.js +1 -1
  4. package/dist/src/ai/historian/playwright.js +1 -1
  5. package/dist/src/ai/historian/screencast.js +3 -3
  6. package/dist/src/ai/historian.js +1 -1
  7. package/dist/src/ai/navigator.js +4 -4
  8. package/dist/src/ai/pilot.js +8 -5
  9. package/dist/src/ai/planner.js +2 -4
  10. package/dist/src/ai/provider.js +5 -3
  11. package/dist/src/ai/researcher/locators.js +1 -1
  12. package/dist/src/ai/researcher.js +4 -4
  13. package/dist/src/ai/rules.js +20 -0
  14. package/dist/src/ai/session-analyst.js +4 -1
  15. package/dist/src/ai/task-agent.js +2 -2
  16. package/dist/src/ai/tester.js +3 -1
  17. package/dist/src/ai/tools.js +2 -0
  18. package/dist/src/commands/context-aria-command.js +1 -1
  19. package/dist/src/commands/explore-command.js +67 -25
  20. package/dist/src/commands/test-command.js +2 -1
  21. package/dist/src/components/App.js +1 -33
  22. package/dist/src/components/LogPane.js +9 -3
  23. package/dist/src/experience-tracker.js +2 -2
  24. package/dist/src/explorbot.js +1 -1
  25. package/dist/src/reporter.js +24 -5
  26. package/dist/src/utils/log-filters.js +27 -0
  27. package/dist/src/utils/logger.js +28 -1
  28. package/dist/src/utils/next-steps.js +1 -7
  29. package/package.json +1 -1
  30. package/src/ai/historian/codeceptjs.ts +1 -1
  31. package/src/ai/historian/experience.ts +1 -1
  32. package/src/ai/historian/playwright.ts +1 -1
  33. package/src/ai/historian/screencast.ts +3 -3
  34. package/src/ai/historian.ts +1 -1
  35. package/src/ai/navigator.ts +4 -4
  36. package/src/ai/pilot.ts +8 -5
  37. package/src/ai/planner.ts +1 -3
  38. package/src/ai/provider.ts +5 -3
  39. package/src/ai/researcher/locators.ts +1 -1
  40. package/src/ai/researcher.ts +4 -4
  41. package/src/ai/rules.ts +21 -0
  42. package/src/ai/session-analyst.ts +4 -1
  43. package/src/ai/task-agent.ts +2 -2
  44. package/src/ai/tester.ts +3 -1
  45. package/src/ai/tools.ts +2 -0
  46. package/src/commands/context-aria-command.ts +1 -1
  47. package/src/commands/explore-command.ts +68 -23
  48. package/src/commands/test-command.ts +2 -1
  49. package/src/components/App.tsx +0 -33
  50. package/src/components/LogPane.tsx +18 -3
  51. package/src/experience-tracker.ts +2 -2
  52. package/src/explorbot.ts +1 -1
  53. package/src/reporter.ts +24 -6
  54. package/src/utils/log-filters.ts +26 -0
  55. package/src/utils/logger.ts +24 -2
  56. package/src/utils/next-steps.ts +1 -6
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
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 @@ export function WithCodeceptJS(Base) {
82
82
  const filePath = join(testsDir, safeFilename(plan.title, '.js'));
83
83
  writeFileSync(filePath, lines.join('\n'));
84
84
  this.savedFiles.add(filePath);
85
- tag('substep').log(`Saved plan tests to: ${relativeToCwd(filePath)}`);
85
+ tag('operation').log(`Saved plan tests to: ${relativeToCwd(filePath)}`);
86
86
  return filePath;
87
87
  }
88
88
  getKnowledgeLines(url, indent = ' ') {
@@ -30,7 +30,7 @@ export function WithExperience(Base) {
30
30
  await this.reportSession(task, steps);
31
31
  }
32
32
  await this.stopScreencast();
33
- tag('substep').log(`Historian saved session for: ${task.description}`);
33
+ tag('operation').log(`Historian saved session for: ${task.description}`);
34
34
  }
35
35
  async reportSession(test, steps) {
36
36
  if (!this.reporter)
@@ -119,7 +119,7 @@ export function WithPlaywright(Base) {
119
119
  const filePath = join(testsDir, safeFilename(plan.title, '.spec.ts'));
120
120
  writeFileSync(filePath, lines.join('\n'));
121
121
  this.savedFiles.add(filePath);
122
- tag('substep').log(`Saved plan tests to: ${relativeToCwd(filePath)}`);
122
+ tag('operation').log(`Saved plan tests to: ${relativeToCwd(filePath)}`);
123
123
  return filePath;
124
124
  }
125
125
  getPlaywrightKnowledgeLines(url, indent = ' ') {
@@ -76,7 +76,7 @@ export function WithScreencast(Base) {
76
76
  this.screencastLastChapter = null;
77
77
  }
78
78
  catch (err) {
79
- tag('substep').log(`Screencast start failed: ${err.message}`);
79
+ tag('operation').log(`Screencast start failed: ${err.message}`);
80
80
  }
81
81
  }
82
82
  async emitChapter(_step) {
@@ -104,7 +104,7 @@ export function WithScreencast(Base) {
104
104
  await this.screencastPage.screencast.stop();
105
105
  }
106
106
  catch (err) {
107
- tag('substep').log(`Screencast stop failed: ${err.message}`);
107
+ tag('operation').log(`Screencast stop failed: ${err.message}`);
108
108
  }
109
109
  this.screencastActive = false;
110
110
  this.screencastPage = null;
@@ -114,7 +114,7 @@ export function WithScreencast(Base) {
114
114
  if (path) {
115
115
  this.savedFiles.add(path);
116
116
  task?.addArtifact?.(path);
117
- tag('substep').log(`Saved screencast: ${relativeToCwd(path)}`);
117
+ tag('operation').log(`Saved screencast: ${relativeToCwd(path)}`);
118
118
  }
119
119
  }
120
120
  };
@@ -38,6 +38,6 @@ export class Historian extends HistorianBase {
38
38
  }
39
39
  writeFileSync(filePath, content);
40
40
  this.savedFiles.add(filePath);
41
- tag('substep').log(`Updated test file with healed steps: ${relativeToCwd(filePath)}`);
41
+ tag('operation').log(`Updated test file with healed steps: ${relativeToCwd(filePath)}`);
42
42
  }
43
43
  }
@@ -182,7 +182,7 @@ class Navigator {
182
182
  if (!actionResult.isInsideIframe) {
183
183
  const successful = this.experienceTracker.getSuccessfulExperience(actionResult);
184
184
  if (successful.length > 0) {
185
- tag('substep').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${actionResult.url}`);
185
+ tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${actionResult.url}`);
186
186
  experience = `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${successful.join('\n\n')}\n</experience>`;
187
187
  }
188
188
  }
@@ -276,7 +276,7 @@ class Navigator {
276
276
  stop();
277
277
  return;
278
278
  }
279
- tag('substep').log('Feeding failures back to AI for a new batch...');
279
+ tag('operation').log('Feeding failures back to AI for a new batch...');
280
280
  let contextMsg = 'Previous solutions did not work. Analyze the failures and try DIFFERENT strategies (not syntactic variants of the same locator).\n\n';
281
281
  if (batchFailures.length > 0) {
282
282
  const lines = batchFailures
@@ -573,7 +573,7 @@ class Navigator {
573
573
  debugLog('Verification message:', message);
574
574
  const cachedVerification = actionResult.getVerification(message);
575
575
  if (cachedVerification !== null) {
576
- tag('substep').log(`Reusing cached verification: ${cachedVerification ? 'PASS' : 'FAIL'}`);
576
+ tag('operation').log(`Reusing cached verification: ${cachedVerification ? 'PASS' : 'FAIL'}`);
577
577
  return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
578
578
  }
579
579
  let knowledge = '';
@@ -591,7 +591,7 @@ class Navigator {
591
591
  const toc = this.experienceTracker.getExperienceTableOfContents(actionResult);
592
592
  if (toc.length > 0) {
593
593
  const totalSections = toc.reduce((sum, entry) => sum + entry.sections.length, 0);
594
- tag('substep').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections) for: ${actionResult.url}`);
594
+ tag('operation').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections) for: ${actionResult.url}`);
595
595
  experience = renderExperienceToc(toc);
596
596
  }
597
597
  }
@@ -84,7 +84,7 @@ export class Pilot {
84
84
  }
85
85
  const schema = z.object({
86
86
  decision: z.enum(['pass', 'fail', 'continue', 'skipped']).describe('pass = test succeeded, fail = test failed, continue = tester should keep going, skipped = scenario is irrelevant OR systematic execution failures prevented testing'),
87
- reason: z.string().describe('What happened and why (1-2 sentences). Do NOT repeat the decision status (e.g. "scenario goal achieved/not achieved") — just explain the evidence. For continue: explain why rejected and suggest alternatives.'),
87
+ reason: z.string().describe('Concise user-facing reason, maximum 1 short sentence and 120 characters. Do NOT repeat the decision status; explain only the evidence. For continue: explain why rejected and suggest alternatives.'),
88
88
  guidance: z.string().nullable().describe('Required for "continue": specific actionable instruction for the tester — what exactly to verify, retry differently, or complete next. Be concrete.'),
89
89
  requestVerification: z
90
90
  .string()
@@ -149,7 +149,7 @@ export class Pilot {
149
149
  this.explorer.getPlaywrightRecorder().recordVerification(verifyResult.assertionSteps);
150
150
  }
151
151
  }
152
- tag('info').log(`Pilot: ${result.decision} ${result.reason}`);
152
+ tag('info').log(`Pilot: ${result.decision} - ${result.reason}`);
153
153
  task.summary = result.reason;
154
154
  const verdictState = screenshotState || currentState;
155
155
  if (result.decision === 'pass') {
@@ -187,7 +187,7 @@ export class Pilot {
187
187
  const notes = task.notesToString() || 'No notes recorded.';
188
188
  const schema = z.object({
189
189
  decision: z.enum(['allow', 'fail', 'continue', 'skipped']).describe('allow = reset proceeds, fail = test failed (stop looping), continue = veto reset, tester should act on current page instead, skipped = scenario is irrelevant or cannot be executed'),
190
- reason: z.string().describe('What evidence justifies this decision (1-2 sentences). Do not restate the decision.'),
190
+ reason: z.string().describe('Concise evidence-only reason, maximum 1 short sentence and 120 characters. Do not restate the decision.'),
191
191
  guidance: z.string().nullable().describe('Required for "continue": concrete instruction for what the tester should do instead of resetting (e.g. which tool to call, what to verify).'),
192
192
  });
193
193
  const userContent = dedent `
@@ -343,8 +343,9 @@ export class Pilot {
343
343
  - "continue": tester hasn't completed the goal; provide concrete guidance (which tool, what to check).
344
344
  If a verify() asserted a state that was ALREADY TRUE before the test, it proves nothing — reject.
345
345
 
346
- reason field: do NOT restate the decision ("scenario goal achieved/not achieved"). State what happened —
347
- what was verified, what failed, what evidence was found.
346
+ reason field: one short sentence, maximum 120 characters. Do NOT restate the decision
347
+ ("scenario goal achieved/not achieved"). State what happened: what was verified, what failed,
348
+ or what evidence was found.
348
349
  `;
349
350
  }
350
351
  async planTest(task, currentState) {
@@ -919,6 +920,8 @@ export class Pilot {
919
920
  Response format:
920
921
  PROGRESS: <1 sentence assessment>
921
922
  NEXT: <specific actionable instruction for Tester>
923
+
924
+ Keep user-facing reasons concise: one short sentence, maximum 120 characters, evidence only, no repeated verdict wording.
922
925
  `;
923
926
  }
924
927
  }
@@ -14,7 +14,7 @@ import { mdq } from '../utils/markdown-query.js';
14
14
  import { planToCompactAiContext } from "../utils/test-plan-markdown.js";
15
15
  import { Conversation } from "./conversation.js";
16
16
  import { WithSessionDedup } from "./planner/session-dedup.js";
17
- import { getActiveStyle, getStyles } from "./planner/styles.js";
17
+ import { getActiveStyle } from "./planner/styles.js";
18
18
  import { WithSubPages, getPlannedByStateHash, getRegisteredPlan, registerPlan } from "./planner/subpages.js";
19
19
  import { POSSIBLE_SECTIONS, Researcher } from "./researcher.js";
20
20
  import { findSimilarStateHash } from "./researcher/cache.js";
@@ -28,7 +28,7 @@ const TasksSchema = z.object({
28
28
  scenario: z.string().describe('A single sentence describing what to test'),
29
29
  priority: z.enum(['critical', 'important', 'high', 'normal', 'low']).describe('Priority of the task based on business importance'),
30
30
  startUrl: z.string().nullable().describe('Start URL for the test if different from plan URL (only for tests on visited subpages)'),
31
- steps: z.array(z.string()).describe('List of steps to perform for this scenario. Each step should be a specific action (e.g., "Click on Login button", "Enter username in email field", "Submit the form"). Keep steps atomic and actionable.'),
31
+ steps: z.array(z.string()).describe('List of steps to perform for this scenario. Each step should be a specific action (e.g., "Open the form", "Enter required data", "Submit the form"). Keep steps atomic and actionable.'),
32
32
  expectedOutcomes: z
33
33
  .array(z.string())
34
34
  .describe('List of expected outcomes that can be verified. Each outcome should be simple, specific, and easy to check (e.g., "Success message appears", "URL changes to /dashboard", "Form field shows error"). Keep outcomes atomic - do not combine multiple checks into one.'),
@@ -199,9 +199,7 @@ export class Planner extends PlannerBase {
199
199
  tag('multiline').log(summary);
200
200
  }
201
201
  }
202
- const availableStyles = Object.keys(getStyles()).join(', ');
203
202
  tag('success').log(`Planning complete! ${this.currentPlan.tests.length} tests in plan: ${this.currentPlan.title}`);
204
- tag('info').log(`Planning style: ${this.lastStyleName} (available: ${availableStyles})`);
205
203
  if (state.url)
206
204
  registerPlan(state.url, this.currentPlan, feature, state.hash);
207
205
  this.registerPlanInSession(this.currentPlan);
@@ -259,8 +259,11 @@ export class Provider {
259
259
  }
260
260
  throw new ContextLengthError(error.message || error.toString());
261
261
  }
262
- tag('error').log(error.message || error.toString());
263
- throw new AiError(error.message || error.toString());
262
+ const message = error.message || error.toString();
263
+ if (message !== 'No response text from AI') {
264
+ tag('error').log(message);
265
+ }
266
+ throw new AiError(message);
264
267
  }
265
268
  }
266
269
  async generateWithTools(messages, model, tools, options = {}) {
@@ -333,7 +336,6 @@ export class Provider {
333
336
  catch (error) {
334
337
  clearActivity();
335
338
  if (error?.message?.includes('Tool choice is required')) {
336
- tag('warning').log('Model completed without calling a tool, returning empty result');
337
339
  return { text: '', toolCalls: [], toolResults: [], response: { messages: [] }, usage: null };
338
340
  }
339
341
  if (error?.name === 'AbortError')
@@ -70,7 +70,7 @@ export function WithLocators(Base) {
70
70
  broken++;
71
71
  }
72
72
  }
73
- tag('substep').log(`Validated ${locators.length} locators: ${locators.length - broken} valid, ${broken} broken`);
73
+ tag('operation').log(`Validated ${locators.length} locators: ${locators.length - broken} valid, ${broken} broken`);
74
74
  }
75
75
  async fixBrokenSections(result, conversation) {
76
76
  const broken = result.locators.filter((l) => l.valid === false);
@@ -114,7 +114,7 @@ export class Researcher extends ResearcherBase {
114
114
  if (!deep && !force) {
115
115
  const similar = await findSimilarResearch(combinedHtml);
116
116
  if (similar) {
117
- tag('substep').log('Similar research found, reusing cached result');
117
+ tag('operation').log('Similar research found, reusing cached result');
118
118
  if (stateHash)
119
119
  saveResearch(stateHash, similar, combinedHtml);
120
120
  tag('multiline').log(formatResearchSummary(similar));
@@ -264,10 +264,10 @@ export class Researcher extends ResearcherBase {
264
264
  tag('multiline').log(formatResearchSummary(result.text, { visionUsed: this.hasScreenshotToAnalyze }));
265
265
  tag('success').log('Research complete');
266
266
  if (researchFile)
267
- tag('substep').log(`Research file saved to: ${researchFile}`);
267
+ tag('operation').log(`Research file saved to: ${researchFile}`);
268
268
  if (this.actionResult?.screenshotFile) {
269
269
  const screenshotPath = outputPath('states', this.actionResult.screenshotFile);
270
- tag('substep').log(`UI screenshot: file://${screenshotPath}`);
270
+ tag('operation').log(`UI screenshot: file://${screenshotPath}`);
271
271
  }
272
272
  await this.hooksRunner.runAfterHook('researcher', state.url);
273
273
  return result.text;
@@ -402,7 +402,7 @@ export class Researcher extends ResearcherBase {
402
402
  .map((k) => k.content)
403
403
  .filter((k) => !!k)
404
404
  .join('\n\n');
405
- tag('substep').log(`Found ${knowledgeFiles.length} relevant knowledge ${pluralize(knowledgeFiles.length, 'file')} for: ${this.actionResult.url}`);
405
+ tag('operation').log(`Found ${knowledgeFiles.length} relevant knowledge ${pluralize(knowledgeFiles.length, 'file')} for: ${this.actionResult.url}`);
406
406
  knowledge = `
407
407
  <hint>
408
408
  Here is relevant knowledge for this page:
@@ -127,6 +127,11 @@ export const fileUploadRule = dedent `
127
127
  Works with drag-and-drop upload zones.
128
128
  </file_upload>
129
129
  `;
130
+ export const formRequirementsRule = dedent `
131
+ <form_requirements>
132
+ Before filling a form that persists data (create/update), read each control's requirements (required, type/format, length, placeholder/aria-describedby hints) from <page_aria> and page HTML — call context() if not visible — and enter values that satisfy them. Search/filter/sort forms that only change the view do not need this.
133
+ </form_requirements>
134
+ `;
130
135
  // in rage mode we do not protect from irreversible actions
131
136
  export const protectionRule = dedent `
132
137
  <important>
@@ -260,6 +265,8 @@ export const actionRule = dedent `
260
265
  If locator doesn't work, try CSS or XPath locators.
261
266
  If nothing works, use I.clickXY(x, y) as last resort.
262
267
 
268
+ For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
269
+
263
270
 
264
271
  ### I.fillField
265
272
 
@@ -346,6 +353,19 @@ export const actionRule = dedent `
346
353
  I.selectOption('form select[name=account]', 'Premium');
347
354
  </example>
348
355
 
356
+ ### I.checkOption / I.uncheckOption
357
+
358
+ Set a checkbox/radio to a definite state — idempotent, never toggles. Use for checkboxes instead of I.click. Run via form(), not click().
359
+
360
+ I.checkOption(<locator>, <context>)
361
+ I.uncheckOption(<locator>, <context>)
362
+
363
+ <example>
364
+ I.checkOption('Subscribe');
365
+ I.checkOption({ role: 'checkbox', text: 'Agree' });
366
+ I.uncheckOption('Subscribe', '.preferences');
367
+ </example>
368
+
349
369
  ### I.attachFile
350
370
 
351
371
  Attaches a file to a file input element.
@@ -34,6 +34,9 @@ export class SessionAnalyst {
34
34
 
35
35
  Crucial distinction: "the app misbehaved" vs "the automation could not interact with the app". ONLY the first is a Defect. If the automation gives up before the app responds — timeout, retries exhausted, dead loop / loop detected, could not click or find an element — that is an Execution issue regardless of what the log calls it. Failure inside the automation ≠ failure inside the product.
36
36
 
37
+ The action log is more authoritative than the scenario title. If the actual submitted data, page state, or action sequence does not match the scenario title, classify it as Execution issue and do not list that scenario under What works. Do NOT infer a product Defect or UX issue from behavior caused by incorrect test data or an automation mismatch.
38
+ Negative test data is valid when it matches a negative scenario. Do not call intentionally invalid input wrong data when the scenario expects rejection or validation feedback.
39
+
37
40
  A solitary failure where adjacent tests on the same feature passed → Execution, not Defect.
38
41
 
39
42
  ## Severity (defects only)
@@ -69,7 +72,7 @@ export class SessionAnalyst {
69
72
 
70
73
  ## Brevity rules
71
74
 
72
- - Headline: 2 sentences MAX. About the FEATURE, not the run. No counts, no "N tests", no "this session". Banned words: "exercised", "comprehensive", "notably", "this session", "module", "targeted", "covered creation".
75
+ - Headline: 2 sentences MAX. About the FEATURE, not the run. No counts, no "N tests", no "this session". Never use these words: "exercised", "comprehensive", "notably", "this session", "module", "targeted", "covered creation".
73
76
  - What works: feature name + test refs. NO parentheticals, NO caveats. If there's a caveat, the entry doesn't belong here.
74
77
  - Defect title is the BUG ("Search returns non-matching results"), never the scenario name.
75
78
  - Reproduce steps are imperative one-liners drawn from the log.
@@ -24,7 +24,7 @@ export class TaskAgent {
24
24
  .map((k) => k.content)
25
25
  .filter((k) => !!k)
26
26
  .join('\n\n');
27
- tag('substep').log(`Found ${knowledgeFiles.length} relevant knowledge ${pluralize(knowledgeFiles.length, 'file')}`);
27
+ tag('operation').log(`Found ${knowledgeFiles.length} relevant knowledge ${pluralize(knowledgeFiles.length, 'file')}`);
28
28
  return dedent `
29
29
  <knowledge>
30
30
  Here is relevant knowledge for this page:
@@ -40,7 +40,7 @@ export class TaskAgent {
40
40
  return '';
41
41
  const totalSections = toc.reduce((sum, entry) => sum + entry.sections.length, 0);
42
42
  debugLog(`injecting experience TOC (${toc.length} files, ${totalSections} sections)`);
43
- tag('substep').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections)`);
43
+ tag('operation').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections)`);
44
44
  return renderExperienceToc(toc);
45
45
  }
46
46
  setHistorian(historian) {
@@ -14,7 +14,7 @@ import { ErrorPageError } from "../utils/error-page.js";
14
14
  import { HooksRunner } from "../utils/hooks-runner.js";
15
15
  import { createDebug, tag } from "../utils/logger.js";
16
16
  import { loop } from "../utils/loop.js";
17
- import { actionRule, focusedElementRule, locatorRule, multipleTabsRule, protectionRule, sectionContextRule } from "./rules.js";
17
+ import { actionRule, focusedElementRule, formRequirementsRule, locatorRule, multipleTabsRule, protectionRule, sectionContextRule } from "./rules.js";
18
18
  import { TaskAgent } from "./task-agent.js";
19
19
  import { createCodeceptJSTools, createSpecialContextTools } from "./tools.js";
20
20
  const debugLog = createDebug('explorbot:tester');
@@ -691,6 +691,8 @@ export class Tester extends TaskAgent {
691
691
 
692
692
  ${sectionContextRule}
693
693
 
694
+ ${formRequirementsRule}
695
+
694
696
  ${this.provider.getSystemPromptForAgent('tester', this.explorer.getStateManager().getCurrentState()?.url) || ''}
695
697
  `;
696
698
  }
@@ -262,6 +262,7 @@ export function createCodeceptJSTools(explorer, task) {
262
262
 
263
263
  Use cases:
264
264
  - Typing into input fields (I.fillField, I.type)
265
+ - Setting checkboxes/radios to a definite state (I.checkOption, I.uncheckOption)
265
266
  - Working with iframes (switch context with I.switchTo)
266
267
  - Performing multiple form actions in a single batch
267
268
  - Complex interactions requiring sequential commands
@@ -269,6 +270,7 @@ export function createCodeceptJSTools(explorer, task) {
269
270
  Example - filling a form with context (PREFERRED):
270
271
  I.fillField('Username', 'John', '.login-form')
271
272
  I.selectOption('Country', 'USA', '.address-section')
273
+ I.checkOption('Agree', '.terms-section')
272
274
  I.attachFile('input[type="file"]', 'path/to/file', '.upload-section')
273
275
 
274
276
  Example - filling a form with ARIA locators:
@@ -12,6 +12,6 @@ export class ContextAriaCommand extends BaseCommand {
12
12
  if (!ariaSnapshot) {
13
13
  throw new Error('No ARIA snapshot available for current page');
14
14
  }
15
- tag('multiline').log(`ARIA Snapshot:\n\n${ariaSnapshot}`);
15
+ tag('multiline').log(`ARIA Snapshot:\n\n${ariaSnapshot}`, { maxLines: 10 });
16
16
  }
17
17
  }
@@ -7,7 +7,6 @@ import { TestResult } from '../test-plan.js';
7
7
  import { getCliName } from "../utils/cli-name.js";
8
8
  import { ErrorPageError } from "../utils/error-page.js";
9
9
  import { tag } from '../utils/logger.js';
10
- import { jsonToTable } from '../utils/markdown-parser.js';
11
10
  import { printNextSteps, relativeToCwd } from "../utils/next-steps.js";
12
11
  import { safeFilename } from "../utils/strings.js";
13
12
  import { BaseCommand } from './base-command.js';
@@ -85,14 +84,14 @@ export class ExploreCommand extends BaseCommand {
85
84
  const t = tests[i];
86
85
  lines.push(` ${String(i + 1).padStart(2)}. [${this.originLabel(t)}] [${t.priority.padEnd(9)}] ${t.scenario}`);
87
86
  }
88
- tag('multiline').log(lines.join('\n'));
87
+ tag('multiline').log(lines.join('\n'), { maxLines: 24 });
89
88
  }
90
89
  async runFreshMode(mainUrl, feature, styles) {
91
90
  await this.runAllStyles(mainUrl, feature, undefined, undefined, styles);
91
+ this.rememberCurrentPlan();
92
92
  const mainPlan = this.explorBot.getCurrentPlan();
93
93
  if (!mainPlan)
94
94
  return;
95
- this.completedPlans.push(mainPlan);
96
95
  if (feature || this.isLimitReached())
97
96
  return;
98
97
  await this.discoverNewSubPages(mainPlan, mainUrl, styles, new Set());
@@ -264,6 +263,8 @@ export class ExploreCommand extends BaseCommand {
264
263
  const styleList = styles ?? Object.keys(getStyles());
265
264
  let fresh = true;
266
265
  for (const style of styleList) {
266
+ if (this.isLimitReached())
267
+ break;
267
268
  if (!fresh && pageUrl && !this.dryRun) {
268
269
  await this.explorBot.visit(pageUrl);
269
270
  }
@@ -274,9 +275,20 @@ export class ExploreCommand extends BaseCommand {
274
275
  opts.noSave = true;
275
276
  await this.planWithRetry(feature, opts, pageUrl);
276
277
  await this.runPendingTests();
278
+ this.rememberCurrentPlan();
277
279
  fresh = false;
278
280
  }
279
281
  }
282
+ rememberCurrentPlan() {
283
+ const plan = this.explorBot.getCurrentPlan();
284
+ if (!plan)
285
+ return;
286
+ if (this.completedPlans.includes(plan))
287
+ return;
288
+ if (plan.tests.every((test) => test.startTime == null))
289
+ return;
290
+ this.completedPlans.push(plan);
291
+ }
280
292
  async planWithRetry(feature, opts, pageUrl) {
281
293
  const before = new Set(this.explorBot.getCurrentPlan()?.tests ?? []);
282
294
  await this.explorBot.plan(feature, opts);
@@ -394,9 +406,9 @@ export class ExploreCommand extends BaseCommand {
394
406
  const allTests = this.completedPlans.flatMap((plan) => plan.tests.filter((t) => t.startTime != null).map((test) => ({ test, planTitle: plan.title }))).sort((a, b) => (a.test.startTime ?? 0) - (b.test.startTime ?? 0));
395
407
  if (allTests.length === 0)
396
408
  return;
397
- const hasSubPages = this.completedPlans.length > 1;
409
+ const hasSubPages = new Set(this.completedPlans.map((plan) => plan.title)).size > 1;
398
410
  const hasOrigin = this.oldTestRefs.size > 0;
399
- const rows = allTests.map(({ test, planTitle }, index) => {
411
+ const completed = allTests.map(({ test, planTitle }, index) => {
400
412
  const durationMs = test.getDurationMs();
401
413
  const duration = durationMs != null ? `${(durationMs / 1000).toFixed(1)}s` : '-';
402
414
  let status = 'failed';
@@ -404,28 +416,51 @@ export class ExploreCommand extends BaseCommand {
404
416
  status = 'passed';
405
417
  else if (test.isSkipped)
406
418
  status = 'skipped';
407
- const row = {
408
- '#': String(index + 1),
409
- Status: status,
410
- Title: test.scenario.replace(/\|/g, '-'),
411
- Priority: test.priority,
412
- Time: duration,
413
- Steps: String(Object.keys(test.notes).length),
419
+ return {
420
+ index: index + 1,
421
+ status,
422
+ title: test.scenario.replace(/\s+/g, ' ').trim(),
423
+ priority: test.priority,
424
+ duration,
425
+ durationMs: durationMs ?? 0,
426
+ steps: Object.keys(test.notes).length,
427
+ origin: hasOrigin ? this.originLabel(test) : '',
428
+ planTitle: hasSubPages ? planTitle : '',
414
429
  };
415
- if (hasOrigin) {
416
- row.Origin = this.originLabel(test);
430
+ });
431
+ const passed = completed.filter((t) => t.status === 'passed').length;
432
+ const failed = completed.filter((t) => t.status === 'failed').length;
433
+ const skipped = completed.filter((t) => t.status === 'skipped').length;
434
+ const totalSeconds = completed.reduce((sum, t) => sum + t.durationMs, 0) / 1000;
435
+ const lines = [`Results: ${passed} passed, ${failed} failed, ${skipped} skipped - ${formatDuration(totalSeconds)}`];
436
+ const failedTests = completed.filter((t) => t.status === 'failed');
437
+ if (failedTests.length > 0) {
438
+ lines.push('', 'Failed tests:');
439
+ for (const test of failedTests) {
440
+ lines.push(` #${test.index} [${test.priority}] ${test.title} (${test.duration}, ${test.steps} steps)`);
417
441
  }
418
- if (hasSubPages) {
419
- row.Plan = planTitle;
442
+ }
443
+ const slowTests = completed
444
+ .filter((t) => t.durationMs >= 1000)
445
+ .sort((a, b) => b.durationMs - a.durationMs)
446
+ .slice(0, 3);
447
+ if (slowTests.length > 0) {
448
+ lines.push('', 'Slowest tests:');
449
+ for (const test of slowTests) {
450
+ lines.push(` #${test.index} ${test.duration} - ${test.title}`);
420
451
  }
421
- return row;
422
- });
423
- const columns = ['#', 'Status', 'Title', 'Priority', 'Time', 'Steps'];
424
- if (hasOrigin)
425
- columns.push('Origin');
426
- if (hasSubPages)
427
- columns.push('Plan');
428
- tag('multiline').log(jsonToTable(rows, columns));
452
+ }
453
+ const detailLines = completed
454
+ .map((test) => {
455
+ const details = [test.origin, test.planTitle].filter(Boolean).join(' - ');
456
+ return details ? ` #${test.index} ${details}` : '';
457
+ })
458
+ .filter(Boolean);
459
+ if (detailLines.length > 0) {
460
+ lines.push('', 'Details:');
461
+ lines.push(...detailLines);
462
+ }
463
+ tag('multiline').log(lines.join('\n'));
429
464
  tag('info').log(`${figureSet.tick} ${allTests.length} tests completed`);
430
465
  }
431
466
  printNextSteps(savedPlanPath) {
@@ -455,8 +490,8 @@ export class ExploreCommand extends BaseCommand {
455
490
  });
456
491
  }
457
492
  if (screencasts.length > 0) {
458
- const commands = screencasts.map((f) => ({ label: '', command: relativeToCwd(f) }));
459
493
  const screencastDir = relativeToCwd(outputPath('screencasts'));
494
+ const commands = [{ label: 'Folder', command: screencastDir }];
460
495
  const planSlugs = [...new Set(this.completedPlans.map((p) => safeFilename(p.title)).filter(Boolean))];
461
496
  for (const slug of planSlugs) {
462
497
  commands.push({ label: 'Browse plan', command: `ls ${screencastDir}/${slug}-*` });
@@ -513,3 +548,10 @@ function parseRatio(s) {
513
548
  return null;
514
549
  return n;
515
550
  }
551
+ function formatDuration(seconds) {
552
+ if (seconds < 60)
553
+ return `${seconds.toFixed(1)}s`;
554
+ const minutes = Math.floor(seconds / 60);
555
+ const remainingSeconds = Math.round(seconds % 60);
556
+ return `${minutes}m ${remainingSeconds}s`;
557
+ }
@@ -68,7 +68,8 @@ export class TestCommand extends BaseCommand {
68
68
  }
69
69
  tag('info').log(`Launching ${toExecute.length} test scenario(s).`);
70
70
  const tester = this.explorBot.agentTester();
71
- for (const test of toExecute) {
71
+ for (const [index, test] of toExecute.entries()) {
72
+ tag('info').log(`Starting test ${index + 1}/${toExecute.length}: ${test.scenario}`);
72
73
  await tester.test(test);
73
74
  }
74
75
  tag('success').log('Test execution finished');
@@ -8,7 +8,6 @@ import Autocomplete from './Autocomplete.js';
8
8
  import InputReadline from './InputReadline.js';
9
9
  import LogPane from './LogPane.js';
10
10
  import PlanEditor from './PlanEditor.js';
11
- import PlanPane from './PlanPane.js';
12
11
  import SessionTimer from './SessionTimer.js';
13
12
  import StateTransitionPane from './StateTransitionPane.js';
14
13
  import TaskPane, { WINDOW_SIZE } from './TaskPane.js';
@@ -124,39 +123,18 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
124
123
  }, [explorBot, inputCallbackReady]);
125
124
  const planRef = useRef(undefined);
126
125
  const unsubscribeRef = useRef(undefined);
127
- const [completedPlans, setCompletedPlans] = useState([]);
128
- const [activePlanInfo, setActivePlanInfo] = useState(null);
129
126
  useEffect(() => {
130
- const makeSummary = (plan) => {
131
- const enabled = plan.tests.filter((t) => t.enabled);
132
- return {
133
- title: plan.title,
134
- testCount: enabled.length,
135
- passed: enabled.filter((t) => t.isSuccessful).length,
136
- failed: enabled.filter((t) => t.hasFailed).length,
137
- };
138
- };
139
127
  const subscribeToPlan = (plan) => {
140
128
  if (unsubscribeRef.current)
141
129
  unsubscribeRef.current();
142
- if (planRef.current && planRef.current !== plan && planRef.current.tests.length > 0) {
143
- const summary = makeSummary(planRef.current);
144
- setCompletedPlans((prev) => {
145
- if (prev.some((p) => p.title === summary.title))
146
- return prev;
147
- return [...prev, summary];
148
- });
149
- }
150
130
  planRef.current = plan;
151
131
  tasksRef.current = [...plan.tests];
152
132
  setTasks(tasksRef.current);
153
133
  setTaskScrollOffset(0);
154
- setActivePlanInfo(makeSummary(plan));
155
134
  let lastInProgressIdx = -1;
156
135
  unsubscribeRef.current = plan.onTestsChange((updatedTests) => {
157
136
  tasksRef.current = [...updatedTests];
158
137
  setTasks(tasksRef.current);
159
- setActivePlanInfo(makeSummary(plan));
160
138
  const inProgressIdx = updatedTests.findIndex((t) => t.status === 'in_progress' && t.enabled);
161
139
  if (inProgressIdx >= 0 && inProgressIdx !== lastInProgressIdx) {
162
140
  lastInProgressIdx = inProgressIdx;
@@ -175,18 +153,9 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
175
153
  else if (!currentPlan && planRef.current) {
176
154
  if (unsubscribeRef.current)
177
155
  unsubscribeRef.current();
178
- if (planRef.current.tests.length > 0) {
179
- const summary = makeSummary(planRef.current);
180
- setCompletedPlans((prev) => {
181
- if (prev.some((p) => p.title === summary.title))
182
- return prev;
183
- return [...prev, summary];
184
- });
185
- }
186
156
  planRef.current = undefined;
187
157
  tasksRef.current = [];
188
158
  setTasks([]);
189
- setActivePlanInfo(null);
190
159
  }
191
160
  }, 2000);
192
161
  return () => {
@@ -325,6 +294,5 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
325
294
  React.createElement(StateTransitionPane, { currentState: currentState }))),
326
295
  tasks.length > 0 && (React.createElement(Box, { width: currentState ? '50%' : '100%' },
327
296
  React.createElement(TaskPane, { tasks: tasks, scrollOffset: taskScrollOffset }))),
328
- React.createElement(Autocomplete, null)),
329
- React.createElement(PlanPane, { completedPlans: completedPlans, activePlan: activePlanInfo })));
297
+ React.createElement(Autocomplete, null))));
330
298
  }