explorbot 0.1.25 → 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 (50) 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/session-analyst.js +4 -1
  14. package/dist/src/ai/task-agent.js +2 -2
  15. package/dist/src/commands/context-aria-command.js +1 -1
  16. package/dist/src/commands/explore-command.js +67 -25
  17. package/dist/src/commands/test-command.js +2 -1
  18. package/dist/src/components/App.js +1 -33
  19. package/dist/src/components/LogPane.js +9 -3
  20. package/dist/src/experience-tracker.js +2 -2
  21. package/dist/src/explorbot.js +1 -1
  22. package/dist/src/reporter.js +24 -5
  23. package/dist/src/utils/log-filters.js +27 -0
  24. package/dist/src/utils/logger.js +28 -1
  25. package/dist/src/utils/next-steps.js +1 -7
  26. package/package.json +1 -1
  27. package/src/ai/historian/codeceptjs.ts +1 -1
  28. package/src/ai/historian/experience.ts +1 -1
  29. package/src/ai/historian/playwright.ts +1 -1
  30. package/src/ai/historian/screencast.ts +3 -3
  31. package/src/ai/historian.ts +1 -1
  32. package/src/ai/navigator.ts +4 -4
  33. package/src/ai/pilot.ts +8 -5
  34. package/src/ai/planner.ts +1 -3
  35. package/src/ai/provider.ts +5 -3
  36. package/src/ai/researcher/locators.ts +1 -1
  37. package/src/ai/researcher.ts +4 -4
  38. package/src/ai/session-analyst.ts +4 -1
  39. package/src/ai/task-agent.ts +2 -2
  40. package/src/commands/context-aria-command.ts +1 -1
  41. package/src/commands/explore-command.ts +68 -23
  42. package/src/commands/test-command.ts +2 -1
  43. package/src/components/App.tsx +0 -33
  44. package/src/components/LogPane.tsx +18 -3
  45. package/src/experience-tracker.ts +2 -2
  46. package/src/explorbot.ts +1 -1
  47. package/src/reporter.ts +24 -6
  48. package/src/utils/log-filters.ts +26 -0
  49. package/src/utils/logger.ts +24 -2
  50. 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.25",
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:
@@ -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) {
@@ -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
  }
