@detiq/crawler 1.1.11 → 1.1.13
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/dist/crawler.js +52 -6
- package/dist/crawler.js.map +2 -2
- package/dist/job-queue.js +499 -10
- package/dist/job-queue.js.map +2 -2
- package/dist/locator-extractor.js +35 -1
- package/dist/locator-extractor.js.map +2 -2
- package/dist/test-executor.js +102 -0
- package/dist/test-executor.js.map +2 -2
- package/package.json +1 -1
- package/src/content-chunker.ts +2 -2
- package/src/crawler.ts +95 -30
- package/src/frontier.ts +5 -3
- package/src/gap-pass.ts +1 -1
- package/src/http-engine.ts +9 -4
- package/src/job-queue.ts +350 -18
- package/src/locator-extractor.ts +41 -3
- package/src/policies/crawl-safety.ts +7 -1
- package/src/profiles/domain-profile.ts +24 -1
- package/src/rate-limiter.ts +2 -2
- package/src/test-executor.ts +122 -0
- package/src/url-pattern-completer.ts +1 -1
- package/src/visual-diff-verdict.ts +4 -2
- package/types/crawler.d.ts +9 -0
- package/types/locator-extractor.d.ts +2 -0
- package/types/test-executor.d.ts +7 -0
|
@@ -114,7 +114,7 @@ const PROFILES: Record<Industry, Omit<DomainProfile, 'confidence' | 'evidence'>>
|
|
|
114
114
|
},
|
|
115
115
|
};
|
|
116
116
|
|
|
117
|
-
const
|
|
117
|
+
const BASE_INDUSTRY_HINTS: Array<{ industry: Industry; terms: RegExp[] }> = [
|
|
118
118
|
{ industry: 'ecommerce', terms: [/cart/i, /checkout/i, /product/i, /catalog/i, /sku/i, /wishlist/i, /order/i] },
|
|
119
119
|
{ industry: 'banking', terms: [/account/i, /statement/i, /beneficiar/i, /transfer/i, /iban/i, /routing/i, /balance/i] },
|
|
120
120
|
{ industry: 'fintech', terms: [/wallet/i, /kyc/i, /payout/i, /card/i, /payment/i, /risk/i, /dispute/i] },
|
|
@@ -125,6 +125,29 @@ const INDUSTRY_HINTS: Array<{ industry: Industry; terms: RegExp[] }> = [
|
|
|
125
125
|
{ industry: 'saas', terms: [/workspace/i, /tenant/i, /integration/i, /billing/i, /api key/i, /admin/i, /dashboard/i] },
|
|
126
126
|
];
|
|
127
127
|
|
|
128
|
+
// Allow additional industry hint keywords via ENV without replacing the baseline.
|
|
129
|
+
// Format: JSON array of { industry: string; terms: string[] } — terms are treated as case-insensitive regex.
|
|
130
|
+
// Example: EXTRA_INDUSTRY_HINTS_JSON='[{"industry":"saas","terms":["platform","multitenant"]}]'
|
|
131
|
+
const _extraHints: Array<{ industry: Industry; terms: RegExp[] }> = (() => {
|
|
132
|
+
const raw = process.env.EXTRA_INDUSTRY_HINTS_JSON;
|
|
133
|
+
if (!raw) return [];
|
|
134
|
+
try {
|
|
135
|
+
const parsed: Array<{ industry: string; terms: string[] }> = JSON.parse(raw);
|
|
136
|
+
return parsed.map(h => ({ industry: h.industry as Industry, terms: h.terms.map(t => new RegExp(t, 'i')) }));
|
|
137
|
+
} catch { return []; }
|
|
138
|
+
})();
|
|
139
|
+
|
|
140
|
+
const INDUSTRY_HINTS = [...BASE_INDUSTRY_HINTS, ..._extraHints];
|
|
141
|
+
|
|
142
|
+
// Allow patching profile fields (riskyActions, piiRules, etc.) per industry via ENV.
|
|
143
|
+
// Format: JSON object keyed by industry, value is partial DomainProfile fields to merge.
|
|
144
|
+
// Example: DOMAIN_PROFILE_OVERRIDES_JSON='{"saas":{"riskyActions":["delete workspace","export all data"]}}'
|
|
145
|
+
const _profileOverrides: Partial<Record<Industry, Partial<Omit<DomainProfile, 'confidence' | 'evidence'>>>> = (() => {
|
|
146
|
+
const raw = process.env.DOMAIN_PROFILE_OVERRIDES_JSON;
|
|
147
|
+
if (!raw) return {};
|
|
148
|
+
try { return JSON.parse(raw); } catch { return {}; }
|
|
149
|
+
})();
|
|
150
|
+
|
|
128
151
|
export function classifyDomainProfile(input: {
|
|
129
152
|
appUrl: string;
|
|
130
153
|
projectName?: string | null;
|
package/src/rate-limiter.ts
CHANGED
|
@@ -30,8 +30,8 @@ export class DomainRateLimiter {
|
|
|
30
30
|
private readonly rateMap: Record<string, number>;
|
|
31
31
|
|
|
32
32
|
constructor(opts: RateLimiterOpts = {}) {
|
|
33
|
-
this.defaultRps = opts.defaultRps ?? 2;
|
|
34
|
-
this.burstSize = opts.burstSize ?? 5;
|
|
33
|
+
this.defaultRps = opts.defaultRps ?? (parseFloat(process.env.CRAWLER_RATE_LIMIT_DEFAULT_RPS ?? '') || 2);
|
|
34
|
+
this.burstSize = opts.burstSize ?? (parseInt(process.env.CRAWLER_RATE_LIMIT_BURST_SIZE ?? '') || 5);
|
|
35
35
|
this.rateMap = opts.rateMap ?? {};
|
|
36
36
|
}
|
|
37
37
|
|
package/src/test-executor.ts
CHANGED
|
@@ -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';
|
|
@@ -70,7 +70,7 @@ Respond with ONLY valid JSON, no explanation:
|
|
|
70
70
|
const Anthropic = (await import('@anthropic-ai/sdk')).default;
|
|
71
71
|
const client = new Anthropic({ apiKey: llmCfg.apiKey });
|
|
72
72
|
const response = await client.messages.create({
|
|
73
|
-
model: llmCfg.model ?? 'claude-haiku-4-5-20251001',
|
|
73
|
+
model: llmCfg.model ?? process.env.CRAWLER_DEFAULT_MODEL ?? 'claude-haiku-4-5-20251001',
|
|
74
74
|
max_tokens: 1024,
|
|
75
75
|
temperature: 0,
|
|
76
76
|
// @ts-ignore: cache_control supported in beta
|
|
@@ -23,9 +23,11 @@ export function pHashVerdict(
|
|
|
23
23
|
: changeLevel === 'MINOR' ? 'MINOR'
|
|
24
24
|
: changeLevel === 'MODERATE' ? 'SIGNIFICANT'
|
|
25
25
|
: 'CRITICAL';
|
|
26
|
+
const approveThreshold = parseInt(process.env.CRAWLER_VISUAL_DIFF_APPROVE_THRESHOLD ?? '') || 5;
|
|
27
|
+
const reviewThreshold = parseInt(process.env.CRAWLER_VISUAL_DIFF_REVIEW_THRESHOLD ?? '') || 20;
|
|
26
28
|
const recommendation: VisualDiffVerdict['recommendation'] = pHashDistance === 0 ? 'APPROVE'
|
|
27
|
-
: pHashDistance <
|
|
28
|
-
: pHashDistance <
|
|
29
|
+
: pHashDistance < approveThreshold ? 'APPROVE'
|
|
30
|
+
: pHashDistance < reviewThreshold ? 'REVIEW'
|
|
29
31
|
: 'FLAG';
|
|
30
32
|
return { changeType, recommendation, changes };
|
|
31
33
|
}
|
package/types/crawler.d.ts
CHANGED
|
@@ -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.
|
package/types/test-executor.d.ts
CHANGED
|
@@ -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;
|