explorbot 0.1.32-beta.1 → 0.2.1
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.
- package/README.md +35 -19
- package/bin/explorbot-cli.ts +33 -10
- package/boat/api-tester/src/config.ts +45 -3
- package/boat/doc-collector/src/ai/documentarian.ts +26 -28
- package/boat/doc-collector/src/ai/tools.ts +74 -16
- package/boat/doc-collector/src/cli.ts +1 -6
- package/boat/doc-collector/src/docbot.ts +30 -9
- package/boat/doc-collector/src/docs-renderer.ts +19 -24
- package/boat/doc-collector/src/screenshots.ts +34 -2
- package/boat/doc-collector/src/state-diagram.ts +281 -0
- package/dist/bin/explorbot-cli.js +29 -9
- package/dist/boat/api-tester/src/config.js +43 -4
- package/dist/boat/doc-collector/src/ai/documentarian.js +19 -7
- package/dist/boat/doc-collector/src/ai/tools.js +53 -16
- package/dist/boat/doc-collector/src/cli.js +1 -5
- package/dist/boat/doc-collector/src/docbot.js +27 -8
- package/dist/boat/doc-collector/src/docs-renderer.js +18 -3
- package/dist/boat/doc-collector/src/screenshots.js +30 -1
- package/dist/boat/doc-collector/src/state-diagram.js +200 -0
- package/dist/models.json +30 -0
- package/dist/package.json +23 -3
- package/dist/src/action-result.d.ts +8 -10
- package/dist/src/action-result.js +72 -164
- package/dist/src/action.d.ts +13 -12
- package/dist/src/action.js +37 -113
- package/dist/src/ai/agent.d.ts +17 -0
- package/dist/src/ai/captain/idle-mode.js +1 -1
- package/dist/src/ai/captain/test-mode.js +1 -1
- package/dist/src/ai/captain/web-mode.js +18 -29
- package/dist/src/ai/captain.d.ts +1 -3
- package/dist/src/ai/captain.js +17 -45
- package/dist/src/ai/conversation.d.ts +2 -5
- package/dist/src/ai/conversation.js +9 -28
- package/dist/src/ai/driller.d.ts +2 -17
- package/dist/src/ai/driller.js +26 -50
- package/dist/src/ai/experience-compactor.d.ts +2 -2
- package/dist/src/ai/experience-compactor.js +16 -33
- package/dist/src/ai/fisherman-tools.js +1 -1
- package/dist/src/ai/fisherman.d.ts +0 -1
- package/dist/src/ai/fisherman.js +0 -3
- package/dist/src/ai/historian/codeceptjs.d.ts +2 -0
- package/dist/src/ai/historian/codeceptjs.js +4 -3
- package/dist/src/ai/historian/playwright.d.ts +4 -1
- package/dist/src/ai/historian/playwright.js +4 -3
- package/dist/src/ai/historian/screencast.d.ts +2 -1
- package/dist/src/ai/historian/screencast.js +2 -2
- package/dist/src/ai/historian/utils.d.ts +0 -1
- package/dist/src/ai/historian/utils.js +0 -1
- package/dist/src/ai/historian.d.ts +10 -8
- package/dist/src/ai/historian.js +1 -5
- package/dist/src/ai/navigator.d.ts +8 -6
- package/dist/src/ai/navigator.js +34 -65
- package/dist/src/ai/pilot.d.ts +13 -8
- package/dist/src/ai/pilot.js +53 -59
- package/dist/src/ai/planner.d.ts +5 -4
- package/dist/src/ai/planner.js +66 -52
- package/dist/src/ai/provider.d.ts +4 -1
- package/dist/src/ai/provider.js +77 -137
- package/dist/src/ai/quartermaster.d.ts +22 -22
- package/dist/src/ai/quartermaster.js +8 -14
- package/dist/src/ai/rerunner.d.ts +3 -13
- package/dist/src/ai/rerunner.js +23 -44
- package/dist/src/ai/researcher/cache.js +11 -18
- package/dist/src/ai/researcher/coordinates.js +4 -3
- package/dist/src/ai/researcher/deep-analysis.d.ts +2 -0
- package/dist/src/ai/researcher/deep-analysis.js +8 -11
- package/dist/src/ai/researcher/locators.d.ts +9 -2
- package/dist/src/ai/researcher/locators.js +65 -8
- package/dist/src/ai/researcher/parser.js +1 -1
- package/dist/src/ai/researcher/research-result.d.ts +0 -1
- package/dist/src/ai/researcher/research-result.js +0 -19
- package/dist/src/ai/researcher/sections.d.ts +2 -0
- package/dist/src/ai/researcher/sections.js +7 -3
- package/dist/src/ai/researcher.js +40 -106
- package/dist/src/ai/rules.d.ts +1 -0
- package/dist/src/ai/rules.js +22 -15
- package/dist/src/ai/session-analyst.js +8 -5
- package/dist/src/ai/task-agent.d.ts +19 -6
- package/dist/src/ai/task-agent.js +37 -33
- package/dist/src/ai/tester.d.ts +6 -14
- package/dist/src/ai/tester.js +46 -111
- package/dist/src/ai/tools.d.ts +26 -14
- package/dist/src/ai/tools.js +138 -132
- package/dist/src/command-handler.js +1 -1
- package/dist/src/commands/compact-command.d.ts +1 -1
- package/dist/src/commands/compact-command.js +6 -6
- package/dist/src/commands/context-aria-command.js +1 -1
- package/dist/src/commands/context-command.js +8 -6
- package/dist/src/commands/context-data-command.js +2 -2
- package/dist/src/commands/context-experience-command.js +2 -2
- package/dist/src/commands/context-html-command.js +2 -2
- package/dist/src/commands/context-knowledge-command.js +2 -2
- package/dist/src/commands/drill-command.js +1 -1
- package/dist/src/commands/experience-command.js +1 -1
- package/dist/src/commands/explore-command.js +4 -3
- package/dist/src/commands/freesail-command.js +2 -2
- package/dist/src/commands/knows-command.js +1 -1
- package/dist/src/commands/learn-command.js +5 -3
- package/dist/src/commands/path-command.js +1 -1
- package/dist/src/commands/research-command.js +1 -1
- package/dist/src/commands/test-command.js +1 -1
- package/dist/src/components/AddKnowledge.d.ts +2 -0
- package/dist/src/components/AddKnowledge.js +3 -7
- package/dist/src/components/App.js +3 -3
- package/dist/src/config.d.ts +19 -0
- package/dist/src/config.js +156 -10
- package/dist/src/experience-tracker.d.ts +11 -6
- package/dist/src/experience-tracker.js +82 -155
- package/dist/src/explorbot.d.ts +21 -13
- package/dist/src/explorbot.js +84 -80
- package/dist/src/explorer.d.ts +66 -102
- package/dist/src/explorer.js +332 -623
- package/dist/src/knowledge-tracker.d.ts +2 -2
- package/dist/src/knowledge-tracker.js +38 -45
- package/dist/src/state-manager.d.ts +10 -30
- package/dist/src/state-manager.js +11 -129
- package/dist/src/utils/aria.d.ts +6 -1
- package/dist/src/utils/aria.js +4 -1
- package/dist/src/utils/cache.d.ts +15 -0
- package/dist/src/utils/cache.js +34 -0
- package/dist/src/utils/context-formatter.d.ts +6 -0
- package/dist/src/utils/context-formatter.js +15 -27
- package/dist/src/utils/hooks-runner.js +2 -4
- package/dist/src/utils/html.d.ts +5 -0
- package/dist/src/utils/html.js +71 -0
- package/dist/src/utils/markdown-files.d.ts +10 -0
- package/dist/src/utils/markdown-files.js +21 -0
- package/dist/src/utils/markdown-query.d.ts +6 -0
- package/dist/src/utils/markdown-query.js +14 -1
- package/dist/src/utils/page-readiness.d.ts +1 -0
- package/dist/src/utils/page-readiness.js +1 -1
- package/dist/src/utils/secrets.js +2 -2
- package/dist/src/utils/strings.d.ts +2 -0
- package/dist/src/utils/strings.js +10 -0
- package/dist/src/utils/test-files.js +1 -1
- package/dist/src/utils/web-annotate.d.ts +5 -0
- package/dist/src/utils/web-annotate.js +58 -0
- package/dist/src/utils/web-eidx.d.ts +2 -0
- package/dist/src/utils/web-eidx.js +20 -0
- package/dist/src/utils/web-element.d.ts +3 -1
- package/dist/src/utils/web-element.js +32 -0
- package/dist/src/utils/web-sandbox.d.ts +4 -0
- package/dist/src/utils/web-sandbox.js +37 -0
- package/models.json +30 -0
- package/package.json +23 -3
- package/src/action-result.ts +70 -173
- package/src/action.ts +38 -132
- package/src/ai/agent.ts +20 -0
- package/src/ai/captain/idle-mode.ts +1 -1
- package/src/ai/captain/test-mode.ts +1 -1
- package/src/ai/captain/web-mode.ts +18 -30
- package/src/ai/captain.ts +17 -42
- package/src/ai/conversation.ts +9 -32
- package/src/ai/driller.ts +53 -83
- package/src/ai/experience-compactor.ts +16 -35
- package/src/ai/fisherman-tools.ts +1 -1
- package/src/ai/fisherman.ts +0 -4
- package/src/ai/historian/codeceptjs.ts +5 -3
- package/src/ai/historian/playwright.ts +7 -4
- package/src/ai/historian/screencast.ts +4 -3
- package/src/ai/historian/utils.ts +0 -1
- package/src/ai/historian.ts +6 -8
- package/src/ai/navigator.ts +39 -70
- package/src/ai/pilot.ts +59 -61
- package/src/ai/planner.ts +76 -62
- package/src/ai/provider.ts +85 -146
- package/src/ai/quartermaster.ts +39 -45
- package/src/ai/rerunner.ts +27 -50
- package/src/ai/researcher/cache.ts +10 -16
- package/src/ai/researcher/coordinates.ts +4 -3
- package/src/ai/researcher/deep-analysis.ts +10 -11
- package/src/ai/researcher/locators.ts +66 -9
- package/src/ai/researcher/parser.ts +1 -1
- package/src/ai/researcher/research-result.ts +0 -18
- package/src/ai/researcher/sections.ts +9 -3
- package/src/ai/researcher.ts +42 -119
- package/src/ai/rules.ts +24 -13
- package/src/ai/session-analyst.ts +8 -5
- package/src/ai/task-agent.ts +45 -38
- package/src/ai/tester.ts +51 -129
- package/src/ai/tools.ts +145 -153
- package/src/command-handler.ts +1 -1
- package/src/commands/compact-command.ts +7 -8
- package/src/commands/context-aria-command.ts +1 -1
- package/src/commands/context-command.ts +8 -6
- package/src/commands/context-data-command.ts +2 -2
- package/src/commands/context-experience-command.ts +2 -2
- package/src/commands/context-html-command.ts +2 -2
- package/src/commands/context-knowledge-command.ts +2 -2
- package/src/commands/drill-command.ts +1 -1
- package/src/commands/experience-command.ts +1 -1
- package/src/commands/explore-command.ts +4 -3
- package/src/commands/freesail-command.ts +2 -2
- package/src/commands/knows-command.ts +1 -1
- package/src/commands/learn-command.ts +5 -3
- package/src/commands/path-command.ts +1 -1
- package/src/commands/research-command.ts +1 -1
- package/src/commands/test-command.ts +1 -1
- package/src/components/AddKnowledge.tsx +5 -7
- package/src/components/App.tsx +3 -3
- package/src/config.ts +185 -11
- package/src/experience-tracker.ts +89 -152
- package/src/explorbot.ts +88 -83
- package/src/explorer.ts +402 -693
- package/src/knowledge-tracker.ts +42 -49
- package/src/state-manager.ts +20 -155
- package/src/utils/aria.ts +11 -2
- package/src/utils/cache.ts +40 -0
- package/src/utils/context-formatter.ts +12 -15
- package/src/utils/hooks-runner.ts +2 -4
- package/src/utils/html.ts +79 -0
- package/src/utils/markdown-files.ts +30 -0
- package/src/utils/markdown-query.ts +15 -1
- package/src/utils/page-readiness.ts +1 -1
- package/src/utils/secrets.ts +2 -3
- package/src/utils/strings.ts +12 -0
- package/src/utils/test-files.ts +1 -1
- package/src/utils/web-annotate.ts +64 -0
- package/src/utils/web-eidx.ts +21 -0
- package/src/utils/web-element.ts +34 -0
- package/src/utils/web-sandbox.ts +43 -0
- package/dist/boat/api-tester/example/apibot.config.js +0 -30
package/dist/src/ai/rerunner.js
CHANGED
|
@@ -14,10 +14,10 @@ import { ActionResult } from "../action-result.js";
|
|
|
14
14
|
import { setActivity } from "../activity.js";
|
|
15
15
|
import { Stats } from "../stats.js";
|
|
16
16
|
import { Task, Test, TestResult } from "../test-plan.js";
|
|
17
|
+
import { formatHeadings } from "../utils/context-formatter.js";
|
|
17
18
|
import { createDebug, tag } from "../utils/logger.js";
|
|
18
19
|
import { loop } from "../utils/loop.js";
|
|
19
20
|
import { RulesLoader } from "../utils/rules-loader.js";
|
|
20
|
-
import { loadTestSuites, printTestList } from "../utils/test-files.js";
|
|
21
21
|
import { toolExecutionLabel } from "./conversation.js";
|
|
22
22
|
import { actionRule, locatorRule, sectionContextRule } from "./rules.js";
|
|
23
23
|
import { TaskAgent } from "./task-agent.js";
|
|
@@ -26,31 +26,19 @@ const debugLog = createDebug('explorbot:rerunner');
|
|
|
26
26
|
export class Rerunner extends TaskAgent {
|
|
27
27
|
ACTION_TOOLS = ['click', 'pressKey', 'form'];
|
|
28
28
|
emoji = '🔄';
|
|
29
|
-
explorer;
|
|
30
|
-
provider;
|
|
31
29
|
agentTools;
|
|
32
30
|
healedSteps = [];
|
|
33
31
|
traceDir = '';
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
this.provider = provider;
|
|
32
|
+
static pluginsWired = false;
|
|
33
|
+
constructor(deps, agentTools) {
|
|
34
|
+
super(deps);
|
|
38
35
|
this.agentTools = agentTools;
|
|
39
36
|
}
|
|
40
37
|
getNavigator() {
|
|
41
38
|
throw new Error('Rerunner does not use Navigator');
|
|
42
39
|
}
|
|
43
|
-
getExperienceTracker() {
|
|
44
|
-
return this.explorer.getStateManager().getExperienceTracker();
|
|
45
|
-
}
|
|
46
|
-
getKnowledgeTracker() {
|
|
47
|
-
return this.explorer.getKnowledgeTracker();
|
|
48
|
-
}
|
|
49
|
-
getProvider() {
|
|
50
|
-
return this.provider;
|
|
51
|
-
}
|
|
52
40
|
get rerunnerConfig() {
|
|
53
|
-
return this.
|
|
41
|
+
return this.config.ai?.agents?.rerunner || {};
|
|
54
42
|
}
|
|
55
43
|
get healLimit() {
|
|
56
44
|
return this.rerunnerConfig.healLimit ?? 3;
|
|
@@ -58,9 +46,6 @@ export class Rerunner extends TaskAgent {
|
|
|
58
46
|
get healMaxIterations() {
|
|
59
47
|
return this.rerunnerConfig.healMaxIterations ?? 3;
|
|
60
48
|
}
|
|
61
|
-
listTests(testsDir) {
|
|
62
|
-
printTestList(loadTestSuites(testsDir));
|
|
63
|
-
}
|
|
64
49
|
async rerun(filePath, options) {
|
|
65
50
|
const absPath = resolve(filePath);
|
|
66
51
|
if (!existsSync(absPath)) {
|
|
@@ -191,7 +176,6 @@ export class Rerunner extends TaskAgent {
|
|
|
191
176
|
}
|
|
192
177
|
setupPlugins() {
|
|
193
178
|
const healMod = heal.default || heal;
|
|
194
|
-
healMod.connectToEvents();
|
|
195
179
|
healMod.addRecipe('explorbot-ai-healer', {
|
|
196
180
|
priority: 10,
|
|
197
181
|
fn: async (context) => {
|
|
@@ -202,20 +186,26 @@ export class Rerunner extends TaskAgent {
|
|
|
202
186
|
for (const [name, recipe] of Object.entries(userRecipes)) {
|
|
203
187
|
healMod.addRecipe(name, recipe);
|
|
204
188
|
}
|
|
189
|
+
const outputDir = global.output_dir || 'output';
|
|
190
|
+
this.traceDir = `${outputDir}/rerun-traces`;
|
|
191
|
+
if (Rerunner.pluginsWired)
|
|
192
|
+
return;
|
|
193
|
+
Rerunner.pluginsWired = true;
|
|
194
|
+
healMod.connectToEvents();
|
|
205
195
|
let currentTest = null;
|
|
206
196
|
let healTries = 0;
|
|
207
197
|
let isHealing = false;
|
|
208
198
|
let caughtError = null;
|
|
209
|
-
const healLimit = this.healLimit;
|
|
210
199
|
codeceptjs.event.dispatcher.on('test.before', (test) => {
|
|
211
200
|
currentTest = test;
|
|
212
201
|
healTries = 0;
|
|
213
202
|
caughtError = null;
|
|
203
|
+
isHealing = false;
|
|
214
204
|
});
|
|
215
205
|
codeceptjs.event.dispatcher.on('step.after', (step) => {
|
|
216
206
|
if (isHealing)
|
|
217
207
|
return;
|
|
218
|
-
if (healTries >= healLimit)
|
|
208
|
+
if (healTries >= this.healLimit)
|
|
219
209
|
return;
|
|
220
210
|
if (!healMod.hasCorrespondingRecipes(step))
|
|
221
211
|
return;
|
|
@@ -249,9 +239,6 @@ export class Rerunner extends TaskAgent {
|
|
|
249
239
|
maxTimeout: 5000,
|
|
250
240
|
factor: 1.5,
|
|
251
241
|
});
|
|
252
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
253
|
-
const outputDir = global.output_dir || 'output';
|
|
254
|
-
this.traceDir = `${outputDir}/rerun_${timestamp}`;
|
|
255
242
|
const aiTrace = aiTracePlugin.default || aiTracePlugin;
|
|
256
243
|
aiTrace(this.rerunnerConfig.aiTrace || { output: this.traceDir });
|
|
257
244
|
import('@testomatio/reporter/codecept')
|
|
@@ -263,9 +250,9 @@ export class Rerunner extends TaskAgent {
|
|
|
263
250
|
}
|
|
264
251
|
teardownHealing() {
|
|
265
252
|
const healMod = heal.default || heal;
|
|
266
|
-
healMod.recipes
|
|
253
|
+
Reflect.deleteProperty(healMod.recipes, 'explorbot-ai-healer');
|
|
267
254
|
for (const name of Object.keys(this.rerunnerConfig.recipes || {})) {
|
|
268
|
-
healMod.recipes
|
|
255
|
+
Reflect.deleteProperty(healMod.recipes, name);
|
|
269
256
|
}
|
|
270
257
|
}
|
|
271
258
|
async healStep(step, error) {
|
|
@@ -282,7 +269,7 @@ export class Rerunner extends TaskAgent {
|
|
|
282
269
|
},
|
|
283
270
|
});
|
|
284
271
|
const healTask = new Task(`Heal: ${failedCode}`);
|
|
285
|
-
const codeceptTools = createCodeceptJSTools(this.
|
|
272
|
+
const codeceptTools = createCodeceptJSTools(this.toolDeps, healTask);
|
|
286
273
|
let healed = false;
|
|
287
274
|
let healedCommand = '';
|
|
288
275
|
const tools = {
|
|
@@ -300,9 +287,9 @@ export class Rerunner extends TaskAgent {
|
|
|
300
287
|
healTask.addNote(note);
|
|
301
288
|
tag('substep').log(note);
|
|
302
289
|
}
|
|
303
|
-
const action = this.explorer.
|
|
290
|
+
const action = this.explorer.action();
|
|
304
291
|
await action.execute(`I.wait(${seconds})`);
|
|
305
|
-
const state = this.
|
|
292
|
+
const state = this.stateManager.getCurrentState();
|
|
306
293
|
const ar = state ? ActionResult.fromState(state) : null;
|
|
307
294
|
return {
|
|
308
295
|
success: true,
|
|
@@ -378,13 +365,13 @@ export class Rerunner extends TaskAgent {
|
|
|
378
365
|
if (!healed) {
|
|
379
366
|
throw new Error(`Could not heal: ${failedCode}`);
|
|
380
367
|
}
|
|
381
|
-
this.healedSteps.push({
|
|
368
|
+
this.healedSteps.push({ original: failedCode, healed: healedCommand });
|
|
382
369
|
console.log(chalk.green(` ${figureSet.tick} Healed: ${healedCommand}`));
|
|
383
370
|
};
|
|
384
371
|
}
|
|
385
372
|
getHealSystemPrompt() {
|
|
386
|
-
const customRules = this.provider.getSystemPromptForAgent('rerunner', this.
|
|
387
|
-
const currentUrl = this.
|
|
373
|
+
const customRules = this.provider.getSystemPromptForAgent('rerunner', this.stateManager.getCurrentState()?.url) || '';
|
|
374
|
+
const currentUrl = this.stateManager.getCurrentState()?.url || '';
|
|
388
375
|
const approach = RulesLoader.loadRules('rerunner', ['healing-approach'], currentUrl);
|
|
389
376
|
return dedent `
|
|
390
377
|
<role>
|
|
@@ -417,17 +404,9 @@ export class Rerunner extends TaskAgent {
|
|
|
417
404
|
`;
|
|
418
405
|
}
|
|
419
406
|
getHealUserPrompt(failedCode, error) {
|
|
420
|
-
const state = this.
|
|
407
|
+
const state = this.stateManager.getCurrentState();
|
|
421
408
|
const actionResult = state ? ActionResult.fromState(state) : null;
|
|
422
|
-
const headings =
|
|
423
|
-
if (state?.h1)
|
|
424
|
-
headings.push(`H1: ${state.h1}`);
|
|
425
|
-
if (state?.h2)
|
|
426
|
-
headings.push(`H2: ${state.h2}`);
|
|
427
|
-
if (state?.h3)
|
|
428
|
-
headings.push(`H3: ${state.h3}`);
|
|
429
|
-
if (state?.h4)
|
|
430
|
-
headings.push(`H4: ${state.h4}`);
|
|
409
|
+
const headings = formatHeadings(state || {});
|
|
431
410
|
return dedent `
|
|
432
411
|
A test step failed and needs healing.
|
|
433
412
|
|
|
@@ -2,14 +2,14 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'no
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { Worker } from 'node:worker_threads';
|
|
4
4
|
import { outputPath } from "../../config.js";
|
|
5
|
+
import { TTLCache } from "../../utils/cache.js";
|
|
5
6
|
import { computeHtmlFingerprint } from "../../utils/html-diff.js";
|
|
6
7
|
import { debugLog } from "./mixin.js";
|
|
7
8
|
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
8
9
|
const FINGERPRINT_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
|
9
10
|
const FINGERPRINT_WORKER_TIMEOUT_MS = 10_000;
|
|
10
11
|
const SIMILARITY_THRESHOLD = 90;
|
|
11
|
-
const memoryCache =
|
|
12
|
-
const memoryCacheTimestamps = {};
|
|
12
|
+
const memoryCache = new TTLCache(CACHE_TTL_MS);
|
|
13
13
|
let fingerprintWorker = null;
|
|
14
14
|
function getStatesDir() {
|
|
15
15
|
return outputPath('states');
|
|
@@ -22,29 +22,23 @@ function getFingerprintWorker() {
|
|
|
22
22
|
return fingerprintWorker;
|
|
23
23
|
}
|
|
24
24
|
export function clearResearchCache() {
|
|
25
|
-
|
|
26
|
-
delete memoryCache[key];
|
|
27
|
-
for (const key of Object.keys(memoryCacheTimestamps))
|
|
28
|
-
delete memoryCacheTimestamps[key];
|
|
25
|
+
memoryCache.clear();
|
|
29
26
|
}
|
|
30
27
|
export function getCachedResearch(hash) {
|
|
31
28
|
if (!hash)
|
|
32
29
|
return '';
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return memoryCache[hash] || '';
|
|
37
|
-
}
|
|
30
|
+
const cached = memoryCache.get(hash);
|
|
31
|
+
if (cached !== undefined)
|
|
32
|
+
return cached;
|
|
38
33
|
const researchFile = outputPath('research', `${hash}.md`);
|
|
39
34
|
if (!existsSync(researchFile))
|
|
40
35
|
return '';
|
|
41
36
|
const stats = statSync(researchFile);
|
|
42
|
-
if (now - stats.mtimeMs > CACHE_TTL_MS)
|
|
37
|
+
if (Date.now() - stats.mtimeMs > CACHE_TTL_MS)
|
|
43
38
|
return '';
|
|
44
|
-
const
|
|
45
|
-
memoryCache
|
|
46
|
-
|
|
47
|
-
return cached;
|
|
39
|
+
const fromDisk = readFileSync(researchFile, 'utf8');
|
|
40
|
+
memoryCache.set(hash, fromDisk);
|
|
41
|
+
return fromDisk;
|
|
48
42
|
}
|
|
49
43
|
export function getPreviousResearch(hash) {
|
|
50
44
|
if (!hash)
|
|
@@ -60,8 +54,7 @@ export function saveResearch(hash, text, combinedHtml) {
|
|
|
60
54
|
if (!existsSync(researchDir))
|
|
61
55
|
mkdirSync(researchDir, { recursive: true });
|
|
62
56
|
writeFileSync(researchFile, text);
|
|
63
|
-
memoryCache
|
|
64
|
-
memoryCacheTimestamps[hash] = Date.now();
|
|
57
|
+
memoryCache.set(hash, text);
|
|
65
58
|
debugLog(`Research saved to ${researchFile}`);
|
|
66
59
|
if (combinedHtml) {
|
|
67
60
|
const statesDir = getStatesDir();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import dedent from 'dedent';
|
|
2
2
|
import { tag } from '../../utils/logger.js';
|
|
3
3
|
import { mdq } from "../../utils/markdown-query.js";
|
|
4
|
+
import { eidxByLocator } from "../../utils/web-eidx.js";
|
|
4
5
|
import { WebElement } from "../../utils/web-element.js";
|
|
5
6
|
import { debugLog } from "./mixin.js";
|
|
6
7
|
import { parseResearchSections } from "./parser.js";
|
|
@@ -67,7 +68,7 @@ export function WithCoordinates(Base) {
|
|
|
67
68
|
return this._analyzeScreenshotForVisualProps();
|
|
68
69
|
}
|
|
69
70
|
async visuallyAnnotateElements(opts) {
|
|
70
|
-
return this.explorer.
|
|
71
|
+
return this.explorer.withPage((page) => visuallyAnnotateContainers(page, opts?.containers || []));
|
|
71
72
|
}
|
|
72
73
|
async _analyzeScreenshotForVisualProps() {
|
|
73
74
|
const elements = new Map();
|
|
@@ -160,7 +161,7 @@ export function WithCoordinates(Base) {
|
|
|
160
161
|
if (!eidx) {
|
|
161
162
|
const locator = el.css || el.xpath || (el.aria ? `role=${el.aria.role}[name="${el.aria.text}"]` : null);
|
|
162
163
|
if (locator)
|
|
163
|
-
eidx = await this.explorer.
|
|
164
|
+
eidx = await this.explorer.withPage((page) => eidxByLocator(page, locator, section.containerCss));
|
|
164
165
|
}
|
|
165
166
|
if (!eidx)
|
|
166
167
|
continue;
|
|
@@ -187,7 +188,7 @@ export function WithCoordinates(Base) {
|
|
|
187
188
|
}
|
|
188
189
|
if (eidxWithoutCoords.length === 0)
|
|
189
190
|
return;
|
|
190
|
-
const webElements = await this.explorer.
|
|
191
|
+
const webElements = await this.explorer.withPage((page) => WebElement.fromEidxList(page, eidxWithoutCoords));
|
|
191
192
|
if (webElements.length === 0)
|
|
192
193
|
return;
|
|
193
194
|
const rectMap = new Map(webElements.map((w) => [w.eidx, w]));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ActionResult, type Diff } from '../../action-result.js';
|
|
2
|
+
import type { ExplorbotConfig } from '../../config.js';
|
|
2
3
|
import type Explorer from '../../explorer.js';
|
|
3
4
|
import type { StateManager } from '../../state-manager.js';
|
|
4
5
|
import { WebPageState } from '../../state-manager.js';
|
|
@@ -11,6 +12,7 @@ export declare function WithDeepAnalysis<T extends Constructor>(Base: T): {
|
|
|
11
12
|
explorer: Explorer;
|
|
12
13
|
provider: Provider;
|
|
13
14
|
stateManager: StateManager;
|
|
15
|
+
config: ExplorbotConfig;
|
|
14
16
|
actionResult: ActionResult | undefined;
|
|
15
17
|
performDeepAnalysis(state: WebPageState, result: ResearchResult): Promise<void>;
|
|
16
18
|
researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null>;
|
|
@@ -14,7 +14,7 @@ export function WithDeepAnalysis(Base) {
|
|
|
14
14
|
async performDeepAnalysis(state, result) {
|
|
15
15
|
tag('info').log('Starting deep analysis of expandable elements');
|
|
16
16
|
await this.navigateTo(state.fullUrl || state.url);
|
|
17
|
-
const maxClicks = this.
|
|
17
|
+
const maxClicks = this.config.ai?.agents?.researcher?.maxExpandableClicks ?? DEFAULT_MAX_EXPANDABLE_CLICKS;
|
|
18
18
|
const expandedSections = [];
|
|
19
19
|
const navigationLinks = [];
|
|
20
20
|
let verifiedCodes = [];
|
|
@@ -74,7 +74,6 @@ export function WithDeepAnalysis(Base) {
|
|
|
74
74
|
return null;
|
|
75
75
|
}
|
|
76
76
|
const diff = await current.diff(previous);
|
|
77
|
-
await diff.calculate();
|
|
78
77
|
if (!diff.ariaChanged && diff.htmlParts.length === 0) {
|
|
79
78
|
debugLog(`No diff between current and previous state for overlay "${focusArea.name}"`);
|
|
80
79
|
return null;
|
|
@@ -312,12 +311,11 @@ export function WithDeepAnalysis(Base) {
|
|
|
312
311
|
const isCoordinateClick = el.commands[0].startsWith('I.clickXY(');
|
|
313
312
|
if (!isCoordinateClick) {
|
|
314
313
|
const hoverCmd = el.commands[0].replace('I.click(', 'I.moveCursorTo(');
|
|
315
|
-
await this.explorer.
|
|
314
|
+
await this.explorer.action().attempt(hoverCmd);
|
|
316
315
|
await new Promise((r) => setTimeout(r, 500));
|
|
317
|
-
await this.explorer.
|
|
316
|
+
await this.explorer.capture();
|
|
318
317
|
const hoverAR = ActionResult.fromState(this.stateManager.getCurrentState());
|
|
319
318
|
const hoverDiff = await hoverAR.diff(previousState);
|
|
320
|
-
await hoverDiff.calculate();
|
|
321
319
|
const hoverHtmlSize = hoverDiff.htmlParts.reduce((sum, p) => sum + p.subtree.length, 0);
|
|
322
320
|
const hoverRevealed = hoverDiff.ariaChanged && hoverHtmlSize > 500;
|
|
323
321
|
if (hoverRevealed) {
|
|
@@ -353,9 +351,9 @@ export function WithDeepAnalysis(Base) {
|
|
|
353
351
|
async _executeAndAnalyze(commands, description, state, originalAria, alreadyExpanded) {
|
|
354
352
|
const previousState = ActionResult.fromState(this.stateManager.getCurrentState());
|
|
355
353
|
let clickCode = null;
|
|
356
|
-
const action = this.explorer.
|
|
354
|
+
const action = this.explorer.action();
|
|
357
355
|
for (const cmd of commands) {
|
|
358
|
-
if (await action.attempt(cmd, undefined
|
|
356
|
+
if (await action.attempt(cmd, undefined)) {
|
|
359
357
|
clickCode = cmd;
|
|
360
358
|
break;
|
|
361
359
|
}
|
|
@@ -367,10 +365,9 @@ export function WithDeepAnalysis(Base) {
|
|
|
367
365
|
await new Promise((r) => setTimeout(r, 500));
|
|
368
366
|
let diff;
|
|
369
367
|
try {
|
|
370
|
-
await this.explorer.
|
|
368
|
+
await this.explorer.capture();
|
|
371
369
|
const currAR = ActionResult.fromState(this.stateManager.getCurrentState());
|
|
372
370
|
diff = await currAR.diff(previousState);
|
|
373
|
-
await diff.calculate();
|
|
374
371
|
}
|
|
375
372
|
catch (err) {
|
|
376
373
|
tag('warning').log(`State capture failed after click: ${err instanceof Error ? err.message : err}`);
|
|
@@ -397,9 +394,9 @@ export function WithDeepAnalysis(Base) {
|
|
|
397
394
|
async _restorePageState(url, originalAria) {
|
|
398
395
|
try {
|
|
399
396
|
await this.cancelInUi();
|
|
400
|
-
await this.explorer.
|
|
397
|
+
await this.explorer.capture();
|
|
401
398
|
const currentAria = this.stateManager.getCurrentState()?.ariaSnapshot || '';
|
|
402
|
-
if (!diffAriaSnapshots(originalAria, currentAria))
|
|
399
|
+
if (!diffAriaSnapshots(originalAria, currentAria).text)
|
|
403
400
|
return;
|
|
404
401
|
}
|
|
405
402
|
catch (err) {
|
|
@@ -12,9 +12,13 @@ export declare function WithLocators<T extends Constructor>(Base: T): {
|
|
|
12
12
|
explorer: Explorer;
|
|
13
13
|
provider: Provider;
|
|
14
14
|
actionResult: ActionResult | undefined;
|
|
15
|
-
testLocators(locators: Locator[]
|
|
15
|
+
testLocators(locators: Locator[], opts?: {
|
|
16
|
+
scope?: boolean;
|
|
17
|
+
}): Promise<void>;
|
|
16
18
|
fixBrokenSections(result: ResearchResult, conversation: Conversation): Promise<void>;
|
|
17
19
|
backfillBrokenLocators(result: ResearchResult): Promise<void>;
|
|
20
|
+
resolveContainers(result: ResearchResult): Promise<Locator[]>;
|
|
21
|
+
recoverContainerFromChildren(result: ResearchResult, css: string): Promise<boolean>;
|
|
18
22
|
validateContainers(result: ResearchResult): Promise<void>;
|
|
19
23
|
updateSectionContainer(result: ResearchResult, section: ReturnType<typeof parseResearchSections>[0], newCss: string | null): void;
|
|
20
24
|
};
|
|
@@ -30,7 +34,10 @@ export interface Locator {
|
|
|
30
34
|
pwLocator: string | null;
|
|
31
35
|
}
|
|
32
36
|
export interface LocatorMethods {
|
|
33
|
-
testLocators(locators: Locator[]
|
|
37
|
+
testLocators(locators: Locator[], opts?: {
|
|
38
|
+
scope?: boolean;
|
|
39
|
+
}): Promise<void>;
|
|
34
40
|
fixBrokenSections(result: ResearchResult, conversation: Conversation): Promise<void>;
|
|
35
41
|
backfillBrokenLocators(result: ResearchResult): Promise<void>;
|
|
42
|
+
resolveContainers(result: ResearchResult): Promise<Locator[]>;
|
|
36
43
|
}
|
|
@@ -4,6 +4,7 @@ import { parseAriaLocator } from "../../utils/aria.js";
|
|
|
4
4
|
import { tag } from '../../utils/logger.js';
|
|
5
5
|
import { mdq } from "../../utils/markdown-query.js";
|
|
6
6
|
import { WebElement } from "../../utils/web-element.js";
|
|
7
|
+
import { isDynamicId } from "../../utils/xpath.js";
|
|
7
8
|
import { locatorRule as generalLocatorRuleText } from '../rules.js';
|
|
8
9
|
import { FOCUSED_MARKER } from "./focus.js";
|
|
9
10
|
import { debugLog } from "./mixin.js";
|
|
@@ -26,7 +27,7 @@ function buildPwLocatorString(loc) {
|
|
|
26
27
|
}
|
|
27
28
|
export function WithLocators(Base) {
|
|
28
29
|
return class extends Base {
|
|
29
|
-
async testLocators(locators) {
|
|
30
|
+
async testLocators(locators, opts = {}) {
|
|
30
31
|
let broken = 0;
|
|
31
32
|
for (const loc of locators) {
|
|
32
33
|
if (executionController.isInterrupted())
|
|
@@ -40,21 +41,23 @@ export function WithLocators(Base) {
|
|
|
40
41
|
continue;
|
|
41
42
|
}
|
|
42
43
|
try {
|
|
43
|
-
const count = await this.explorer.
|
|
44
|
+
const count = await this.explorer.withPage((page) => {
|
|
44
45
|
const base = loc.container ? page.locator(loc.container) : page;
|
|
45
46
|
if (loc.type === 'aria') {
|
|
46
47
|
const parsed = parseAriaLocator(loc.locator);
|
|
47
48
|
if (!parsed)
|
|
48
|
-
return page.locator('__invalid__');
|
|
49
|
-
return base.getByRole(parsed.role, { name: parsed.text });
|
|
49
|
+
return page.locator('__invalid__').count();
|
|
50
|
+
return base.getByRole(parsed.role, { name: parsed.text }).count();
|
|
50
51
|
}
|
|
51
52
|
const converted = loc.locator.replace(/:contains\(/g, ':has-text(');
|
|
52
53
|
if (converted !== loc.locator) {
|
|
53
54
|
loc.locator = converted;
|
|
54
55
|
}
|
|
55
|
-
return base.locator(loc.locator);
|
|
56
|
+
return base.locator(loc.locator).count();
|
|
56
57
|
});
|
|
57
58
|
loc.valid = count === 1;
|
|
59
|
+
if (opts.scope && count > 1)
|
|
60
|
+
loc.valid = true;
|
|
58
61
|
loc.pwLocator = buildPwLocatorString(loc);
|
|
59
62
|
if (!loc.valid) {
|
|
60
63
|
loc.error = count === 0 ? '0 elements' : `${count} elements`;
|
|
@@ -173,7 +176,7 @@ export function WithLocators(Base) {
|
|
|
173
176
|
}
|
|
174
177
|
}
|
|
175
178
|
if (needsXpath.length > 0) {
|
|
176
|
-
const webElements = await this.explorer.
|
|
179
|
+
const webElements = await this.explorer.withPage((page) => WebElement.fromEidxList(page, needsXpath));
|
|
177
180
|
const changedSections = new Set();
|
|
178
181
|
for (const w of webElements) {
|
|
179
182
|
const entry = needsXpathEls.get(w.eidx);
|
|
@@ -188,6 +191,60 @@ export function WithLocators(Base) {
|
|
|
188
191
|
}
|
|
189
192
|
await this.validateContainers(result);
|
|
190
193
|
}
|
|
194
|
+
async resolveContainers(result) {
|
|
195
|
+
const containerLocs = result.containerLocators;
|
|
196
|
+
await this.testLocators(containerLocs, { scope: true });
|
|
197
|
+
for (const loc of containerLocs.filter((l) => l.valid === false)) {
|
|
198
|
+
if (await this.recoverContainerFromChildren(result, loc.locator))
|
|
199
|
+
loc.valid = true;
|
|
200
|
+
}
|
|
201
|
+
return containerLocs.filter((l) => l.valid === false);
|
|
202
|
+
}
|
|
203
|
+
async recoverContainerFromChildren(result, css) {
|
|
204
|
+
const sections = parseResearchSections(result.text).filter((s) => s.containerCss === css);
|
|
205
|
+
let recovered = 0;
|
|
206
|
+
for (const section of sections) {
|
|
207
|
+
const eidxList = section.elements.map((el) => el.eidx).filter(Boolean);
|
|
208
|
+
if (eidxList.length < 2)
|
|
209
|
+
continue;
|
|
210
|
+
const ancestor = await this.explorer.runWithBrowserRecovery('recoverContainerFromChildren', () => WebElement.commonAncestor(this.explorer.playwrightHelper.page, eidxList));
|
|
211
|
+
if (!ancestor)
|
|
212
|
+
continue;
|
|
213
|
+
const candidates = [];
|
|
214
|
+
const id = ancestor.attrs.id;
|
|
215
|
+
if (id && !isDynamicId(id))
|
|
216
|
+
candidates.push(`#${id}`);
|
|
217
|
+
for (const cls of ancestor.filteredClasses) {
|
|
218
|
+
if (!/^[\w-]+$/.test(cls))
|
|
219
|
+
continue;
|
|
220
|
+
candidates.push(`${ancestor.tag}.${cls}`);
|
|
221
|
+
}
|
|
222
|
+
let unique = null;
|
|
223
|
+
let multiple = null;
|
|
224
|
+
for (const candidate of candidates) {
|
|
225
|
+
let count = 0;
|
|
226
|
+
try {
|
|
227
|
+
count = await this.explorer.playwrightLocatorCount((page) => page.locator(candidate));
|
|
228
|
+
}
|
|
229
|
+
catch { }
|
|
230
|
+
if (count === 1) {
|
|
231
|
+
unique = candidate;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
if (count > 1 && !multiple)
|
|
235
|
+
multiple = candidate;
|
|
236
|
+
}
|
|
237
|
+
const newCss = unique || multiple;
|
|
238
|
+
if (!newCss)
|
|
239
|
+
continue;
|
|
240
|
+
debugLog(`Recovered container: '${css}' → '${newCss}' in "${section.name}"`);
|
|
241
|
+
this.updateSectionContainer(result, section, newCss);
|
|
242
|
+
recovered++;
|
|
243
|
+
}
|
|
244
|
+
if (recovered > 0 && recovered < sections.length)
|
|
245
|
+
debugLog(`Container '${css}' recovered in ${recovered}/${sections.length} sections, still broken for the rest`);
|
|
246
|
+
return recovered > 0 && recovered === sections.length;
|
|
247
|
+
}
|
|
191
248
|
async validateContainers(result) {
|
|
192
249
|
const sections = parseResearchSections(result.text);
|
|
193
250
|
for (const section of sections) {
|
|
@@ -195,7 +252,7 @@ export function WithLocators(Base) {
|
|
|
195
252
|
continue;
|
|
196
253
|
let count = 0;
|
|
197
254
|
try {
|
|
198
|
-
count = await this.explorer.
|
|
255
|
+
count = await this.explorer.withPage((page) => page.locator(section.containerCss).count());
|
|
199
256
|
}
|
|
200
257
|
catch { }
|
|
201
258
|
if (count >= 1)
|
|
@@ -204,7 +261,7 @@ export function WithLocators(Base) {
|
|
|
204
261
|
if (simplified) {
|
|
205
262
|
let simplifiedCount = 0;
|
|
206
263
|
try {
|
|
207
|
-
simplifiedCount = await this.explorer.
|
|
264
|
+
simplifiedCount = await this.explorer.withPage((page) => page.locator(simplified).count());
|
|
208
265
|
}
|
|
209
266
|
catch { }
|
|
210
267
|
if (simplifiedCount >= 1) {
|
|
@@ -69,7 +69,7 @@ export function extractContainerFromBlockquote(sectionMarkdown) {
|
|
|
69
69
|
return css;
|
|
70
70
|
}
|
|
71
71
|
export function parseResearchSections(markdown) {
|
|
72
|
-
const hasExtendedResearch = markdown.
|
|
72
|
+
const hasExtendedResearch = mdq(markdown).query('section1(~"Extended Research")').count() > 0;
|
|
73
73
|
return parseSections(markdown)
|
|
74
74
|
.filter((s) => !SKIP_SECTIONS.has(s.name.toLowerCase()) && !s.name.toLowerCase().includes('data:'))
|
|
75
75
|
.map((section) => {
|
|
@@ -9,7 +9,6 @@ export declare class ResearchResult {
|
|
|
9
9
|
parseLocators(): void;
|
|
10
10
|
get containers(): string[];
|
|
11
11
|
get containerLocators(): Locator[];
|
|
12
|
-
updateSection(sectionName: string, locators: Locator[]): void;
|
|
13
12
|
rebuildSectionInText(section: ResearchSection): void;
|
|
14
13
|
cleanup(): void;
|
|
15
14
|
reorderColumns(columns: string[]): string[];
|
|
@@ -42,25 +42,6 @@ export class ResearchResult {
|
|
|
42
42
|
pwLocator: null,
|
|
43
43
|
}));
|
|
44
44
|
}
|
|
45
|
-
updateSection(sectionName, locators) {
|
|
46
|
-
const sections = parseResearchSections(this.text);
|
|
47
|
-
const section = sections.find((s) => s.name === sectionName);
|
|
48
|
-
if (!section)
|
|
49
|
-
return;
|
|
50
|
-
for (const el of section.elements) {
|
|
51
|
-
const elLocators = locators.filter((l) => l.element === el.name);
|
|
52
|
-
for (const loc of elLocators) {
|
|
53
|
-
const value = loc.valid === false ? null : loc.locator || null;
|
|
54
|
-
if (loc.type === 'css')
|
|
55
|
-
el.css = value;
|
|
56
|
-
if (loc.type === 'xpath')
|
|
57
|
-
el.xpath = value;
|
|
58
|
-
if (loc.type === 'aria')
|
|
59
|
-
el.aria = value ? parseAriaLocator(value) : null;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
this.rebuildSectionInText(section);
|
|
63
|
-
}
|
|
64
45
|
rebuildSectionInText(section) {
|
|
65
46
|
if (section.elements.length === 0)
|
|
66
47
|
return;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ActionResult } from '../../action-result.js';
|
|
2
|
+
import type { ExplorbotConfig } from '../../config.js';
|
|
2
3
|
import type Explorer from '../../explorer.js';
|
|
3
4
|
import type { StateManager } from '../../state-manager.js';
|
|
4
5
|
import type { Provider } from '../provider.js';
|
|
@@ -11,6 +12,7 @@ export declare function WithSections<T extends Constructor>(Base: T): {
|
|
|
11
12
|
explorer: Explorer;
|
|
12
13
|
provider: Provider;
|
|
13
14
|
stateManager: StateManager;
|
|
15
|
+
config: ExplorbotConfig;
|
|
14
16
|
actionResult: ActionResult | undefined;
|
|
15
17
|
researchBySections(): Promise<string>;
|
|
16
18
|
_detectFocusCss(): Promise<string | null>;
|
|
@@ -50,11 +50,11 @@ export function WithSections(Base) {
|
|
|
50
50
|
return focused.text;
|
|
51
51
|
}
|
|
52
52
|
async _detectFocusCss() {
|
|
53
|
-
const focusSections = this.
|
|
53
|
+
const focusSections = this.config.ai?.agents?.researcher?.focusSections;
|
|
54
54
|
if (!focusSections?.length)
|
|
55
55
|
return null;
|
|
56
56
|
for (const css of focusSections) {
|
|
57
|
-
const count = await this.explorer.
|
|
57
|
+
const count = await this.explorer.withPage((page) => page.locator(css).count()).catch(() => 0);
|
|
58
58
|
if (count > 0)
|
|
59
59
|
return css;
|
|
60
60
|
}
|
|
@@ -89,9 +89,13 @@ export function WithSections(Base) {
|
|
|
89
89
|
</section_format>
|
|
90
90
|
|
|
91
91
|
<rules>
|
|
92
|
-
-
|
|
92
|
+
- List only elements physically inside this section's declared container.
|
|
93
|
+
- Do not copy global toolbar, navigation, list, or detail elements into this section unless they are descendants of this section container.
|
|
94
|
+
- Every element with eidx inside this section's container MUST appear in the table.
|
|
93
95
|
- Every row needs CSS; ARIA may be "-" for icon-only buttons.
|
|
94
96
|
- ARIA locator JSON uses keys "role" and "text" (NOT "name").
|
|
97
|
+
- Elements marked data-explorbot-hit="covered" or "offscreen" are not directly actionable; describe the covering or focused UI first.
|
|
98
|
+
- In split-pane pages, entity detail panels are active detail context; include close/back/pin controls in the detail panel section when present.
|
|
95
99
|
</rules>
|
|
96
100
|
|
|
97
101
|
${generalLocatorRuleText}
|