explorbot 0.5.3 → 0.5.5

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 (60) hide show
  1. package/boat/doc-collector/src/config.ts +1 -0
  2. package/boat/doc-collector/src/docbot.ts +10 -1
  3. package/boat/prima/README.md +5 -10
  4. package/boat/prima/package.json +1 -1
  5. package/boat/prima/src/cli.ts +8 -50
  6. package/boat/prima/src/envelope.ts +12 -6
  7. package/boat/prima/src/help.ts +4 -2
  8. package/boat/prima/src/prima.ts +58 -81
  9. package/boat/prima/src/session-log.ts +3 -3
  10. package/dist/boat/doc-collector/src/docbot.js +11 -1
  11. package/dist/boat/prima/src/cli.js +9 -45
  12. package/dist/boat/prima/src/envelope.js +12 -3
  13. package/dist/boat/prima/src/help.js +4 -2
  14. package/dist/boat/prima/src/prima.js +58 -77
  15. package/dist/boat/prima/src/session-log.js +3 -3
  16. package/dist/package.json +1 -1
  17. package/dist/rules/navigator/output.md +3 -3
  18. package/dist/src/action.js +4 -0
  19. package/dist/src/ai/driller.js +2 -28
  20. package/dist/src/ai/navigator.d.ts +5 -2
  21. package/dist/src/ai/navigator.js +23 -12
  22. package/dist/src/ai/pilot.d.ts +2 -7
  23. package/dist/src/ai/pilot.js +34 -7
  24. package/dist/src/ai/researcher.js +10 -8
  25. package/dist/src/ai/rules.d.ts +1 -0
  26. package/dist/src/ai/rules.js +7 -0
  27. package/dist/src/ai/tools.js +6 -1
  28. package/dist/src/explorbot.js +1 -1
  29. package/dist/src/test-plan.d.ts +11 -0
  30. package/dist/src/test-plan.js +21 -0
  31. package/dist/src/utils/code-extractor.d.ts +1 -0
  32. package/dist/src/utils/code-extractor.js +40 -0
  33. package/dist/src/utils/error-page.d.ts +1 -0
  34. package/dist/src/utils/error-page.js +8 -0
  35. package/dist/src/utils/html.d.ts +2 -0
  36. package/dist/src/utils/html.js +36 -1
  37. package/dist/src/utils/mdq/cli.js +3 -1
  38. package/dist/src/utils/mdq/query.d.ts +4 -1
  39. package/dist/src/utils/mdq/query.js +48 -7
  40. package/dist/src/utils/web-sandbox.js +4 -4
  41. package/docs/doc-collection/basics.md +13 -0
  42. package/docs/reference/commands.md +4 -6
  43. package/docs/web-testing/researcher.md +8 -0
  44. package/package.json +1 -1
  45. package/rules/navigator/output.md +3 -3
  46. package/src/action.ts +4 -0
  47. package/src/ai/driller.ts +2 -33
  48. package/src/ai/navigator.ts +23 -13
  49. package/src/ai/pilot.ts +35 -15
  50. package/src/ai/researcher.ts +10 -9
  51. package/src/ai/rules.ts +8 -0
  52. package/src/ai/tools.ts +5 -1
  53. package/src/explorbot.ts +1 -1
  54. package/src/test-plan.ts +23 -0
  55. package/src/utils/code-extractor.ts +41 -0
  56. package/src/utils/error-page.ts +8 -0
  57. package/src/utils/html.ts +33 -1
  58. package/src/utils/mdq/cli.ts +3 -2
  59. package/src/utils/mdq/query.ts +48 -7
  60. package/src/utils/web-sandbox.ts +4 -4
@@ -13,6 +13,7 @@ import { type StateManager, normalizeUrl } from '../state-manager.js';
13
13
  import { isFatalBrowserError } from '../utils/browser-errors.ts';
14
14
  import { getCliName } from '../utils/cli-name.ts';
15
15
  import { extractCodeBlocks } from '../utils/code-extractor.js';
16
+ import { isLoginPage } from '../utils/error-page.ts';
16
17
  import { HooksRunner } from '../utils/hooks-runner.ts';
17
18
  import { createDebug, pluralize, tag } from '../utils/logger.js';
18
19
  import { loop, pause } from '../utils/loop.js';
@@ -24,7 +25,7 @@ import type { Conversation } from './conversation.js';
24
25
  import { type Decision, JUDGE_PAGE_CAP, type Judge, UNDECIDED } from './judge.ts';
25
26
  import type { Provider } from './provider.js';
26
27
  import { Researcher } from './researcher.ts';
27
- import { actionRule, locatorRule, unexpectedPopupRule } from './rules.js';
28
+ import { actionRule, credentialsRule, locatorRule, unexpectedPopupRule } from './rules.js';
28
29
  import { isInteractive } from './task-agent.js';
29
30
  import { createLearnExperienceTool } from './tools.ts';
