@detiq/crawler 1.1.10 → 1.1.12

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.
@@ -56,6 +56,7 @@ export function elementToPlaywrightLocator(element: {
56
56
  text?: string | null;
57
57
  notes?: unknown;
58
58
  selector?: string | null;
59
+ ariaState?: Record<string, unknown> | null;
59
60
  }): PlaywrightLocator | null {
60
61
  const attrs = parseNotes(element.notes);
61
62
  const testId = attrs['data-testid'];
@@ -66,6 +67,12 @@ export function elementToPlaywrightLocator(element: {
66
67
  const text = (element.text ?? '').trim();
67
68
  const meaning = (element.meaning ?? '').trim();
68
69
  const role = mapRole(element.role);
70
+ const ariaState: Record<string, string> = {};
71
+ if (element.ariaState && typeof element.ariaState === 'object') {
72
+ for (const [k, v] of Object.entries(element.ariaState)) {
73
+ if (typeof v === 'string') ariaState[k] = v;
74
+ }
75
+ }
69
76
 
70
77
  const label = meaning || text || testId || ariaLabel || placeholder || id || name || 'element';
71
78
  const key = toKey(label);
@@ -106,7 +113,19 @@ export function elementToPlaywrightLocator(element: {
106
113
  }
107
114
  }
108
115
 
109
- // Priority 3: checkbox/radio → getByLabel
116
+ // Priority 3: checkbox/radio → getByRole with optional checked state from ariaState
117
+ if ((role === 'checkbox' || role === 'radio') && meaning) {
118
+ const isChecked = ariaState['checked'];
119
+ const stateOpt = isChecked === 'true' ? `, { name: ${JSON.stringify(meaning)}, checked: true }` : isChecked === 'false' ? `, { name: ${JSON.stringify(meaning)}, checked: false }` : `, { name: ${JSON.stringify(meaning)} }`;
120
+ return {
121
+ key,
122
+ expression: `page.getByRole(${JSON.stringify(role)}${stateOpt})`,
123
+ elementType: role,
124
+ confidence: 'high',
125
+ source: 'role-name',
126
+ description: meaning,
127
+ };
128
+ }
110
129
  if ((role === 'checkbox' || role === 'radio') && ariaLabel) {
111
130
  return {
112
131
  key,
@@ -118,11 +137,29 @@ export function elementToPlaywrightLocator(element: {
118
137
  };
119
138
  }
120
139
 
121
- // Priority 4: button/link with meaningful name → getByRole
140
+ // Priority 3b: tab/option/treeitem → getByRole with optional selected state
141
+ const tabLikeRoles = ['tab', 'option', 'treeitem', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'gridcell'];
142
+ if (tabLikeRoles.includes(role ?? '') && meaning) {
143
+ const isSelected = ariaState['selected'];
144
+ const isCurrent = ariaState['current'];
145
+ const stateOpt = isSelected === 'true' ? `, { name: ${JSON.stringify(meaning)}, selected: true }` : isCurrent ? `, { name: ${JSON.stringify(meaning)} }` : `, { name: ${JSON.stringify(meaning)} }`;
146
+ return {
147
+ key,
148
+ expression: `page.getByRole(${JSON.stringify(role)}${stateOpt})`,
149
+ elementType: role ?? 'element',
150
+ confidence: 'high',
151
+ source: 'role-name',
152
+ description: meaning,
153
+ };
154
+ }
155
+
156
+ // Priority 4: button/link with meaningful name → getByRole; include pressed state for toggles
122
157
  if ((role === 'button' || role === 'link') && meaning) {
158
+ const isPressed = ariaState['pressed'];
159
+ const stateOpt = isPressed === 'true' ? `, { name: ${JSON.stringify(meaning)}, pressed: true }` : isPressed === 'false' ? `, { name: ${JSON.stringify(meaning)}, pressed: false }` : `, { name: ${JSON.stringify(meaning)} }`;
123
160
  return {
124
161
  key,
125
- expression: `page.getByRole(${JSON.stringify(role)}, { name: ${JSON.stringify(meaning)} })`,
162
+ expression: `page.getByRole(${JSON.stringify(role)}${stateOpt})`,
126
163
  elementType: role,
127
164
  confidence: 'high',
128
165
  source: 'role-name',
@@ -194,6 +231,7 @@ export function elementsToPlaywrightLocators(elements: Array<{
194
231
  notes?: unknown;
195
232
  selector?: string | null;
196
233
  isInteractive?: boolean | null;
234
+ ariaState?: Record<string, unknown> | null;
197
235
  }>): PlaywrightLocator[] {
198
236
  const CONFIDENCE_RANK = { high: 0, medium: 1, low: 2 };
199
237
  const byKey = new Map<string, PlaywrightLocator>();
@@ -138,6 +138,116 @@ function applyHealInfo(result: StepResult, r: { healed: boolean; healedVia?: 'me
138
138
  result.healConfidence = r.healConfidence;
139
139
  }
140
140
 
141
+ export async function executeExecutionStep(
142
+ page: Page,
143
+ step: ExecutionStep,
144
+ options: { healCtx?: { tenantId: string; projectId: string } } = {},
145
+ ): Promise<Pick<StepResult, 'healed' | 'healedVia' | 'healReasoning' | 'healConfidence' | 'elementBoundingRect' | 'elementSelector'>> {
146
+ const result: Pick<StepResult, 'healed' | 'healedVia' | 'healReasoning' | 'healConfidence' | 'elementBoundingRect' | 'elementSelector'> = {};
147
+ const timeout = step.timeout ?? parseInt(process.env.TEST_STEP_TIMEOUT_MS ?? '8000', 10);
148
+
149
+ switch (step.action) {
150
+ case 'navigate':
151
+ if (!step.url) throw new Error('Navigate step missing url');
152
+ assertCrawlUrlSafe(step.url);
153
+ await page.goto(step.url, { waitUntil: 'domcontentloaded', timeout });
154
+ break;
155
+
156
+ case 'click': {
157
+ const r = await resolveLocator(page, step, options.healCtx);
158
+ if (!r) throw new Error('Could not resolve locator');
159
+ result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
160
+ result.elementSelector = step.selector;
161
+ await r.loc.first().click({ timeout });
162
+ applyHealInfo(result as StepResult, r);
163
+ break;
164
+ }
165
+
166
+ case 'fill': {
167
+ const r = await resolveLocator(page, step, options.healCtx);
168
+ if (!r) throw new Error('Could not resolve locator');
169
+ result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
170
+ result.elementSelector = step.selector;
171
+ await r.loc.first().fill(step.value ?? '', { timeout });
172
+ applyHealInfo(result as StepResult, r);
173
+ break;
174
+ }
175
+
176
+ case 'select': {
177
+ const r = await resolveLocator(page, step, options.healCtx);
178
+ if (!r) throw new Error('Could not resolve locator');
179
+ result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
180
+ result.elementSelector = step.selector;
181
+ await r.loc.first().selectOption(step.value ?? '', { timeout });
182
+ applyHealInfo(result as StepResult, r);
183
+ break;
184
+ }
185
+
186
+ case 'hover': {
187
+ const r = await resolveLocator(page, step, options.healCtx);
188
+ if (!r) throw new Error('Could not resolve locator');
189
+ result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
190
+ result.elementSelector = step.selector;
191
+ await r.loc.first().hover({ timeout });
192
+ applyHealInfo(result as StepResult, r);
193
+ break;
194
+ }
195
+
196
+ case 'press_key':
197
+ await page.keyboard.press(step.value ?? 'Enter');
198
+ break;
199
+
200
+ case 'assert_visible': {
201
+ const r = await resolveLocator(page, step, options.healCtx);
202
+ if (!r) throw new Error('Could not resolve locator');
203
+ await r.loc.first().waitFor({ state: 'visible', timeout });
204
+ result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
205
+ result.elementSelector = step.selector;
206
+ applyHealInfo(result as StepResult, r);
207
+ break;
208
+ }
209
+
210
+ case 'assert_text': {
211
+ const r = await resolveLocator(page, step, options.healCtx);
212
+ if (!r) throw new Error('Could not resolve locator');
213
+ const text = await r.loc.first().textContent({ timeout });
214
+ if (!text?.includes(step.value ?? '')) {
215
+ throw new Error(`Expected text "${step.value}" not found. Got: "${text?.slice(0, 100)}"`);
216
+ }
217
+ result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
218
+ result.elementSelector = step.selector;
219
+ applyHealInfo(result as StepResult, r);
220
+ break;
221
+ }
222
+
223
+ case 'assert_url': {
224
+ const url = page.url();
225
+ if (!url.includes(step.value ?? '')) {
226
+ throw new Error(`Expected URL to contain "${step.value}". Got: "${url}"`);
227
+ }
228
+ break;
229
+ }
230
+
231
+ case 'wait':
232
+ await page.waitForTimeout(step.timeout ?? parseInt(process.env.TEST_STEP_DELAY_MS ?? '2000', 10));
233
+ break;
234
+
235
+ case 'screenshot':
236
+ break;
237
+
238
+ case 'script':
239
+ case 'setVariable':
240
+ if (step.value) {
241
+ await page.evaluate(step.value).catch(err => {
242
+ throw new Error(`Script execution failed: ${err.message}`);
243
+ });
244
+ }
245
+ break;
246
+ }
247
+
248
+ return result;
249
+ }
250
+
141
251
  async function runPlan(
142
252
  page: Page,
143
253
  plan: ExecutionStep[],
@@ -253,6 +363,18 @@ async function runPlan(
253
363
  result.screenshotPath = ssPath;
254
364
  break;
255
365
  }
366
+
367
+ case 'script':
368
+ case 'setVariable': {
369
+ if (step.value) {
370
+ // Execute arbitrary JS string. Often used by manual test builder extension for custom variables
371
+ // or assertions.
372
+ await page.evaluate(step.value).catch(err => {
373
+ throw new Error(`Script execution failed: ${err.message}`);
374
+ });
375
+ }
376
+ break;
377
+ }
256
378
  }
257
379
 
258
380
  result.status = 'PASSED';
@@ -190,4 +190,13 @@ export declare function crawlProject(opts: {
190
190
  domHashes: string[];
191
191
  discoveredUrls?: undefined;
192
192
  }>;
193
+ export declare function domFallbackElements(page: any): Promise<Array<{
194
+ meaning: string;
195
+ role: string;
196
+ expectedData?: string;
197
+ notes?: string;
198
+ boundingRect?: Record<string, number>;
199
+ ariaState?: Record<string, string>;
200
+ parentLandmark?: string;
201
+ }>>;
193
202
  export {};
@@ -17,6 +17,7 @@ export declare function elementToPlaywrightLocator(element: {
17
17
  text?: string | null;
18
18
  notes?: unknown;
19
19
  selector?: string | null;
20
+ ariaState?: Record<string, unknown> | null;
20
21
  }): PlaywrightLocator | null;
21
22
  /**
22
23
  * Convert an array of DB Element records to deduplicated Playwright locators.
@@ -29,6 +30,7 @@ export declare function elementsToPlaywrightLocators(elements: Array<{
29
30
  notes?: unknown;
30
31
  selector?: string | null;
31
32
  isInteractive?: boolean | null;
33
+ ariaState?: Record<string, unknown> | null;
32
34
  }>): PlaywrightLocator[];
33
35
  /**
34
36
  * Format locators as a block for injection into LLM script generation prompts.
@@ -1,3 +1,4 @@
1
+ import { type Page } from 'playwright';
1
2
  import type { ExecutionStep } from '@detiq/agents';
2
3
  export interface StepResult {
3
4
  stepNumber: number;
@@ -47,6 +48,12 @@ export interface ExecutionResult {
47
48
  duration: number;
48
49
  }>;
49
50
  }
51
+ export declare function executeExecutionStep(page: Page, step: ExecutionStep, options?: {
52
+ healCtx?: {
53
+ tenantId: string;
54
+ projectId: string;
55
+ };
56
+ }): Promise<Pick<StepResult, 'healed' | 'healedVia' | 'healReasoning' | 'healConfidence' | 'elementBoundingRect' | 'elementSelector'>>;
50
57
  export declare function executeTestPlan(plan: ExecutionStep[], options: {
51
58
  screenshotDir: string;
52
59
  testRunId: string;