@@ -10,7 +10,6 @@ const LogPane = React.memo(({ verboseMode }) => {
10
10
  const [logs, setLogs] = useState([]);
11
11
  const pendingLogsRef = React.useRef([]);
12
12
  const flushTimeoutRef = React.useRef(null);
13
- const MAX_MULTILINE_LINES = 16;
14
13
  const MAX_STEP_LINES = 8;
15
14
  const MAX_SUBSTEP_LINES = 6;
16
15
  const formatCollapsedContent = useCallback((lines, collapsedCount, label) => {
@@ -88,6 +87,8 @@ const LogPane = React.memo(({ verboseMode }) => {
88
87
  return { color: 'yellow' };
89
88
  case 'debug':
90
89
  return { color: 'gray', dimColor: true };
90
+ case 'operation':
91
+ return { color: 'gray', dimColor: true };
91
92
  case 'substep':
92
93
  return { color: 'gray', dimColor: true };
93
94
  case 'step':
@@ -116,7 +117,8 @@ const LogPane = React.memo(({ verboseMode }) => {
116
117
  const cleaned = stripAnsi(dedent(log.content));
117
118
  const parsed = parseMarkdownToTerminal(cleaned);
118
119
  const lines = parsed.split('\n');
119
- const truncated = lines.length > MAX_MULTILINE_LINES ? `${lines.slice(0, MAX_MULTILINE_LINES).join('\n')}\n... (${lines.length - MAX_MULTILINE_LINES} more lines)` : parsed;
120
+ const maxLines = log.maxLines || 16;
121
+ const truncated = lines.length > maxLines ? `${lines.slice(0, maxLines).join('\n')}\n... (${lines.length - maxLines} more lines)` : parsed;
120
122
  return (React.createElement(Box, { key: index, borderStyle: "classic", borderLeft: false, borderRight: false, marginY: 1, padding: 1, borderColor: "dim", overflow: "hidden" },
121
123
  React.createElement(Text, { color: "gray", dimColor: true }, truncated)));
122
124
  }
@@ -127,6 +129,7 @@ const LogPane = React.memo(({ verboseMode }) => {
127
129
  type: 'multiline',
128
130
  content: `HTML Content:\n\n${markdown}`,
129
131
  timestamp: log.timestamp,
132
+ maxLines: 10,
130
133
  };
131
134
  return renderLogEntry(multilineLog, index);
132
135
  }
@@ -134,6 +137,9 @@ const LogPane = React.memo(({ verboseMode }) => {
134
137
  if (log.type === 'substep') {
135
138
  return (React.createElement(Box, { key: index, marginLeft: 2, flexDirection: "column" }, lines.map((line, lineIndex) => (React.createElement(Text, { key: `${index}-${lineIndex}`, ...styles }, lineIndex === 0 ? `> ${line}` : ` ${line}`)))));
136
139
  }
140
+ if (log.type === 'operation') {
141
+ return (React.createElement(Box, { key: index, marginLeft: 2, flexDirection: "column" }, lines.map((line, lineIndex) => (React.createElement(Text, { key: `${index}-${lineIndex}`, ...styles }, lineIndex === 0 ? `· ${line}` : ` ${line}`)))));
142
+ }
137
143
  if (log.type === 'step') {
138
144
  return (React.createElement(Box, { key: index, flexDirection: "column", paddingLeft: 2 }, lines.map((line, lineIndex) => (React.createElement(Text, { key: `${index}-${lineIndex}`, ...styles }, line)))));
139
145
  }
@@ -145,7 +151,7 @@ const LogPane = React.memo(({ verboseMode }) => {
145
151
  icon && React.createElement(Text, { ...styles }, icon),
146
152
  React.createElement(Box, { flexDirection: "column" }, lines.map((line, lineIndex) => (React.createElement(Text, { key: `${index}-${lineIndex}`, ...styles }, line))))));
147
153
  };
148
- const maxLogs = 100;
154
+ const maxLogs = 80;
149
155
  const visibleLogs = logs.length > maxLogs ? logs.slice(-maxLogs) : logs;
150
156
  return React.createElement(Box, { flexDirection: "column" }, visibleLogs.map((log, index) => renderLogEntry(log, index)).filter(Boolean));
151
157
  });
@@ -164,7 +164,7 @@ export class ExperienceTracker {
164
164
  const newEntry = generateActionContent(title, filteredCode, action.explanation);
165
165
  const updatedContent = `${newEntry}\n\n${content}`;
166
166
  this.writeExperienceFile(stateHash, updatedContent, data);
167
- tag('substep').log(` Added ACTION to: ${stateHash}.md`);
167
+ tag('operation').log(`Added ACTION to: ${stateHash}.md`);
168
168
  }
169
169
  writeFlow(state, body, relatedUrls) {
170
170
  if (this.disabled || this.isWritingDisabled(state))
@@ -190,7 +190,7 @@ export class ExperienceTracker {
190
190
  }
191
191
  const updatedContent = `${body}\n${content}`;
192
192
  this.writeExperienceFile(stateHash, updatedContent, data);
193
- tag('substep').log(`Added FLOW to: ${stateHash}.md`);
193
+ tag('operation').log(`Added FLOW to: ${stateHash}.md`);
194
194
  }
195
195
  getAllExperience() {
196
196
  const allFiles = [];
@@ -422,7 +422,7 @@ export class ExplorBot {
422
422
  this.lastReportedTestCount = tests.length;
423
423
  return;
424
424
  }
425
- tag('multiline').log(markdown);
425
+ tag('multiline').log(markdown, { maxLines: 22 });
426
426
  const filePath = this.agentSessionAnalyst().writeReport(markdown);
427
427
  tag('info').log(`Session report saved: ${relativeToCwd(filePath)}`);
428
428
  const reporter = this.explorer?.getReporter();