30
31
 
@@ -59,7 +60,7 @@ class Navigator implements Agent {
59
60
  NEVER use executeScript, executeAsyncScript, or any JS evaluation to change the URL, bypass redirects, or fake the page state.
60
61
  If the target URL redirects to an authentication/login page, DO NOT try to force the original URL. Instead:
61
62
  1. Look for credentials in the provided knowledge/hint context and perform a real login through the form.
62
- 2. If no credentials are available, ask the user for credentials or ask the user to log in manually.
63
+ 2. If no credentials are available, stop and name the credentials that are missing. Never guess them.
63
64
  A redirect to /login, /sign_in, /auth, or similar is a signal that authentication is required — treat it as such, never as an obstacle to bypass.
64
65
  </constraints>
65
66
  `;
@@ -136,11 +137,11 @@ class Navigator implements Agent {
136
137
  return matchesNavigationUrl(expectedUrl, currentUrl);
137
138
  }
138
139
 
139
- async visit(url: string): Promise<void> {
140
- return this.visitOnce(url);
140
+ async visit(url: string, opts: { recover?: boolean } = {}): Promise<boolean> {
141
+ return this.visitOnce(url, opts.recover !== false);
141
142
  }
142
143
 
143
- private async visitOnce(url: string): Promise<void> {
144
+ private async visitOnce(url: string, recover: boolean): Promise<boolean> {
144
145
  try {
145
146
  const action = this.explorer.action();
146
147
 
@@ -153,7 +154,10 @@ class Navigator implements Agent {
153
154
 
154
155
  await this.hooksRunner.runBeforeHook('navigator', url);
155
156
 
156
- if (!this.isOnExpectedPage(url, action.stateManager)) {
157
+ if (!recover && action.lastError) throw new Error(`Navigation to ${url} failed: ${action.lastError.message}`);
158
+
159
+ const arrived = this.isOnExpectedPage(url, action.stateManager);
160
+ if (recover && !arrived) {
157
161
  const actualPath = action.stateManager.getCurrentState()?.url || '';
158
162
  const actionResult = action.actionResult || ActionResult.fromState(action.stateManager.getCurrentState()!);
159
163
  const originalMessage = `Navigate to: ${url}. Current page: ${actualPath}`;
@@ -177,6 +181,7 @@ class Navigator implements Agent {
177
181
  }
178
182
  await this.explorer.capture({ screenshot: true });
179
183
  await this.hooksRunner.runAfterHook('navigator', url);
184
+ return arrived || recover;
180
185
  } catch (error) {
181
186
  const errorMessage = error instanceof Error ? error.message : String(error);
182
187
  if (errorMessage.includes('ERR_CONNECTION_REFUSED')) {
@@ -284,7 +289,7 @@ class Navigator implements Agent {
284
289
  return;
285
290
  }
286
291
  tag('operation').log('Feeding failures back to AI for a new batch...');
287
- conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, actionResult));
292
+ conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, action.actionResult ?? actionResult));
288
293
  htmlContextAdded = true;
289
294
  codeBlocks = [];
290
295
  batchFailures.length = 0;
@@ -377,6 +382,9 @@ class Navigator implements Agent {
377
382
  experience = this.experienceTracker.renderExperienceFor(actionResult);
378
383
  }
379
384
 
385
+ let credentials = '';
386
+ if (isLoginPage(actionResult)) credentials = credentialsRule;
387
+
380
388
  return dedent`
381
389
  <message>
382
390
  ${message}
@@ -384,10 +392,6 @@ class Navigator implements Agent {
384
392
 
385
393
  <page>
386
394
  ${actionResult.toAiContext()}
387
-
388
- <page_html>
389
- ${await actionResult.combinedHtml()}
390
- </page_html>
391
395
  </page>
392
396
 
393
397
  <task>
@@ -397,6 +401,8 @@ class Navigator implements Agent {
397
401
  Propose different solutions to achieve the result.
398
402
  Solution should be valid CodeceptJS code.
399
403
  Use only data from the <page> context to plan the solution.
404
+ The page is given as an ARIA snapshot only: use ARIA and text locators from it.
405
+ Full HTML is provided if these solutions fail.
400
406
  Try various ways to achieve the result
401
407
  </task>
402
408
 
@@ -404,6 +410,8 @@ class Navigator implements Agent {
404
410
 
405
411
  ${unexpectedPopupRule}
406
412
 
413
+ ${credentials}
414
+
407
415
  ${RulesLoader.loadRules('navigator', ['multiple-locator', 'output'], actionResult.url || '').replace('{{maxAttempts}}', String(this.MAX_ATTEMPTS))}
408
416
 
409
417
  ${experience}
@@ -813,7 +821,9 @@ class Navigator implements Agent {
813
821
 
814
822
  const verified = await action.attempt(codeBlock, message);
815
823
  const proof = action.assertionSteps.map(renderAssertion).filter(Boolean);
816
- results.push({ code: codeBlock, passed: verified, proof });
824
+ const result: AssertionResult = { code: codeBlock, passed: verified, proof };
825
+ if (!verified && action.lastError) result.error = action.lastError.message;
826
+ results.push(result);
817
827
 
818
828
  if (verified) {
819
829
  tag('success').log('Verification passed');
@@ -862,6 +872,6 @@ class Navigator implements Agent {
862
872
 
863
873
  type BatchFailure = { code: string; error: string; ariaChanges?: string | null; urlAfter?: string };
864
874
 
865
- export type AssertionResult = { code: string; passed: boolean; proof: string[] };
875
+ export type AssertionResult = { code: string; passed: boolean; proof: string[]; error?: string };
866
876
 
867
877
  export { Navigator };
package/src/ai/pilot.ts CHANGED
@@ -8,7 +8,7 @@ import type Explorer from '../explorer.ts';
8
8
  import type { PlaywrightRecorder } from '../playwright-recorder.ts';
9
9
  import type { StateManager } from '../state-manager.ts';
10
10
  import { Stats } from '../stats.ts';
11
- import { type Test, TestResult, TestStatus } from '../test-plan.ts';
11
+ import { SettledExpectation, type SettledStatus, type Test, TestResult, TestStatus } from '../test-plan.ts';
12
12
  import { collectInteractiveNodes } from '../utils/aria.ts';
13
13
  import { ErrorPageError } from '../utils/error-page.ts';
14
14
  import { createDebug, tag } from '../utils/logger.ts';
@@ -39,6 +39,12 @@ const OUTCOME_STATUS: Record<string, SettledStatus> = {
39
39
  'The run shows this outcome did not happen.': 'failed',
40
40
  };
41
41
 
42
+ const CONTRADICTION_RESOLUTION: Record<string, { status: SettledStatus; resolution: string }> = {
43
+ 'The page structure agrees with the screenshot, and the outcome happened.': { status: 'passed', resolution: 'the page structure agrees with the screenshot, so the run log was wrong' },
44
+ 'The page structure agrees with the screenshot, and the outcome did not happen.': { status: 'failed', resolution: 'the page structure agrees with the screenshot, so the run log was wrong' },
45
+ 'The page structure agrees with the run log: the page holds it, but the screenshot does not show it.': { status: 'contradiction', resolution: 'the page structure agrees with the run log: the page holds it but does not display it, a likely rendering bug' },
46
+ };
47
+
42
48
  export class Pilot implements Agent {
43
49
  emoji = '🧭';
44
50
  private provider: Provider;
@@ -619,7 +625,7 @@ export class Pilot implements Agent {
619
625
  let settledByJudge = new Map<string, SettledStatus>();
620
626
  if (!image) settledByJudge = await this.settleByJudge(task, undecided);
621
627
  undecided = undecided.filter((text) => !settledByJudge.has(text));
622
- if (!undecided.length) return task.expected.map((text) => ({ text, status: settledByJudge.get(text) || decided(text) }));
628
+ if (!undecided.length) return task.expected.map((text) => new SettledExpectation(text, settledByJudge.get(text) || decided(text)));
623
629
 
624
630
  const schema = z.object({
625
631
  outcomes: z.array(
@@ -691,14 +697,36 @@ export class Pilot implements Agent {
691
697
  if (!response) response = await settle(userContent, this.provider.getAgenticModel('pilot'));
692
698
 
693
699
  const judged = new Map((response?.object?.outcomes || []).map((outcome: any) => [outcome.expectation, outcome]));
694
- return task.expected.map((text) => {
700
+ const settled = task.expected.map((text) => {
695
701
  const byJudge = settledByJudge.get(text);
696
- if (byJudge) return { text, status: byJudge };
697
- if (!undecided.includes(text)) return { text, status: decided(text) };
702
+ if (byJudge) return new SettledExpectation(text, byJudge);
703
+ if (!undecided.includes(text)) return new SettledExpectation(text, decided(text));
698
704
  const outcome = judged.get(text) as { status: SettledStatus; evidence?: string } | undefined;
699
- if (!outcome) return { text, status: 'unverified' as SettledStatus };
700
- return { text, status: outcome.status || 'unverified', evidence: outcome.evidence };
705
+ if (!outcome) return new SettledExpectation(text, 'unverified');
706
+ return new SettledExpectation(text, outcome.status || 'unverified', outcome.evidence);
701
707
  });
708
+ return this.resolveContradictions(settled, finalState);
709
+ }
710
+
711
+ private async resolveContradictions(outcomes: SettledExpectation[], finalState?: ActionResult): Promise<SettledExpectation[]> {
712
+ const judge = this.judge;
713
+ if (!judge || !finalState) return outcomes;
714
+ if (!outcomes.some((outcome) => outcome.contradicted)) return outcomes;
715
+
716
+ const page = finalState.getCompactARIA().slice(0, JUDGE_PAGE_CAP);
717
+ return Promise.all(
718
+ outcomes.map(async (outcome) => {
719
+ if (!outcome.contradicted) return outcome;
720
+ const decision = await judge.decide(`The screenshot and the run log disagree about the expected outcome: ${outcome.text}. Which side does the page structure support?`, [...Object.keys(CONTRADICTION_RESOLUTION), UNDECIDED], {
721
+ expectation: outcome.text,
722
+ disagreement: outcome.evidence || '',
723
+ page,
724
+ });
725
+ const resolved = CONTRADICTION_RESOLUTION[decision.value ?? ''];
726
+ if (!resolved) return outcome;
727
+ return outcome.resolve(resolved.status, resolved.resolution);
728
+ })
729
+ );
702
730
  }
703
731
 
704
732
  private async settleByJudge(task: Test, expectations: string[]): Promise<Map<string, SettledStatus>> {
@@ -1275,11 +1303,3 @@ export class Pilot implements Agent {
1275
1303
  `;
1276
1304
  }
1277
1305
  }
1278
-
1279
- export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
1280
-
1281
- export interface SettledExpectation {
1282
- text: string;
1283
- status: SettledStatus;
1284
- evidence?: string;
1285
- }
@@ -103,8 +103,8 @@ export class Researcher extends ResearcherBase implements Agent {
103
103
  `;
104
104
  }
105
105
 
106
- async research(state: WebPageState, opts: { screenshot?: boolean; force?: boolean; deep?: boolean; data?: boolean; fix?: boolean; _retriesLeft?: number } = {}): Promise<string> {
107
- const { screenshot = false, force = false, deep = false, data = false, fix = true } = opts;
106
+ async research(state: WebPageState, opts: { screenshot?: boolean; force?: boolean; deep?: boolean; data?: boolean; fix?: boolean; light?: boolean; _retriesLeft?: number } = {}): Promise<string> {
107
+ const { screenshot = false, force = false, deep = false, data = false, fix = true, light = false } = opts;
108
108
  const maxRetries = this.settings.retries ?? 2;
109
109
  let retriesLeft = opts._retriesLeft ?? maxRetries;
110
110
  this.actionResult = ActionResult.fromState(state);
@@ -133,6 +133,7 @@ export class Researcher extends ResearcherBase implements Agent {
133
133
  return Observability.run(sessionName, { tags: ['researcher'], sessionId: stateHash }, async () => {
134
134
  const displayUrl = state.fullUrl || state.url;
135
135
  tag('info').log(`Researching ${displayUrl} to understand the context...`);
136
+ if (light) tag('info').log('Light research: single pass, locator verification skipped');
136
137
  setActivity(`${this.emoji} Researching...`, 'action');
137
138
 
138
139
  await this.ensureNavigated(displayUrl, screenshot && this.provider.hasVision());
@@ -171,7 +172,7 @@ export class Researcher extends ResearcherBase implements Agent {
171
172
  }
172
173
 
173
174
  const isOnCurrentState = this.actionResult!.getStateHash() === this.stateManager.getCurrentState()?.hash;
174
- this.hasScreenshotToAnalyze = screenshot && this.provider.hasVision() && isOnCurrentState;
175
+ this.hasScreenshotToAnalyze = !light && screenshot && this.provider.hasVision() && isOnCurrentState;
175
176
 
176
177
  const conversation = this.provider.startConversation(this.getSystemMessage(), 'researcher');
177
178
 
@@ -205,13 +206,13 @@ export class Researcher extends ResearcherBase implements Agent {
205
206
  result.parseLocators();
206
207
  debugLog(`Extracted ${result.locators.length} locators from research`);
207
208
 
208
- if (!interrupted() && result.locators.length === 0 && retriesLeft > 0) {
209
+ if (!light && !interrupted() && result.locators.length === 0 && retriesLeft > 0) {
209
210
  tag('warning').log(`No locators parsed, retrying research (${maxRetries - retriesLeft + 1}/${maxRetries})...`);
210
211
  await new Promise((r) => setTimeout(r, 1000));
211
212
  return this.research(state, { ...opts, force: true, _retriesLeft: retriesLeft - 1 } as any);
212
213
  }
213
214
 
214
- if (!interrupted()) {
215
+ if (!light && !interrupted()) {
215
216
  const brokenContainers = await this.resolveContainers(result);
216
217
  const containerCount = result.containers.length;
217
218
  if (containerCount > 0 && brokenContainers.length === containerCount && retriesLeft > 0) {
@@ -232,7 +233,7 @@ export class Researcher extends ResearcherBase implements Agent {
232
233
  }
233
234
 
234
235
  // Stage 3: Fix broken sections via AI conversation continuation
235
- if (!interrupted() && fix && result.locators.some((l) => l.valid === false)) {
236
+ if (!light && !interrupted() && fix && result.locators.some((l) => l.valid === false)) {
236
237
  await this.fixBrokenSections(result, activeConversation);
237
238
  }
238
239
 
@@ -273,12 +274,12 @@ export class Researcher extends ResearcherBase implements Agent {
273
274
  }
274
275
 
275
276
  // Stage 5: Backfill broken elements
276
- if (!interrupted()) {
277
+ if (!light && !interrupted()) {
277
278
  await this.backfillCoordinates(result);
278
279
  await this.backfillBrokenLocators(result);
279
280
  }
280
281
 
281
- if (!interrupted()) {
282
+ if (!light && !interrupted()) {
282
283
  await this.detectPagination(result);
283
284
  }
284
285
 
@@ -309,7 +310,7 @@ export class Researcher extends ResearcherBase implements Agent {
309
310
  result.cleanup();
310
311
 
311
312
  let researchFile: string | null = null;
312
- if (stateHash) {
313
+ if (stateHash && !light) {
313
314
  researchFile = saveResearch(researchState, result.text, combinedHtml);
314
315
  }
315
316
 
package/src/ai/rules.ts CHANGED
@@ -147,6 +147,14 @@ export const formRequirementsRule = dedent`
147
147
  </form_requirements>
148
148
  `;
149
149
 
150
+ export const credentialsRule = dedent`
151
+ <credentials>
152
+ Sign in to an existing account only with credentials present in the provided context (message, knowledge, experience).
153
+ Never invent, guess, or try default, common, or placeholder credentials for an existing account.
154
+ If the required credentials are not provided, call stop() and name what is missing — do not submit the form.
155
+ </credentials>
156
+ `;
157
+
150
158
  // in rage mode we do not protect from irreversible actions
151
159
  export const protectionRule = dedent`
152
160
  <important>
package/src/ai/tools.ts CHANGED
@@ -112,7 +112,7 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
112
112
  let ambiguityError: Error | null = null;
113
113
 
114
114
  for (let i = 0; i < commands.length; i++) {
115
- const command = transformContainsCommand(commands[i]);
115
+ let command = transformContainsCommand(commands[i]);
116
116
  let success = await action.attempt(command, explanation);
117
117
 
118
118
  const attempt: { command: string; success: boolean; error?: string } = { command, success };
@@ -125,10 +125,14 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
125
125
  const labels = (await extractWebElements(ambiguityError))?.map((el) => el.label) || [];
126
126
  const index = await judge.pick(PICK_ELEMENT_QUESTION, labels, { intent: explanation, task: task.description });
127
127
  if (index) success = await action.attemptExactElementIndex(command, index, explanation);
128
+ if (success) command = action.executedSteps.map((step) => step.command).join('\n') || command;
128
129
  }
129
130
  }
130
131
 
131
132
  if (success) {
133
+ attempt.command = command;
134
+ attempt.success = true;
135
+ attempt.error = undefined;
132
136
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
133
137
 
134
138
  if (!hasObservablePageChange(toolResult)) {
package/src/explorbot.ts CHANGED
@@ -155,7 +155,7 @@ export class ExplorBot {
155
155
  }
156
156
 
157
157
  async visit(url: string): Promise<void> {
158
- return this.agentNavigator().visit(this.configParser.resolveTargetPath(url));
158
+ await this.agentNavigator().visit(this.configParser.resolveTargetPath(url));
159
159
  }
160
160
 
161
161
  async openTab(): Promise<void> {
package/src/test-plan.ts CHANGED
@@ -66,6 +66,29 @@ export class ActiveNote {
66
66
  }
67
67
  }
68
68
 
69
+ export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
70
+
71
+ export class SettledExpectation {
72
+ constructor(
73
+ readonly text: string,
74
+ readonly status: SettledStatus,
75
+ readonly evidence?: string,
76
+ readonly resolution?: string
77
+ ) {}
78
+
79
+ get failed(): boolean {
80
+ return this.status === 'failed';
81
+ }
82
+
83
+ get contradicted(): boolean {
84
+ return this.status === 'contradiction';
85
+ }
86
+
87
+ resolve(status: SettledStatus, resolution: string): SettledExpectation {
88
+ return new SettledExpectation(this.text, status, this.evidence, resolution);
89
+ }
90
+ }
91
+
69
92
  export interface StepData {
70
93
  text: string;
71
94
  duration?: number;
@@ -3,6 +3,9 @@ import { createDebug } from './logger.js';
3
3
  const debugLog = createDebug('explorbot:code-extractor');
4
4
 
5
5
  const JS_LANGUAGES = new Set(['', 'js', 'javascript']);
6
+ const QUOTES = ["'", '"', '`'];
7
+ const OPENING_BRACKETS = ['(', '[', '{'];
8
+ const CLOSING_BRACKETS = [')', ']', '}'];
6
9
 
7
10
  export function extractCodeBlocks(aiResponse: string): string[] {
8
11
  const codeBlockRegex = /```([^\n`]*)\n([\s\S]*?)\n```/g;
@@ -24,3 +27,41 @@ export function extractCodeBlocks(aiResponse: string): string[] {
24
27
 
25
28
  return codeBlocks;
26
29
  }
30
+
31
+ export function splitTopLevel(code: string, separator: string): string[] {
32
+ const parts: string[] = [];
33
+ let current = '';
34
+ let depth = 0;
35
+ let quote = '';
36
+
37
+ for (let i = 0; i < code.length; i++) {
38
+ const char = code[i];
39
+
40
+ if (quote) {
41
+ current += char;
42
+ if (char === '\\') current += code[++i] || '';
43
+ if (char === quote) quote = '';
44
+ continue;
45
+ }
46
+
47
+ if (char === '/' && code[i + 1] === '/') {
48
+ while (i + 1 < code.length && code[i + 1] !== '\n') i++;
49
+ continue;
50
+ }
51
+
52
+ if (char === separator && depth <= 0) {
53
+ parts.push(current);
54
+ current = '';
55
+ depth = 0;
56
+ continue;
57
+ }
58
+
59
+ current += char;
60
+ if (QUOTES.includes(char)) quote = char;
61
+ if (OPENING_BRACKETS.includes(char)) depth++;
62
+ if (CLOSING_BRACKETS.includes(char)) depth--;
63
+ }
64
+
65
+ parts.push(current);
66
+ return parts;
67
+ }
@@ -1,11 +1,14 @@
1
1
  import { ActionResult } from '../action-result.js';
2
2
  import type { WebPageState } from '../state-manager.js';
3
3
  import { isBodyEmpty } from './html.js';
4
+ import { extractStatePath } from './url-matcher.js';
4
5
 
5
6
  const HTTP_ERRORS = ['400 Bad Request', '401 Unauthorized', '403 Forbidden', '404 Not Found', '405 Method Not Allowed', '408 Request Timeout', '500 Internal Server Error', '502 Bad Gateway', '503 Service Unavailable', '504 Gateway Timeout'];
6
7
 
7
8
  const SMALL_PAGE_THRESHOLD = 500;
8
9
  const LOADING_WORD = /\bloading\b/i;
10
+ const PASSWORD_INPUT = /<input\b[^>]*\btype\s*=\s*["']?password\b/i;
11
+ const AUTH_PATH = /\b(log[-_]?in|sign[-_]?in|auth)\b/i;
9
12
 
10
13
  export type PageCondition = 'ok' | 'loading' | 'error';
11
14
 
@@ -40,6 +43,11 @@ export function isErrorPage(actionResult: ActionResult): boolean {
40
43
  return detectPageCondition(actionResult) === 'error';
41
44
  }
42
45
 
46
+ export function isLoginPage(actionResult: ActionResult): boolean {
47
+ if (PASSWORD_INPUT.test(actionResult.html || '')) return true;
48
+ return AUTH_PATH.test(extractStatePath(actionResult.url || '').split('?')[0]);
49
+ }
50
+
43
51
  export function getStateErrorPageError(state: WebPageState | null | undefined): ErrorPageError | null {
44
52
  if (!state) return null;
45
53
  const actionResult = ActionResult.fromState(state);
package/src/utils/html.ts CHANGED
@@ -88,6 +88,7 @@ export const EXPLORBOT_ATTRS = {
88
88
  coveredBy: 'data-explorbot-covered-by',
89
89
  context: 'data-explorbot-context',
90
90
  eidx: 'data-explorbot-eidx',
91
+ hidden: 'data-explorbot-hidden',
91
92
  hit: 'data-explorbot-hit',
92
93
  variant: 'data-explorbot-variant',
93
94
  } as const;
@@ -189,6 +190,17 @@ export function captureHtmlForSnapshot(): string {
189
190
  }
190
191
  }
191
192
 
193
+ const liveElements = Array.from(document.body?.querySelectorAll('*') || []);
194
+ const clonedElements = Array.from(clone.querySelector('body')?.querySelectorAll('*') || []);
195
+ for (let i = 0; i < liveElements.length; i++) {
196
+ const source = liveElements[i];
197
+ if (source.matches('input, textarea, select, option, optgroup')) continue;
198
+ if (source.checkVisibility()) continue;
199
+ if (!source.parentElement?.checkVisibility()) continue;
200
+ if (getComputedStyle(source).display === 'contents') continue;
201
+ clonedElements[i]?.setAttribute('data-explorbot-hidden', 'true');
202
+ }
203
+
192
204
  return clone.outerHTML;
193
205
  }
194
206
 
@@ -1493,7 +1505,27 @@ function getAttribute(element: parse5TreeAdapter.Element, name: string): string
1493
1505
  return attr?.value;
1494
1506
  }
1495
1507
 
1508
+ function getImgAlt(element: parse5TreeAdapter.Element): string {
1509
+ let alt = '';
1510
+
1511
+ function processNode(node: parse5TreeAdapter.Node) {
1512
+ if (alt) return;
1513
+ if ('tagName' in node) {
1514
+ const el = node as parse5TreeAdapter.Element;
1515
+ if (el.tagName.toLowerCase() === 'img') {
1516
+ alt = getAttribute(el, 'alt') || '';
1517
+ return;
1518
+ }
1519
+ }
1520
+ if ('childNodes' in node) node.childNodes.forEach(processNode);
1521
+ }
1522
+
1523
+ processNode(element);
1524
+ return alt;
1525
+ }
1526
+
1496
1527
  function hasHiddenClass(element: parse5TreeAdapter.Element): boolean {
1528
+ if (getAttribute(element, EXPLORBOT_ATTRS.hidden)) return true;
1497
1529
  const classAttr = element.attrs.find((attr) => attr.name === 'class');
1498
1530
  if (!classAttr) return false;
1499
1531
 
@@ -1553,7 +1585,7 @@ export function extractLinks(html: string): ExtractedLink[] {
1553
1585
  if (href) {
1554
1586
  const shouldSkip = skipPrefixes.some((prefix) => href.startsWith(prefix));
1555
1587
  if (!shouldSkip) {
1556
- const rawTitle = getAttribute(element, 'aria-label') || getTextContent(element);
1588
+ const rawTitle = getAttribute(element, 'aria-label') || getTextContent(element) || getImgAlt(element);
1557
1589
  const title = sanitizeLinkTitle(rawTitle);
1558
1590
  if (title && title.length <= 100) {
1559
1591
  const key = `${href}|${title}`;
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
- import { Command } from 'commander';
2
+ import { Command, type CommanderError } from 'commander';
3
3
  import { type MarkdownQuery, MdqError, mdq } from './query.ts';
4
4
 
5
5
  const EDIT_FLAGS = ['remove', 'replace', 'insertBefore', 'insertAfter', 'prepend', 'append', 'addRow', 'addItem', 'set'] as const;
@@ -7,7 +7,7 @@ const EDIT_FLAGS = ['remove', 'replace', 'insertBefore', 'insertAfter', 'prepend
7
7
  export async function runMdq(argv: string[], readStdin: StdinReader): Promise<CliResult> {
8
8
  const program = new Command();
9
9
  program
10
- .name('mdq')
10
+ .name('mdq-cli')
11
11
  .description('query and edit markdown')
12
12
  .argument('[selector]', 'markdown selector')
13
13
  .argument('[file]', 'file to read; stdin when omitted')
@@ -31,6 +31,7 @@ export async function runMdq(argv: string[], readStdin: StdinReader): Promise<Cl
31
31
  try {
32
32
  program.parse(argv, { from: 'user' });
33
33
  } catch (error) {
34
+ if ((error as CommanderError).code === 'commander.helpDisplayed') return { output: program.helpInformation(), code: 0 };
34
35
  return { output: String((error as Error).message), code: 2 };
35
36
  }
36
37
 
@@ -13,9 +13,12 @@ export class MarkdownQuery {
13
13
  }
14
14
 
15
15
  query(selector: string, matcher?: Matcher): MarkdownQuery {
16
- const segments = parseQuery(selector);
17
- if (matcher !== undefined && segments.length > 0) segments[segments.length - 1].textMatch = this.valueMatcher(matcher);
18
- return new MarkdownQuery(this.source, this.run(this.expandSections(this.matches), segments));
16
+ const expression = parseQuery(selector);
17
+ for (const chain of expression.flat()) {
18
+ const last = chain.at(-1);
19
+ if (matcher !== undefined && last) last.textMatch = this.valueMatcher(matcher);
20
+ }
21
+ return new MarkdownQuery(this.source, this.evaluate(this.expandSections(this.matches), expression));
19
22
  }
20
23
 
21
24
  section(matcher?: Matcher, options?: SelectorOptions): MarkdownQuery {
@@ -364,6 +367,27 @@ export class MarkdownQuery {
364
367
  return expanded;
365
368
  }
366
369
 
370
+ private evaluate(candidates: MatchedRange[], expression: QueryExpression): MatchedRange[] {
371
+ const results: MatchedRange[][] = [];
372
+ for (const operands of expression) {
373
+ const matched = operands.map((chain) => this.run(candidates, chain));
374
+ if (matched.some((ranges) => ranges.length === 0)) continue;
375
+ results.push(...matched);
376
+ }
377
+ return this.union(results);
378
+ }
379
+
380
+ private union(results: MatchedRange[][]): MatchedRange[] {
381
+ if (results.length === 1) return results[0];
382
+
383
+ const unique = new Map<string, MatchedRange>();
384
+ for (const range of results.flat()) {
385
+ const key = `${range.start}:${range.length}:${Boolean(range.innerTokens)}`;
386
+ if (!unique.has(key)) unique.set(key, range);
387
+ }
388
+ return [...unique.values()].sort((a, b) => a.start - b.start || b.length - a.length);
389
+ }
390
+
367
391
  private narrow(matches: MatchedRange[], segment: QuerySegment): MatchedRange[] {
368
392
  if (segment.index !== null) {
369
393
  let index = segment.index;
@@ -449,12 +473,24 @@ export function mdq(source: Markdown): MarkdownQuery {
449
473
  return new MarkdownQuery(String(source));
450
474
  }
451
475
 
452
- export function parseQuery(input: string): QuerySegment[] {
453
- const segments: QuerySegment[] = [];
476
+ export function parseQuery(input: string): QueryExpression {
477
+ const expression: QueryExpression = [[[]]];
478
+ let chain = expression[0][0];
454
479
  SEGMENT.lastIndex = 0;
455
480
 
456
481
  while (SEGMENT.lastIndex < input.length) {
457
482
  const at = SEGMENT.lastIndex;
483
+ OPERATOR.lastIndex = at;
484
+ const operator = OPERATOR.exec(input);
485
+ if (operator) {
486
+ if (chain.length === 0) throw new MdqSelectorError(`Empty selector before ${operator[1]}`, at);
487
+ chain = [];
488
+ if (operator[1] === '||') expression.push([chain]);
489
+ if (operator[1] === '&&') expression[expression.length - 1].push(chain);
490
+ SEGMENT.lastIndex = OPERATOR.lastIndex;
491
+ continue;
492
+ }
493
+
458
494
  const match = SEGMENT.exec(input);
459
495
  if (!match) throw new MdqSelectorError(`Unexpected character "${input[at]}" in selector`, at);
460
496
 
@@ -484,10 +520,11 @@ export function parseQuery(input: string): QuerySegment[] {
484
520
  if (to) segment.slice.to = Number.parseInt(to, 10);
485
521
  }
486
522
 
487
- segments.push(segment);
523
+ chain.push(segment);
488
524
  }
489
525
 
490
- return segments;
526
+ if (expression.flat().length > 1 && chain.length === 0) throw new MdqSelectorError('Empty selector after operator', input.length);
527
+ return expression;
491
528
  }
492
529
 
493
530
  export function buildTokenIndex(source: string): MatchedRange[] {
@@ -516,6 +553,8 @@ const TOKEN_ALIASES: Record<string, string> = { item: 'list_item' };
516
553
 
517
554
  const SEGMENT = /(\s*\.?)([A-Za-z]\w*)(?:\((!?)(~?)(?:"((?:[^"\\]|\\.)*)"|\/((?:[^/\\]|\\.)*)\/([a-z]*))\))?((?:\[[^\]]*\])*)\s*/y;
518
555
 
556
+ const OPERATOR = /\s*(&&|\|\|)\s*/y;
557
+
519
558
  export type Markdown = string | MarkdownQuery;
520
559
 
521
560
  export type Matcher = string | RegExp | ((text: string) => boolean);
@@ -534,6 +573,8 @@ export interface TextMatcher {
534
573
  predicate?: (text: string) => boolean;
535
574
  }
536
575
 
576
+ export type QueryExpression = QuerySegment[][][];
577
+
537
578
  export interface QuerySegment {
538
579
  selector: SelectorType;
539
580
  textMatch?: TextMatcher;
@@ -1,6 +1,7 @@
1
1
  import { faker } from '@faker-js/faker';
2
2
  import { hopeThat, retryTo, tryTo, within } from 'codeceptjs/lib/effects';
3
3
  import step from 'codeceptjs/steps';
4
+ import { splitTopLevel } from './code-extractor.ts';
4
5
 
5
6
  const SHADOWED_GLOBALS = ['process', 'global', 'globalThis', 'fetch', 'Bun', 'require', 'module', 'exports'];
6
7
  const ALLOWED_COMMAND_HEADS = ['I.', 'page.', 'await page.', 'await I.'];
@@ -9,10 +10,9 @@ const PLAYWRIGHT_ARG_NAMES = ['page'];
9
10
  const CODECEPT_ARG_NAMES = ['I', 'tryTo', 'retryTo', 'within', 'hopeThat', 'step', 'faker'];
10
11
 
11
12
  export function sanitizeCodeBlock(code: string): string {
12
- return code
13
- .split('\n')
14
- .map((line) => line.trim())
15
- .filter((line) => ALLOWED_COMMAND_HEADS.some((head) => line.startsWith(head)))
13
+ return splitTopLevel(code, '\n')
14
+ .map((statement) => statement.trim())
15
+ .filter((statement) => ALLOWED_COMMAND_HEADS.some((head) => statement.startsWith(head)))
16
16
  .join('\n');
17
17
  }
18
18