@detiq/crawler 0.1.7 → 0.1.9
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/ai-login.js +12 -1
- package/dist/ai-login.js.map +2 -2
- package/dist/crawler.js +89 -8
- package/dist/crawler.js.map +2 -2
- package/dist/http-engine.js +2 -1
- package/dist/http-engine.js.map +2 -2
- package/dist/job-queue.js +5 -2
- package/dist/job-queue.js.map +2 -2
- package/dist/skyvern.js +0 -4
- package/dist/skyvern.js.map +2 -2
- package/dist/test-executor.js +18 -6
- package/dist/test-executor.js.map +2 -2
- package/package.json +1 -1
- package/src/crawler.ts +97 -7
- package/src/crawler.ts.rej +12 -0
- package/src/http-engine.ts +2 -1
- package/src/job-queue.ts +5 -2
- package/src/test-executor.ts +20 -6
- package/types/crawler.d.ts +14 -0
- package/types/test-executor.d.ts +7 -0
package/dist/skyvern.js
CHANGED
|
@@ -5,8 +5,6 @@ async function skyvernNavigate(startUrl, navigationGoals, tenantConfig, triggerR
|
|
|
5
5
|
console.warn("[skyvern] skyvernNavigate: no API config \u2014 skipping post-login vision navigation");
|
|
6
6
|
return { screenshotUrls: [], visitedUrls: [], status: "failed" };
|
|
7
7
|
}
|
|
8
|
-
const { assertResolvesToPublicAddress } = await import("@detiq/core");
|
|
9
|
-
await assertResolvesToPublicAddress(apiUrl);
|
|
10
8
|
const visitedUrls = [];
|
|
11
9
|
const screenshotUrls = [];
|
|
12
10
|
const skyvernSteps = [];
|
|
@@ -93,8 +91,6 @@ async function skyvernFallback(task, tenantConfig) {
|
|
|
93
91
|
);
|
|
94
92
|
return "failed";
|
|
95
93
|
}
|
|
96
|
-
const { assertResolvesToPublicAddress } = await import("@detiq/core");
|
|
97
|
-
await assertResolvesToPublicAddress(apiUrl);
|
|
98
94
|
try {
|
|
99
95
|
const res = await fetch(`${apiUrl}/api/v1/tasks`, {
|
|
100
96
|
method: "POST",
|
package/dist/skyvern.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/skyvern.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Skyvern Vision Fallback\n *\n * Skyvern combines computer vision + LLM reasoning to navigate sites that\n * defeat DOM-based approaches: CAPTCHAs, legacy apps, visual-only login flows,\n * government portals. It's the \"break glass\" option when Stagehand fails.\n *\n * Production setup (Phase 4+):\n * 1. Self-host: https://github.com/Skyvern-AI/skyvern\n * 2. Or use cloud: https://app.skyvern.com\n * 3. Set SKYVERN_API_URL + SKYVERN_API_KEY in .env\n *\n * Pricing note: vision tasks cost more per step than DOM-based Stagehand.\n * Use only when the fast path fails.\n */\n\nexport interface SkyvernTask {\n taskDescription: string;\n url: string;\n credentials?: { username: string; password: string; oneTimeCode?: string };\n}\n\nexport interface SkyvernConfig {\n apiUrl: string;\n apiKey: string;\n llmKey?: string;\n llmApiKey?: string;\n}\n\nexport interface SkyvernStep {\n order: number;\n action_type: string;\n element_description?: string;\n reasoning?: string;\n success: boolean;\n screenshot_url?: string;\n duration_ms?: number;\n error?: string;\n}\n\nexport interface SkyvernNavigateResult {\n screenshotUrls: string[];\n visitedUrls: string[];\n status: 'success' | 'failed' | 'partial';\n steps?: SkyvernStep[];\n taskId?: string;\n durationMs?: number;\n triggerReason?: string;\n}\n\n/**\n * Gap 5: Post-login navigation via Skyvern vision for apps where DOM analysis\n * cannot discover screens (canvas, WebGL, legacy Flash-replacement stacks).\n * Skyvern drives the browser via screenshot + LLM, returning visited page list.\n */\nexport async function skyvernNavigate(\n startUrl: string,\n navigationGoals: string[],\n tenantConfig?: SkyvernConfig,\n triggerReason?: string,\n): Promise<SkyvernNavigateResult> {\n const apiUrl = tenantConfig?.apiUrl || process.env.SKYVERN_API_URL;\n const apiKey = tenantConfig?.apiKey || process.env.SKYVERN_API_KEY;\n\n if (!apiUrl || !apiKey) {\n console.warn('[skyvern] skyvernNavigate: no API config \u2014 skipping post-login vision navigation');\n return { screenshotUrls: [], visitedUrls: [], status: 'failed' };\n }\n\n const
|
|
5
|
-
"mappings": "AAuDA,eAAsB,gBACpB,UACA,iBACA,cACA,eACgC;AAChC,QAAM,SAAS,cAAc,UAAU,QAAQ,IAAI;AACnD,QAAM,SAAS,cAAc,UAAU,QAAQ,IAAI;AAEnD,MAAI,CAAC,UAAU,CAAC,QAAQ;AACtB,YAAQ,KAAK,uFAAkF;AAC/F,WAAO,EAAE,gBAAgB,CAAC,GAAG,aAAa,CAAC,GAAG,QAAQ,SAAS;AAAA,EACjE;AAEA,QAAM,
|
|
4
|
+
"sourcesContent": ["/**\n * Skyvern Vision Fallback\n *\n * Skyvern combines computer vision + LLM reasoning to navigate sites that\n * defeat DOM-based approaches: CAPTCHAs, legacy apps, visual-only login flows,\n * government portals. It's the \"break glass\" option when Stagehand fails.\n *\n * Production setup (Phase 4+):\n * 1. Self-host: https://github.com/Skyvern-AI/skyvern\n * 2. Or use cloud: https://app.skyvern.com\n * 3. Set SKYVERN_API_URL + SKYVERN_API_KEY in .env\n *\n * Pricing note: vision tasks cost more per step than DOM-based Stagehand.\n * Use only when the fast path fails.\n */\n\nexport interface SkyvernTask {\n taskDescription: string;\n url: string;\n credentials?: { username: string; password: string; oneTimeCode?: string };\n}\n\nexport interface SkyvernConfig {\n apiUrl: string;\n apiKey: string;\n llmKey?: string;\n llmApiKey?: string;\n}\n\nexport interface SkyvernStep {\n order: number;\n action_type: string;\n element_description?: string;\n reasoning?: string;\n success: boolean;\n screenshot_url?: string;\n duration_ms?: number;\n error?: string;\n}\n\nexport interface SkyvernNavigateResult {\n screenshotUrls: string[];\n visitedUrls: string[];\n status: 'success' | 'failed' | 'partial';\n steps?: SkyvernStep[];\n taskId?: string;\n durationMs?: number;\n triggerReason?: string;\n}\n\n/**\n * Gap 5: Post-login navigation via Skyvern vision for apps where DOM analysis\n * cannot discover screens (canvas, WebGL, legacy Flash-replacement stacks).\n * Skyvern drives the browser via screenshot + LLM, returning visited page list.\n */\nexport async function skyvernNavigate(\n startUrl: string,\n navigationGoals: string[],\n tenantConfig?: SkyvernConfig,\n triggerReason?: string,\n): Promise<SkyvernNavigateResult> {\n const apiUrl = tenantConfig?.apiUrl || process.env.SKYVERN_API_URL;\n const apiKey = tenantConfig?.apiKey || process.env.SKYVERN_API_KEY;\n\n if (!apiUrl || !apiKey) {\n console.warn('[skyvern] skyvernNavigate: no API config \u2014 skipping post-login vision navigation');\n return { screenshotUrls: [], visitedUrls: [], status: 'failed' };\n }\n\n const visitedUrls: string[] = [];\n const screenshotUrls: string[] = [];\n const skyvernSteps: SkyvernStep[] = [];\n let lastTaskId: string | undefined;\n let totalDurationMs = 0;\n\n for (const goal of navigationGoals) {\n try {\n const taskStart = Date.now();\n const res = await fetch(`${apiUrl}/api/v1/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },\n body: JSON.stringify({\n url: startUrl,\n navigation_goal: goal,\n ...(tenantConfig?.llmKey ? { llm_key: tenantConfig.llmKey } : process.env.SKYVERN_LLM_KEY ? { llm_key: process.env.SKYVERN_LLM_KEY } : {}),\n ...(tenantConfig?.llmApiKey ? { llm_api_key: tenantConfig.llmApiKey } : process.env.OPENAI_API_KEY ? { llm_api_key: process.env.OPENAI_API_KEY } : {}),\n }),\n });\n if (!res.ok) continue;\n const data: any = await res.json();\n const taskId: string = data.task_id;\n if (!taskId) continue;\n lastTaskId = taskId;\n\n // Poll for task completion (max 60s per goal)\n let done = false;\n for (let p = 0; p < 12 && !done; p++) {\n await new Promise((r) => setTimeout(r, 5000));\n const poll = await fetch(`${apiUrl}/api/v1/tasks/${taskId}`, {\n headers: { 'x-api-key': apiKey },\n }).catch(() => null);\n if (!poll?.ok) continue;\n const pollData: any = await poll.json();\n if (pollData.status === 'completed' || pollData.status === 'failed') {\n done = true;\n totalDurationMs += Date.now() - taskStart;\n if (pollData.status === 'completed') {\n if (pollData.output?.url) visitedUrls.push(pollData.output.url);\n if (pollData.output?.screenshot_url) screenshotUrls.push(pollData.output.screenshot_url);\n }\n\n // Fetch step-level detail from Skyvern\n try {\n const stepsRes = await fetch(`${apiUrl}/api/v1/tasks/${taskId}/steps`, {\n headers: { 'x-api-key': apiKey },\n }).catch(() => null);\n if (stepsRes?.ok) {\n const stepsData: any = await stepsRes.json();\n const stepsList = Array.isArray(stepsData) ? stepsData : (stepsData?.steps ?? []);\n for (const s of stepsList) {\n if (s.output?.screenshot_url) screenshotUrls.push(s.output.screenshot_url);\n skyvernSteps.push({\n order: s.step_order ?? s.order ?? 0,\n action_type: s.action_type ?? s.action ?? 'unknown',\n element_description: s.output?.element_description,\n reasoning: s.output?.llm_response?.action_description ?? s.output?.reasoning,\n success: s.status === 'completed',\n screenshot_url: s.output?.screenshot_url,\n error: s.status === 'failed' ? (s.output?.errors?.[0] ?? 'Step failed') : undefined,\n });\n }\n }\n } catch { /* non-fatal */ }\n }\n }\n } catch (err) {\n console.warn(`[skyvern] Navigation goal failed: ${goal}`, err);\n }\n }\n\n return {\n screenshotUrls,\n visitedUrls,\n status: visitedUrls.length > 0 ? 'success' : screenshotUrls.length > 0 ? 'partial' : 'failed',\n steps: skyvernSteps,\n taskId: lastTaskId,\n durationMs: totalDurationMs || undefined,\n triggerReason,\n };\n}\n\nexport async function skyvernFallback(task: SkyvernTask, tenantConfig?: SkyvernConfig): Promise<'success' | 'failed'> {\n const apiUrl = tenantConfig?.apiUrl || process.env.SKYVERN_API_URL;\n const apiKey = tenantConfig?.apiKey || process.env.SKYVERN_API_KEY;\n\n if (!apiUrl || !apiKey) {\n console.warn(\n '[skyvern] Vision fallback disabled \u2014 set SKYVERN_API_URL + SKYVERN_API_KEY in .env to enable.',\n '\\n[skyvern] Self-host: https://github.com/Skyvern-AI/skyvern'\n );\n return 'failed';\n }\n\n try {\n const res = await fetch(`${apiUrl}/api/v1/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },\n body: JSON.stringify({\n url: task.url,\n navigation_goal: task.taskDescription,\n ...(task.credentials && {\n navigation_payload: {\n username: task.credentials.username,\n password: task.credentials.password,\n ...(task.credentials.oneTimeCode && { otp: task.credentials.oneTimeCode }),\n },\n }),\n ...(tenantConfig?.llmKey ? { llm_key: tenantConfig.llmKey } : process.env.SKYVERN_LLM_KEY ? { llm_key: process.env.SKYVERN_LLM_KEY } : {}),\n ...(tenantConfig?.llmApiKey ? { llm_api_key: tenantConfig.llmApiKey } : process.env.OPENAI_API_KEY ? { llm_api_key: process.env.OPENAI_API_KEY } : {}),\n }),\n });\n\n if (!res.ok) {\n const body = await res.text().catch(() => '');\n console.error(`[skyvern] Task creation failed: ${res.status}`, body);\n return 'failed';\n }\n\n const data: any = await res.json();\n console.log(`[skyvern] Task queued: ${data.task_id}. Poll GET /api/v1/tasks/${data.task_id} for completion.`);\n // Full implementation: poll until status === \"completed\" | \"failed\".\n // For Phase 1 the login already happened in the browser context Skyvern manages,\n // so returning 'success' here means we can proceed in that browser session.\n return 'success';\n } catch (err) {\n console.error('[skyvern] Error:', err);\n return 'failed';\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/test-executor.js
CHANGED
|
@@ -36,12 +36,12 @@ async function resolveLocator(page, step, healCtx) {
|
|
|
36
36
|
const { selector, selectorStrategy = "text" } = step;
|
|
37
37
|
if (!selector) return null;
|
|
38
38
|
const primary = buildLocator(page, selector, selectorStrategy);
|
|
39
|
-
const attached = await primary.first().isVisible({ timeout: 1500 }).catch(() => false);
|
|
39
|
+
const attached = await primary.first().isVisible({ timeout: parseInt(process.env.TEST_WAIT_TIMEOUT_MS ?? "1500", 10) }).catch(() => false);
|
|
40
40
|
if (attached) return { loc: primary, healed: false };
|
|
41
41
|
for (const strategy of LOCATOR_STRATEGIES) {
|
|
42
42
|
if (strategy === selectorStrategy) continue;
|
|
43
43
|
const loc = buildLocator(page, selector, strategy);
|
|
44
|
-
const visible = await loc.first().isVisible({ timeout: 800 }).catch(() => false);
|
|
44
|
+
const visible = await loc.first().isVisible({ timeout: parseInt(process.env.TEST_SHORT_TIMEOUT_MS ?? "800", 10) }).catch(() => false);
|
|
45
45
|
if (visible) return { loc, healed: true, healedVia: "mechanical" };
|
|
46
46
|
}
|
|
47
47
|
if (healCtx) {
|
|
@@ -56,7 +56,7 @@ async function resolveLocator(page, step, healCtx) {
|
|
|
56
56
|
});
|
|
57
57
|
if (result.healed && result.updatedTest) {
|
|
58
58
|
const healedLoc = page.getByText(result.updatedTest.elementMeaning, { exact: false });
|
|
59
|
-
const visible = await healedLoc.first().isVisible({ timeout: 1500 }).catch(() => false);
|
|
59
|
+
const visible = await healedLoc.first().isVisible({ timeout: parseInt(process.env.TEST_WAIT_TIMEOUT_MS ?? "1500", 10) }).catch(() => false);
|
|
60
60
|
if (visible) {
|
|
61
61
|
return {
|
|
62
62
|
loc: healedLoc,
|
|
@@ -91,7 +91,7 @@ async function runPlan(page, plan, options, consoleLogs, networkErrors) {
|
|
|
91
91
|
duration: 0
|
|
92
92
|
};
|
|
93
93
|
try {
|
|
94
|
-
const timeout = step.timeout ??
|
|
94
|
+
const timeout = step.timeout ?? parseInt(process.env.TEST_STEP_TIMEOUT_MS ?? "8000", 10);
|
|
95
95
|
switch (step.action) {
|
|
96
96
|
case "navigate":
|
|
97
97
|
if (!step.url) throw new Error("Navigate step missing url");
|
|
@@ -101,6 +101,8 @@ async function runPlan(page, plan, options, consoleLogs, networkErrors) {
|
|
|
101
101
|
case "click": {
|
|
102
102
|
const r = await resolveLocator(page, step, options.healCtx);
|
|
103
103
|
if (!r) throw new Error("Could not resolve locator");
|
|
104
|
+
result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
|
|
105
|
+
result.elementSelector = step.selector;
|
|
104
106
|
await r.loc.first().click({ timeout });
|
|
105
107
|
applyHealInfo(result, r);
|
|
106
108
|
break;
|
|
@@ -108,6 +110,8 @@ async function runPlan(page, plan, options, consoleLogs, networkErrors) {
|
|
|
108
110
|
case "fill": {
|
|
109
111
|
const r = await resolveLocator(page, step, options.healCtx);
|
|
110
112
|
if (!r) throw new Error("Could not resolve locator");
|
|
113
|
+
result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
|
|
114
|
+
result.elementSelector = step.selector;
|
|
111
115
|
await r.loc.first().fill(step.value ?? "", { timeout });
|
|
112
116
|
applyHealInfo(result, r);
|
|
113
117
|
break;
|
|
@@ -115,6 +119,8 @@ async function runPlan(page, plan, options, consoleLogs, networkErrors) {
|
|
|
115
119
|
case "select": {
|
|
116
120
|
const r = await resolveLocator(page, step, options.healCtx);
|
|
117
121
|
if (!r) throw new Error("Could not resolve locator");
|
|
122
|
+
result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
|
|
123
|
+
result.elementSelector = step.selector;
|
|
118
124
|
await r.loc.first().selectOption(step.value ?? "", { timeout });
|
|
119
125
|
applyHealInfo(result, r);
|
|
120
126
|
break;
|
|
@@ -122,6 +128,8 @@ async function runPlan(page, plan, options, consoleLogs, networkErrors) {
|
|
|
122
128
|
case "hover": {
|
|
123
129
|
const r = await resolveLocator(page, step, options.healCtx);
|
|
124
130
|
if (!r) throw new Error("Could not resolve locator");
|
|
131
|
+
result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
|
|
132
|
+
result.elementSelector = step.selector;
|
|
125
133
|
await r.loc.first().hover({ timeout });
|
|
126
134
|
applyHealInfo(result, r);
|
|
127
135
|
break;
|
|
@@ -133,6 +141,8 @@ async function runPlan(page, plan, options, consoleLogs, networkErrors) {
|
|
|
133
141
|
const r = await resolveLocator(page, step, options.healCtx);
|
|
134
142
|
if (!r) throw new Error("Could not resolve locator");
|
|
135
143
|
await r.loc.first().waitFor({ state: "visible", timeout });
|
|
144
|
+
result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
|
|
145
|
+
result.elementSelector = step.selector;
|
|
136
146
|
applyHealInfo(result, r);
|
|
137
147
|
break;
|
|
138
148
|
}
|
|
@@ -143,6 +153,8 @@ async function runPlan(page, plan, options, consoleLogs, networkErrors) {
|
|
|
143
153
|
if (!text?.includes(step.value ?? "")) {
|
|
144
154
|
throw new Error(`Expected text "${step.value}" not found. Got: "${text?.slice(0, 100)}"`);
|
|
145
155
|
}
|
|
156
|
+
result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
|
|
157
|
+
result.elementSelector = step.selector;
|
|
146
158
|
applyHealInfo(result, r);
|
|
147
159
|
break;
|
|
148
160
|
}
|
|
@@ -154,7 +166,7 @@ async function runPlan(page, plan, options, consoleLogs, networkErrors) {
|
|
|
154
166
|
break;
|
|
155
167
|
}
|
|
156
168
|
case "wait":
|
|
157
|
-
await page.waitForTimeout(step.timeout ??
|
|
169
|
+
await page.waitForTimeout(step.timeout ?? parseInt(process.env.TEST_STEP_DELAY_MS ?? "2000", 10));
|
|
158
170
|
break;
|
|
159
171
|
case "screenshot": {
|
|
160
172
|
const ssPath = `${options.screenshotDir}/${options.testRunId}-step-${step.stepNumber}.png`;
|
|
@@ -172,7 +184,7 @@ async function runPlan(page, plan, options, consoleLogs, networkErrors) {
|
|
|
172
184
|
}
|
|
173
185
|
}
|
|
174
186
|
try {
|
|
175
|
-
await page.waitForLoadState("networkidle", { timeout: 1500 }).catch(() => {
|
|
187
|
+
await page.waitForLoadState("networkidle", { timeout: parseInt(process.env.TEST_NETWORK_IDLE_TIMEOUT_MS ?? "1500", 10) }).catch(() => {
|
|
176
188
|
});
|
|
177
189
|
if (!result.screenshotPath) {
|
|
178
190
|
const ssPath = `${options.screenshotDir}/${options.testRunId}-step-${step.stepNumber}.png`;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/test-executor.ts"],
|
|
4
|
-
"sourcesContent": ["import { chromium, firefox, webkit, type Page, type Locator } from 'playwright';\nimport type { ExecutionStep } from '@detiq/agents';\nimport { assertCrawlUrlSafe } from '@detiq/core';\nimport { analyzeFailure } from './failure-analysis-agent.js';\n\nexport interface StepResult {\n stepNumber: number;\n description: string;\n status: 'PASSED' | 'FAILED' | 'SKIPPED';\n duration: number;\n error?: string;\n screenshotPath?: string;\n healed?: boolean;\n /** 'mechanical' = same selector text, different Playwright locator strategy.\n * 'ai' = AI Self-Healing Agent matched a semantically different element\n * (the app's selector text itself changed, e.g. \"Sign In\" -> \"Log In\"). */\n healedVia?: 'mechanical' | 'ai';\n healReasoning?: string;\n healConfidence?: number;\n}\n\nexport interface ExecutionResult {\n stepResults: StepResult[];\n status: 'PASSED' | 'FAILED' | 'ERROR' | 'FLAKY';\n errorStep?: number;\n totalDuration: number;\n finalScreenshotPath?: string;\n retryCount?: number;\n consoleLogs?: Array<{ type: string; text: string; timestamp: number }>;\n networkErrors?: Array<{ url: string; status: number; method: string; timestamp: number }>;\n rootCauseSummary?: string;\n networkTimings?: Array<{ url: string; method: string; status: number; duration: number }>;\n}\n\nconst LOCATOR_STRATEGIES: ExecutionStep['selectorStrategy'][] = ['text', 'label', 'role', 'css'];\n\nfunction buildLocator(page: Page, selector: string, strategy: ExecutionStep['selectorStrategy'] = 'text'): Locator {\n switch (strategy) {\n case 'css': return page.locator(selector);\n case 'text': return page.getByText(selector, { exact: false });\n case 'role': return page.getByRole(selector as any);\n case 'label': return page.getByLabel(selector);\n default: return page.locator(selector);\n }\n}\n\n/** Scans the live page for visible interactive elements \u2014 the \"current page elements\"\n * fed to the AI Self-Healing Agent when mechanical locator strategies fail. */\nasync function extractCurrentPageElements(page: Page): Promise<Array<{ meaning: string; role: string; ariaLabel?: string; textContent?: string }>> {\n return page.evaluate(() => {\n const els = Array.from(document.querySelectorAll<HTMLElement>(\n 'a, button, input, select, textarea, [role=\"button\"], [role=\"link\"], [role=\"tab\"], [role=\"menuitem\"], [aria-label]'\n ));\n return els\n .filter((el) => {\n const rect = el.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0;\n })\n .slice(0, 200)\n .map((el) => {\n const ariaLabel = el.getAttribute('aria-label') ?? undefined;\n const textContent = (el.textContent ?? '').replace(/\\s+/g, ' ').trim().slice(0, 100) || undefined;\n const role = el.getAttribute('role') ?? el.tagName.toLowerCase();\n return { meaning: ariaLabel || textContent || role, role, ariaLabel, textContent };\n });\n });\n}\n\n/**\n * Resolves a step's target element in three tiers:\n * 1. Exact selector + strategy as generated.\n * 2. Mechanical: same selector text, other Playwright locator strategies\n * (handles a strategy mismatch, not an actual UI change).\n * 3. AI: when tenantId/projectId are supplied, asks the Self-Healing Agent to find\n * the current page's best semantic match for the failed step description \u2014 the\n * tier that actually survives the app's selector text changing.\n */\nasync function resolveLocator(\n page: Page,\n step: ExecutionStep,\n healCtx?: { tenantId: string; projectId: string },\n): Promise<{ loc: Locator; healed: boolean; healedVia?: 'mechanical' | 'ai'; healReasoning?: string; healConfidence?: number } | null> {\n const { selector, selectorStrategy = 'text' } = step;\n if (!selector) return null;\n\n const primary = buildLocator(page, selector, selectorStrategy);\n const attached = await primary.first().isVisible({ timeout: 1500 }).catch(() => false);\n if (attached) return { loc: primary, healed: false };\n\n // Tier 2 \u2014 mechanical: try other strategies before giving up\n for (const strategy of LOCATOR_STRATEGIES) {\n if (strategy === selectorStrategy) continue;\n const loc = buildLocator(page, selector, strategy);\n const visible = await loc.first().isVisible({ timeout: 800 }).catch(() => false);\n if (visible) return { loc, healed: true, healedVia: 'mechanical' };\n }\n\n // Tier 3 \u2014 AI: the selector text itself may no longer match anything on the page.\n if (healCtx) {\n try {\n const { healElement } = await import('@detiq/agents');\n const currentPageElements = await extractCurrentPageElements(page);\n const result = await healElement({\n tenantId: healCtx.tenantId,\n projectId: healCtx.projectId,\n failedElementMeaning: step.description || selector,\n currentPageElements,\n });\n if (result.healed && result.updatedTest) {\n const healedLoc = page.getByText(result.updatedTest.elementMeaning, { exact: false });\n const visible = await healedLoc.first().isVisible({ timeout: 1500 }).catch(() => false);\n if (visible) {\n return {\n loc: healedLoc,\n healed: true,\n healedVia: 'ai',\n healReasoning: result.reasoning,\n healConfidence: result.confidence,\n };\n }\n }\n } catch {\n // AI healing is a best-effort last resort \u2014 any failure here (LLM error, bad\n // JSON, etc.) just falls through to the caller's normal Playwright error below.\n }\n }\n\n // Return primary \u2014 caller will fail with a meaningful Playwright error\n return { loc: primary, healed: false };\n}\n\nfunction applyHealInfo(result: StepResult, r: { healed: boolean; healedVia?: 'mechanical' | 'ai'; healReasoning?: string; healConfidence?: number }): void {\n result.healed = r.healed;\n result.healedVia = r.healedVia;\n result.healReasoning = r.healReasoning;\n result.healConfidence = r.healConfidence;\n}\n\nasync function runPlan(\n page: Page,\n plan: ExecutionStep[],\n options: { screenshotDir: string; testRunId: string; onStepComplete?: (result: StepResult) => void; healCtx?: { tenantId: string; projectId: string } },\n consoleLogs: Array<{ type: string; text: string; timestamp: number }>,\n networkErrors: Array<{ url: string; status: number; method: string; timestamp: number }>,\n): Promise<{ stepResults: StepResult[]; errorStep?: number; finalScreenshotPath?: string; consoleLogs: typeof consoleLogs; networkErrors: typeof networkErrors }> {\n const stepResults: StepResult[] = [];\n let errorStep: number | undefined;\n let finalScreenshotPath: string | undefined;\n\n for (const step of plan) {\n const stepStart = Date.now();\n const result: StepResult = {\n stepNumber: step.stepNumber,\n description: step.description,\n status: 'FAILED',\n duration: 0,\n };\n\n try {\n const timeout = step.timeout ?? 8000;\n\n switch (step.action) {\n case 'navigate':\n if (!step.url) throw new Error('Navigate step missing url');\n assertCrawlUrlSafe(step.url);\n await page.goto(step.url, { waitUntil: 'domcontentloaded', timeout });\n break;\n\n case 'click': {\n const r = await resolveLocator(page, step, options.healCtx);\n if (!r) throw new Error('Could not resolve locator');\n await r.loc.first().click({ timeout });\n applyHealInfo(result, r);\n break;\n }\n\n case 'fill': {\n const r = await resolveLocator(page, step, options.healCtx);\n if (!r) throw new Error('Could not resolve locator');\n await r.loc.first().fill(step.value ?? '', { timeout });\n applyHealInfo(result, r);\n break;\n }\n\n case 'select': {\n const r = await resolveLocator(page, step, options.healCtx);\n if (!r) throw new Error('Could not resolve locator');\n await r.loc.first().selectOption(step.value ?? '', { timeout });\n applyHealInfo(result, r);\n break;\n }\n\n case 'hover': {\n const r = await resolveLocator(page, step, options.healCtx);\n if (!r) throw new Error('Could not resolve locator');\n await r.loc.first().hover({ timeout });\n applyHealInfo(result, r);\n break;\n }\n\n case 'press_key':\n await page.keyboard.press(step.value ?? 'Enter');\n break;\n\n case 'assert_visible': {\n const r = await resolveLocator(page, step, options.healCtx);\n if (!r) throw new Error('Could not resolve locator');\n await r.loc.first().waitFor({ state: 'visible', timeout });\n applyHealInfo(result, r);\n break;\n }\n\n case 'assert_text': {\n const r = await resolveLocator(page, step, options.healCtx);\n if (!r) throw new Error('Could not resolve locator');\n const text = await r.loc.first().textContent({ timeout });\n if (!text?.includes(step.value ?? '')) {\n throw new Error(`Expected text \"${step.value}\" not found. Got: \"${text?.slice(0, 100)}\"`);\n }\n applyHealInfo(result, r);\n break;\n }\n\n case 'assert_url': {\n const url = page.url();\n if (!url.includes(step.value ?? '')) {\n throw new Error(`Expected URL to contain \"${step.value}\". Got: \"${url}\"`);\n }\n break;\n }\n\n case 'wait':\n await page.waitForTimeout(step.timeout ?? 2000);\n break;\n\n case 'screenshot': {\n const ssPath = `${options.screenshotDir}/${options.testRunId}-step-${step.stepNumber}.png`;\n await page.screenshot({ path: ssPath, fullPage: true });\n result.screenshotPath = ssPath;\n break;\n }\n }\n\n result.status = 'PASSED';\n } catch (err: any) {\n result.status = step.optional ? 'SKIPPED' : 'FAILED';\n result.error = err.message;\n if (!step.optional) {\n errorStep = step.stepNumber;\n }\n }\n\n try {\n await page.waitForLoadState('networkidle', { timeout: 1500 }).catch(() => {});\n if (!result.screenshotPath) {\n const ssPath = `${options.screenshotDir}/${options.testRunId}-step-${step.stepNumber}.png`;\n await page.screenshot({ path: ssPath, fullPage: true });\n result.screenshotPath = ssPath;\n }\n } catch { /* Screenshot evidence is best-effort and should not fail the run. */ }\n\n result.duration = Date.now() - stepStart;\n stepResults.push(result);\n options.onStepComplete?.(result);\n\n if (result.status === 'FAILED') break;\n }\n\n try {\n const finalPath = `${options.screenshotDir}/${options.testRunId}-final.png`;\n await page.screenshot({ path: finalPath, fullPage: true });\n finalScreenshotPath = finalPath;\n } catch { /* non-fatal */ }\n\n return { stepResults, errorStep, finalScreenshotPath, consoleLogs, networkErrors };\n}\n\nfunction interpolate(template: string | undefined, data: Record<string, string>): string | undefined {\n if (!template) return template;\n return template.replace(/\\{\\{(\\w+)\\}\\}/g, (_, key) => data[key] ?? `{{${key}}}`);\n}\n\nexport async function executeTestPlan(\n plan: ExecutionStep[],\n options: {\n screenshotDir: string;\n testRunId: string;\n viewport?: { width: number; height: number };\n onStepComplete?: (result: StepResult) => void;\n /** Run the test up to this many times to detect flakiness (default 1). */\n maxRuns?: number;\n browser?: 'chromium' | 'firefox' | 'webkit';\n /** Data row for parameterized execution: substitutes {{varname}} in step fields. */\n dataRow?: Record<string, string>;\n /** Enables the AI Self-Healing Agent tier when a step's selector can't be resolved\n * even after trying alternate locator strategies. Omit to keep mechanical-only\n * healing (no LLM calls during execution). */\n healCtx?: { tenantId: string; projectId: string };\n /** Decrypted Playwright storageState JSON \u2014 restores a logged-in session so\n * steps don't have to re-authenticate. Without this, any test case behind a\n * login wall fails at the first authenticated step. */\n storageState?: string;\n },\n): Promise<ExecutionResult> {\n if (options.dataRow && Object.keys(options.dataRow).length > 0) {\n const data = options.dataRow;\n plan = plan.map((step) => ({\n ...step,\n url: interpolate(step.url, data),\n selector: interpolate(step.selector, data),\n value: interpolate(step.value, data),\n description: interpolate(step.description, data) ?? step.description,\n }));\n }\n const maxRuns = options.maxRuns ?? 1;\n const browserEngine = { chromium, firefox, webkit }[options.browser ?? 'chromium'] ?? chromium;\n const start = Date.now();\n\n const outcomes: ('PASSED' | 'FAILED')[] = [];\n let lastResult: Awaited<ReturnType<typeof runPlan>> | null = null;\n let lastNetworkTimings: Array<{ url: string; method: string; status: number; duration: number }> = [];\n\n for (let run = 0; run < maxRuns; run++) {\n const browser = await browserEngine.launch({\n headless: true,\n // Optional override for environments with a non-standard/pre-provisioned\n // browser install (e.g. a custom base image with only system Chromium).\n ...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}),\n });\n let parsedStorageState: any;\n try {\n parsedStorageState = options.storageState ? JSON.parse(options.storageState) : undefined;\n } catch {\n parsedStorageState = undefined;\n }\n const context = await browser.newContext({\n viewport: options.viewport ?? { width: 1440, height: 900 },\n ...(parsedStorageState ? { storageState: parsedStorageState } : {}),\n });\n const page = await context.newPage();\n\n await page\n .addStyleTag({ content: '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }' })\n .catch(() => {});\n\n const consoleLogs: Array<{ type: string; text: string; timestamp: number }> = [];\n const networkErrors: Array<{ url: string; status: number; method: string; timestamp: number }> = [];\n const networkTimings: Array<{ url: string; method: string; status: number; duration: number }> = [];\n const requestStartTimes = new Map<string, number>();\n\n page.on('console', msg => {\n if (consoleLogs.length < 100) {\n consoleLogs.push({ type: msg.type(), text: msg.text().slice(0, 500), timestamp: Date.now() });\n }\n });\n page.on('request', request => {\n const url = request.url();\n if (url.includes('/api/') || url.match(/\\/(graphql|rest|v\\d)\\//)) {\n requestStartTimes.set(request.url(), Date.now());\n }\n });\n page.on('response', response => {\n const url = response.url();\n const start = requestStartTimes.get(url);\n if (start && networkTimings.length < 200) {\n networkTimings.push({ url: url.slice(0, 300), method: response.request().method(), status: response.status(), duration: Date.now() - start });\n requestStartTimes.delete(url);\n }\n if (!response.ok() && !url.includes('favicon') && networkErrors.length < 50) {\n networkErrors.push({\n url: url.slice(0, 300),\n status: response.status(),\n method: response.request().method(),\n timestamp: Date.now(),\n });\n }\n });\n\n const TEST_EXECUTION_TIMEOUT_MS = 5 * 60_000;\n lastResult = await Promise.race([\n runPlan(page, plan, options, consoleLogs, networkErrors),\n new Promise<never>((_, reject) =>\n setTimeout(() => reject(new Error('Test execution timed out')), TEST_EXECUTION_TIMEOUT_MS)\n ),\n ]);\n lastNetworkTimings = networkTimings;\n await browser.close();\n\n const passed = !lastResult.stepResults.some((r) => r.status === 'FAILED');\n outcomes.push(passed ? 'PASSED' : 'FAILED');\n\n // If all subsequent runs are consistent with first, no need to keep going\n if (run > 0 && outcomes.every((o) => o === outcomes[0])) break;\n }\n\n const passCount = outcomes.filter((o) => o === 'PASSED').length;\n const failCount = outcomes.filter((o) => o === 'FAILED').length;\n\n let status: ExecutionResult['status'];\n if (maxRuns > 1 && passCount > 0 && failCount > 0) {\n status = 'FLAKY';\n } else if (passCount === outcomes.length) {\n status = 'PASSED';\n } else {\n status = 'FAILED';\n }\n\n let rootCauseSummary: string | undefined;\n if (status === 'FAILED' || status === 'FLAKY') {\n const failedStep = lastResult!.stepResults.find(s => s.status === 'FAILED');\n rootCauseSummary = await analyzeFailure({\n tenantId: options.healCtx?.tenantId,\n error: failedStep?.error ?? 'Unknown error',\n stepDescription: failedStep?.description,\n consoleLogs: lastResult!.consoleLogs,\n networkErrors: lastResult!.networkErrors,\n screenshotPath: lastResult!.finalScreenshotPath,\n }).catch(() => undefined) ?? undefined;\n }\n\n return {\n stepResults: lastResult!.stepResults,\n status,\n errorStep: lastResult!.errorStep,\n totalDuration: Date.now() - start,\n finalScreenshotPath: lastResult!.finalScreenshotPath,\n retryCount: outcomes.length - 1,\n consoleLogs: lastResult!.consoleLogs,\n networkErrors: lastResult!.networkErrors,\n rootCauseSummary,\n networkTimings: lastNetworkTimings,\n };\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,UAAU,SAAS,cAAuC;AAEnE,SAAS,0BAA0B;AACnC,SAAS,sBAAsB;
|
|
4
|
+
"sourcesContent": ["import { chromium, firefox, webkit, type Page, type Locator } from 'playwright';\nimport type { ExecutionStep } from '@detiq/agents';\nimport { assertCrawlUrlSafe } from '@detiq/core';\nimport { analyzeFailure } from './failure-analysis-agent.js';\n\nexport interface StepResult {\n stepNumber: number;\n description: string;\n status: 'PASSED' | 'FAILED' | 'SKIPPED';\n duration: number;\n error?: string;\n screenshotPath?: string;\n healed?: boolean;\n /** 'mechanical' = same selector text, different Playwright locator strategy.\n * 'ai' = AI Self-Healing Agent matched a semantically different element\n * (the app's selector text itself changed, e.g. \"Sign In\" -> \"Log In\"). */\n healedVia?: 'mechanical' | 'ai';\n healReasoning?: string;\n healConfidence?: number;\n elementBoundingRect?: { x: number; y: number; width: number; height: number } | null;\n elementSelector?: string;\n}\n\nexport interface ExecutionResult {\n stepResults: StepResult[];\n status: 'PASSED' | 'FAILED' | 'ERROR' | 'FLAKY';\n errorStep?: number;\n totalDuration: number;\n finalScreenshotPath?: string;\n retryCount?: number;\n consoleLogs?: Array<{ type: string; text: string; timestamp: number }>;\n networkErrors?: Array<{ url: string; status: number; method: string; timestamp: number }>;\n rootCauseSummary?: string;\n networkTimings?: Array<{ url: string; method: string; status: number; duration: number }>;\n}\n\nconst LOCATOR_STRATEGIES: ExecutionStep['selectorStrategy'][] = ['text', 'label', 'role', 'css'];\n\nfunction buildLocator(page: Page, selector: string, strategy: ExecutionStep['selectorStrategy'] = 'text'): Locator {\n switch (strategy) {\n case 'css': return page.locator(selector);\n case 'text': return page.getByText(selector, { exact: false });\n case 'role': return page.getByRole(selector as any);\n case 'label': return page.getByLabel(selector);\n default: return page.locator(selector);\n }\n}\n\n/** Scans the live page for visible interactive elements \u2014 the \"current page elements\"\n * fed to the AI Self-Healing Agent when mechanical locator strategies fail. */\nasync function extractCurrentPageElements(page: Page): Promise<Array<{ meaning: string; role: string; ariaLabel?: string; textContent?: string }>> {\n return page.evaluate(() => {\n const els = Array.from(document.querySelectorAll<HTMLElement>(\n 'a, button, input, select, textarea, [role=\"button\"], [role=\"link\"], [role=\"tab\"], [role=\"menuitem\"], [aria-label]'\n ));\n return els\n .filter((el) => {\n const rect = el.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0;\n })\n .slice(0, 200)\n .map((el) => {\n const ariaLabel = el.getAttribute('aria-label') ?? undefined;\n const textContent = (el.textContent ?? '').replace(/\\s+/g, ' ').trim().slice(0, 100) || undefined;\n const role = el.getAttribute('role') ?? el.tagName.toLowerCase();\n return { meaning: ariaLabel || textContent || role, role, ariaLabel, textContent };\n });\n });\n}\n\n/**\n * Resolves a step's target element in three tiers:\n * 1. Exact selector + strategy as generated.\n * 2. Mechanical: same selector text, other Playwright locator strategies\n * (handles a strategy mismatch, not an actual UI change).\n * 3. AI: when tenantId/projectId are supplied, asks the Self-Healing Agent to find\n * the current page's best semantic match for the failed step description \u2014 the\n * tier that actually survives the app's selector text changing.\n */\nasync function resolveLocator(\n page: Page,\n step: ExecutionStep,\n healCtx?: { tenantId: string; projectId: string },\n): Promise<{ loc: Locator; healed: boolean; healedVia?: 'mechanical' | 'ai'; healReasoning?: string; healConfidence?: number } | null> {\n const { selector, selectorStrategy = 'text' } = step;\n if (!selector) return null;\n\n const primary = buildLocator(page, selector, selectorStrategy);\n const attached = await primary.first().isVisible({ timeout: parseInt(process.env.TEST_WAIT_TIMEOUT_MS ?? '1500', 10) }).catch(() => false);\n if (attached) return { loc: primary, healed: false };\n\n // Tier 2 \u2014 mechanical: try other strategies before giving up\n for (const strategy of LOCATOR_STRATEGIES) {\n if (strategy === selectorStrategy) continue;\n const loc = buildLocator(page, selector, strategy);\n const visible = await loc.first().isVisible({ timeout: parseInt(process.env.TEST_SHORT_TIMEOUT_MS ?? '800', 10) }).catch(() => false);\n if (visible) return { loc, healed: true, healedVia: 'mechanical' };\n }\n\n // Tier 3 \u2014 AI: the selector text itself may no longer match anything on the page.\n if (healCtx) {\n try {\n const { healElement } = await import('@detiq/agents');\n const currentPageElements = await extractCurrentPageElements(page);\n const result = await healElement({\n tenantId: healCtx.tenantId,\n projectId: healCtx.projectId,\n failedElementMeaning: step.description || selector,\n currentPageElements,\n });\n if (result.healed && result.updatedTest) {\n const healedLoc = page.getByText(result.updatedTest.elementMeaning, { exact: false });\n const visible = await healedLoc.first().isVisible({ timeout: parseInt(process.env.TEST_WAIT_TIMEOUT_MS ?? '1500', 10) }).catch(() => false);\n if (visible) {\n return {\n loc: healedLoc,\n healed: true,\n healedVia: 'ai',\n healReasoning: result.reasoning,\n healConfidence: result.confidence,\n };\n }\n }\n } catch {\n // AI healing is a best-effort last resort \u2014 any failure here (LLM error, bad\n // JSON, etc.) just falls through to the caller's normal Playwright error below.\n }\n }\n\n // Return primary \u2014 caller will fail with a meaningful Playwright error\n return { loc: primary, healed: false };\n}\n\nfunction applyHealInfo(result: StepResult, r: { healed: boolean; healedVia?: 'mechanical' | 'ai'; healReasoning?: string; healConfidence?: number }): void {\n result.healed = r.healed;\n result.healedVia = r.healedVia;\n result.healReasoning = r.healReasoning;\n result.healConfidence = r.healConfidence;\n}\n\nasync function runPlan(\n page: Page,\n plan: ExecutionStep[],\n options: { screenshotDir: string; testRunId: string; onStepComplete?: (result: StepResult) => void; healCtx?: { tenantId: string; projectId: string } },\n consoleLogs: Array<{ type: string; text: string; timestamp: number }>,\n networkErrors: Array<{ url: string; status: number; method: string; timestamp: number }>,\n): Promise<{ stepResults: StepResult[]; errorStep?: number; finalScreenshotPath?: string; consoleLogs: typeof consoleLogs; networkErrors: typeof networkErrors }> {\n const stepResults: StepResult[] = [];\n let errorStep: number | undefined;\n let finalScreenshotPath: string | undefined;\n\n for (const step of plan) {\n const stepStart = Date.now();\n const result: StepResult = {\n stepNumber: step.stepNumber,\n description: step.description,\n status: 'FAILED',\n duration: 0,\n };\n\n try {\n const timeout = step.timeout ?? parseInt(process.env.TEST_STEP_TIMEOUT_MS ?? '8000', 10);\n\n switch (step.action) {\n case 'navigate':\n if (!step.url) throw new Error('Navigate step missing url');\n assertCrawlUrlSafe(step.url);\n await page.goto(step.url, { waitUntil: 'domcontentloaded', timeout });\n break;\n\n case 'click': {\n const r = await resolveLocator(page, step, options.healCtx);\n if (!r) throw new Error('Could not resolve locator');\n result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);\n result.elementSelector = step.selector;\n await r.loc.first().click({ timeout });\n applyHealInfo(result, r);\n break;\n }\n\n case 'fill': {\n const r = await resolveLocator(page, step, options.healCtx);\n if (!r) throw new Error('Could not resolve locator');\n result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);\n result.elementSelector = step.selector;\n await r.loc.first().fill(step.value ?? '', { timeout });\n applyHealInfo(result, r);\n break;\n }\n\n case 'select': {\n const r = await resolveLocator(page, step, options.healCtx);\n if (!r) throw new Error('Could not resolve locator');\n result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);\n result.elementSelector = step.selector;\n await r.loc.first().selectOption(step.value ?? '', { timeout });\n applyHealInfo(result, r);\n break;\n }\n\n case 'hover': {\n const r = await resolveLocator(page, step, options.healCtx);\n if (!r) throw new Error('Could not resolve locator');\n result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);\n result.elementSelector = step.selector;\n await r.loc.first().hover({ timeout });\n applyHealInfo(result, r);\n break;\n }\n\n case 'press_key':\n await page.keyboard.press(step.value ?? 'Enter');\n break;\n\n case 'assert_visible': {\n const r = await resolveLocator(page, step, options.healCtx);\n if (!r) throw new Error('Could not resolve locator');\n await r.loc.first().waitFor({ state: 'visible', timeout });\n result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);\n result.elementSelector = step.selector;\n applyHealInfo(result, r);\n break;\n }\n\n case 'assert_text': {\n const r = await resolveLocator(page, step, options.healCtx);\n if (!r) throw new Error('Could not resolve locator');\n const text = await r.loc.first().textContent({ timeout });\n if (!text?.includes(step.value ?? '')) {\n throw new Error(`Expected text \"${step.value}\" not found. Got: \"${text?.slice(0, 100)}\"`);\n }\n result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);\n result.elementSelector = step.selector;\n applyHealInfo(result, r);\n break;\n }\n\n case 'assert_url': {\n const url = page.url();\n if (!url.includes(step.value ?? '')) {\n throw new Error(`Expected URL to contain \"${step.value}\". Got: \"${url}\"`);\n }\n break;\n }\n\n case 'wait':\n await page.waitForTimeout(step.timeout ?? parseInt(process.env.TEST_STEP_DELAY_MS ?? '2000', 10));\n break;\n\n case 'screenshot': {\n const ssPath = `${options.screenshotDir}/${options.testRunId}-step-${step.stepNumber}.png`;\n await page.screenshot({ path: ssPath, fullPage: true });\n result.screenshotPath = ssPath;\n break;\n }\n }\n\n result.status = 'PASSED';\n } catch (err: any) {\n result.status = step.optional ? 'SKIPPED' : 'FAILED';\n result.error = err.message;\n if (!step.optional) {\n errorStep = step.stepNumber;\n }\n }\n\n try {\n await page.waitForLoadState('networkidle', { timeout: parseInt(process.env.TEST_NETWORK_IDLE_TIMEOUT_MS ?? '1500', 10) }).catch(() => {});\n if (!result.screenshotPath) {\n const ssPath = `${options.screenshotDir}/${options.testRunId}-step-${step.stepNumber}.png`;\n await page.screenshot({ path: ssPath, fullPage: true });\n result.screenshotPath = ssPath;\n }\n } catch { /* Screenshot evidence is best-effort and should not fail the run. */ }\n\n result.duration = Date.now() - stepStart;\n stepResults.push(result);\n options.onStepComplete?.(result);\n\n if (result.status === 'FAILED') break;\n }\n\n try {\n const finalPath = `${options.screenshotDir}/${options.testRunId}-final.png`;\n await page.screenshot({ path: finalPath, fullPage: true });\n finalScreenshotPath = finalPath;\n } catch { /* non-fatal */ }\n\n return { stepResults, errorStep, finalScreenshotPath, consoleLogs, networkErrors };\n}\n\nfunction interpolate(template: string | undefined, data: Record<string, string>): string | undefined {\n if (!template) return template;\n return template.replace(/\\{\\{(\\w+)\\}\\}/g, (_, key) => data[key] ?? `{{${key}}}`);\n}\n\nexport async function executeTestPlan(\n plan: ExecutionStep[],\n options: {\n screenshotDir: string;\n testRunId: string;\n viewport?: { width: number; height: number };\n onStepComplete?: (result: StepResult) => void;\n /** Run the test up to this many times to detect flakiness (default 1). */\n maxRuns?: number;\n browser?: 'chromium' | 'firefox' | 'webkit';\n /** Data row for parameterized execution: substitutes {{varname}} in step fields. */\n dataRow?: Record<string, string>;\n /** Enables the AI Self-Healing Agent tier when a step's selector can't be resolved\n * even after trying alternate locator strategies. Omit to keep mechanical-only\n * healing (no LLM calls during execution). */\n healCtx?: { tenantId: string; projectId: string };\n /** Decrypted Playwright storageState JSON \u2014 restores a logged-in session so\n * steps don't have to re-authenticate. Without this, any test case behind a\n * login wall fails at the first authenticated step. */\n storageState?: string;\n },\n): Promise<ExecutionResult> {\n if (options.dataRow && Object.keys(options.dataRow).length > 0) {\n const data = options.dataRow;\n plan = plan.map((step) => ({\n ...step,\n url: interpolate(step.url, data),\n selector: interpolate(step.selector, data),\n value: interpolate(step.value, data),\n description: interpolate(step.description, data) ?? step.description,\n }));\n }\n const maxRuns = options.maxRuns ?? 1;\n const browserEngine = { chromium, firefox, webkit }[options.browser ?? 'chromium'] ?? chromium;\n const start = Date.now();\n\n const outcomes: ('PASSED' | 'FAILED')[] = [];\n let lastResult: Awaited<ReturnType<typeof runPlan>> | null = null;\n let lastNetworkTimings: Array<{ url: string; method: string; status: number; duration: number }> = [];\n\n for (let run = 0; run < maxRuns; run++) {\n const browser = await browserEngine.launch({\n headless: true,\n // Optional override for environments with a non-standard/pre-provisioned\n // browser install (e.g. a custom base image with only system Chromium).\n ...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}),\n });\n let parsedStorageState: any;\n try {\n parsedStorageState = options.storageState ? JSON.parse(options.storageState) : undefined;\n } catch {\n parsedStorageState = undefined;\n }\n const context = await browser.newContext({\n viewport: options.viewport ?? { width: 1440, height: 900 },\n ...(parsedStorageState ? { storageState: parsedStorageState } : {}),\n });\n const page = await context.newPage();\n\n await page\n .addStyleTag({ content: '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }' })\n .catch(() => {});\n\n const consoleLogs: Array<{ type: string; text: string; timestamp: number }> = [];\n const networkErrors: Array<{ url: string; status: number; method: string; timestamp: number }> = [];\n const networkTimings: Array<{ url: string; method: string; status: number; duration: number }> = [];\n const requestStartTimes = new Map<string, number>();\n\n page.on('console', msg => {\n if (consoleLogs.length < 100) {\n consoleLogs.push({ type: msg.type(), text: msg.text().slice(0, 500), timestamp: Date.now() });\n }\n });\n page.on('request', request => {\n const url = request.url();\n if (url.includes('/api/') || url.match(/\\/(graphql|rest|v\\d)\\//)) {\n requestStartTimes.set(request.url(), Date.now());\n }\n });\n page.on('response', response => {\n const url = response.url();\n const start = requestStartTimes.get(url);\n if (start && networkTimings.length < 200) {\n networkTimings.push({ url: url.slice(0, 300), method: response.request().method(), status: response.status(), duration: Date.now() - start });\n requestStartTimes.delete(url);\n }\n if (!response.ok() && !url.includes('favicon') && networkErrors.length < 50) {\n networkErrors.push({\n url: url.slice(0, 300),\n status: response.status(),\n method: response.request().method(),\n timestamp: Date.now(),\n });\n }\n });\n\n const TEST_EXECUTION_TIMEOUT_MS = 5 * 60_000;\n lastResult = await Promise.race([\n runPlan(page, plan, options, consoleLogs, networkErrors),\n new Promise<never>((_, reject) =>\n setTimeout(() => reject(new Error('Test execution timed out')), TEST_EXECUTION_TIMEOUT_MS)\n ),\n ]);\n lastNetworkTimings = networkTimings;\n await browser.close();\n\n const passed = !lastResult.stepResults.some((r) => r.status === 'FAILED');\n outcomes.push(passed ? 'PASSED' : 'FAILED');\n\n // If all subsequent runs are consistent with first, no need to keep going\n if (run > 0 && outcomes.every((o) => o === outcomes[0])) break;\n }\n\n const passCount = outcomes.filter((o) => o === 'PASSED').length;\n const failCount = outcomes.filter((o) => o === 'FAILED').length;\n\n let status: ExecutionResult['status'];\n if (maxRuns > 1 && passCount > 0 && failCount > 0) {\n status = 'FLAKY';\n } else if (passCount === outcomes.length) {\n status = 'PASSED';\n } else {\n status = 'FAILED';\n }\n\n let rootCauseSummary: string | undefined;\n if (status === 'FAILED' || status === 'FLAKY') {\n const failedStep = lastResult!.stepResults.find(s => s.status === 'FAILED');\n rootCauseSummary = await analyzeFailure({\n tenantId: options.healCtx?.tenantId,\n error: failedStep?.error ?? 'Unknown error',\n stepDescription: failedStep?.description,\n consoleLogs: lastResult!.consoleLogs,\n networkErrors: lastResult!.networkErrors,\n screenshotPath: lastResult!.finalScreenshotPath,\n }).catch(() => undefined) ?? undefined;\n }\n\n return {\n stepResults: lastResult!.stepResults,\n status,\n errorStep: lastResult!.errorStep,\n totalDuration: Date.now() - start,\n finalScreenshotPath: lastResult!.finalScreenshotPath,\n retryCount: outcomes.length - 1,\n consoleLogs: lastResult!.consoleLogs,\n networkErrors: lastResult!.networkErrors,\n rootCauseSummary,\n networkTimings: lastNetworkTimings,\n };\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["start"]
|
|
7
7
|
}
|
package/package.json
CHANGED
package/src/crawler.ts
CHANGED
|
@@ -141,6 +141,7 @@ type AuthDiscoveryOptions = {
|
|
|
141
141
|
projectId: string;
|
|
142
142
|
jobId?: string;
|
|
143
143
|
screenshotDir?: string;
|
|
144
|
+
storageConfig?: any;
|
|
144
145
|
};
|
|
145
146
|
|
|
146
147
|
const AUTH_DISCOVERY_MAX_SCREENS = 36;
|
|
@@ -397,10 +398,14 @@ export async function crawlProject(opts: {
|
|
|
397
398
|
tenantId: string;
|
|
398
399
|
projectId: string;
|
|
399
400
|
appUrl: string;
|
|
401
|
+
/** The viewport used for this crawl pass */
|
|
402
|
+
viewport?: 'DESKTOP' | 'MOBILE_PORTRAIT' | 'TABLET';
|
|
400
403
|
credentials?: Credentials;
|
|
401
404
|
/** Playwright storageState JSON string — if present, session is restored and AI login is skipped. */
|
|
402
405
|
storageState?: string;
|
|
403
406
|
screenshotDir?: string;
|
|
407
|
+
/** Cloud storage config for immediate screenshot upload. If set, screenshots are uploaded to cloud immediately after capture and cloudUrl is persisted. */
|
|
408
|
+
storageConfig?: { provider: string; bucket: string; region?: string; accountId?: string; endpointUrl?: string; accessKeyId?: string; secretAccessKey?: string; prefix?: string; publicUrl?: string } | null;
|
|
404
409
|
/** When true, run a fast URL-only discovery crawl — skips screenshots, DB saves, and AI element extraction. Returns discoveredUrls in the result object. */
|
|
405
410
|
discoveryMode?: boolean;
|
|
406
411
|
maxScreens?: number;
|
|
@@ -502,6 +507,17 @@ export async function crawlProject(opts: {
|
|
|
502
507
|
}, onProgress?: (pct: number, step: string) => Promise<void>) {
|
|
503
508
|
if (opts.screenshotDir) await fs.mkdir(opts.screenshotDir, { recursive: true });
|
|
504
509
|
|
|
510
|
+
// Upload screenshot to cloud storage if configured, returns cloudUrl or undefined
|
|
511
|
+
const uploadToCloud = async (localPath: string, key: string): Promise<string | undefined> => {
|
|
512
|
+
if (!opts.storageConfig) return undefined;
|
|
513
|
+
try {
|
|
514
|
+
const { uploadScreenshotToTenantStorage } = await import('@detiq/app-brain');
|
|
515
|
+
return await uploadScreenshotToTenantStorage(opts.storageConfig as any, localPath, key);
|
|
516
|
+
} catch {
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
|
|
505
521
|
const isDocker = !!process.env.PLAYWRIGHT_IN_DOCKER || process.env.NODE_ENV === 'production';
|
|
506
522
|
|
|
507
523
|
// Use tenant LLM config if available; fall back to env vars (Anthropic only for Stagehand).
|
|
@@ -869,6 +885,20 @@ export async function crawlProject(opts: {
|
|
|
869
885
|
}, opts.authToken);
|
|
870
886
|
}
|
|
871
887
|
|
|
888
|
+
// ── Crawler login bypass: inject internal secret header on login requests ────
|
|
889
|
+
// Prevents the per-email rate limiter from blocking re-auth attempts made by
|
|
890
|
+
// the crawler during long crawl sessions. The API verifies the secret before
|
|
891
|
+
// skipping the limiter.
|
|
892
|
+
const crawlerSecret = process.env.CRAWLER_INTERNAL_SECRET;
|
|
893
|
+
if (crawlerSecret) {
|
|
894
|
+
const loginPattern = `${new URL(opts.appUrl).origin}**/auth/login*`;
|
|
895
|
+
await page.route(loginPattern, (route: any) => {
|
|
896
|
+
route.continue({
|
|
897
|
+
headers: { ...route.request().headers(), 'x-zeta-crawler': crawlerSecret },
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
|
|
872
902
|
// ── Web Vitals capture: CLS via buffered PerformanceObserver ─────────────────
|
|
873
903
|
// CLS entries are only delivered to a PerformanceObserver; this init script
|
|
874
904
|
// re-runs on every new document. LCP is captured per-page in the crawl loop.
|
|
@@ -1518,7 +1548,13 @@ export async function crawlProject(opts: {
|
|
|
1518
1548
|
let totalAttemptsThisSegment = 0;
|
|
1519
1549
|
const maxAttempts = Math.max(segmentSize * 4, 200);
|
|
1520
1550
|
|
|
1551
|
+
let pendingSessionRestore: (() => Promise<void>) | null = null;
|
|
1552
|
+
|
|
1521
1553
|
while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
|
|
1554
|
+
if (pendingSessionRestore) {
|
|
1555
|
+
await pendingSessionRestore();
|
|
1556
|
+
pendingSessionRestore = null;
|
|
1557
|
+
}
|
|
1522
1558
|
if (++totalAttemptsThisSegment > maxAttempts) {
|
|
1523
1559
|
logger.warn({ maxAttempts, visited: visited.size, queued: queue.length }, '[crawler] attempt cap reached — stopping to prevent URL explosion');
|
|
1524
1560
|
break;
|
|
@@ -1608,6 +1644,41 @@ export async function crawlProject(opts: {
|
|
|
1608
1644
|
}
|
|
1609
1645
|
};
|
|
1610
1646
|
rawPage.on('response', onRedirectResponse);
|
|
1647
|
+
|
|
1648
|
+
// Session Isolation: Clear session before crawling public auth routes
|
|
1649
|
+
const AUTH_PATH_REGEX = /\/(login|register|signup|sign-in|sign-up|forgot-password|reset-password)([-/]|$)/i;
|
|
1650
|
+
const isAuthRoute = AUTH_PATH_REGEX.test(new URL(url).pathname);
|
|
1651
|
+
const browserCtx = playwrightBrowser?.contexts()[0];
|
|
1652
|
+
|
|
1653
|
+
if (isAuthRoute && browserCtx) {
|
|
1654
|
+
const currentState = await browserCtx.storageState();
|
|
1655
|
+
const hasSession = (currentState.cookies?.length ?? 0) > 0 ||
|
|
1656
|
+
currentState.origins?.some((o: any) => (o.localStorage?.length ?? 0) > 0);
|
|
1657
|
+
if (hasSession) {
|
|
1658
|
+
pendingSessionRestore = async () => {
|
|
1659
|
+
const ctx = playwrightBrowser?.contexts()[0];
|
|
1660
|
+
if (ctx) {
|
|
1661
|
+
await ctx.addCookies(currentState.cookies);
|
|
1662
|
+
await rawPage.evaluate((origins: any) => {
|
|
1663
|
+
for (const entry of (origins ?? [])) {
|
|
1664
|
+
if (window.location.origin === entry.origin) {
|
|
1665
|
+
for (const item of entry.localStorage) {
|
|
1666
|
+
try { localStorage.setItem(item.name, item.value); } catch {}
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
}, currentState.origins).catch(() => {});
|
|
1671
|
+
logger.info('[crawler] restored session state after public auth route');
|
|
1672
|
+
}
|
|
1673
|
+
};
|
|
1674
|
+
await browserCtx.clearCookies();
|
|
1675
|
+
await rawPage.evaluate(() => {
|
|
1676
|
+
try { localStorage.clear(); sessionStorage.clear(); } catch {}
|
|
1677
|
+
}).catch(() => {});
|
|
1678
|
+
logger.info({ url }, '[crawler] temporarily cleared session for public auth route');
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1611
1682
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch((navErr: any) => {
|
|
1612
1683
|
logger.warn({ url, err: navErr?.message ?? String(navErr) }, '[crawler] navigation failed — skipping');
|
|
1613
1684
|
});
|
|
@@ -2161,8 +2232,15 @@ export async function crawlProject(opts: {
|
|
|
2161
2232
|
// Parallelize: screenshot + element extraction + link discovery + AT capture (all work on current page state)
|
|
2162
2233
|
const [, elements, nextLinks, atElements] = await Promise.all([
|
|
2163
2234
|
page.screenshot({ path: shotPath, fullPage: true })
|
|
2164
|
-
.then(() =>
|
|
2165
|
-
|
|
2235
|
+
.then(async () => {
|
|
2236
|
+
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
|
|
2237
|
+
return AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', cloudUrl);
|
|
2238
|
+
})
|
|
2239
|
+
.catch((err: any) => console.error('[crawler] page.screenshot failed:', err)),
|
|
2240
|
+
extractElements(stagehand, page, elementLLM ?? undefined).catch((err: any) => {
|
|
2241
|
+
console.error('[crawler] extractElements failed:', err);
|
|
2242
|
+
return [] as Awaited<ReturnType<typeof extractElements>>;
|
|
2243
|
+
}),
|
|
2166
2244
|
// Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
|
|
2167
2245
|
// Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
|
|
2168
2246
|
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0)
|
|
@@ -2639,7 +2717,7 @@ export async function crawlProject(opts: {
|
|
|
2639
2717
|
fcp: rawPerf?.fcp,
|
|
2640
2718
|
cls: rawPerf?.cls,
|
|
2641
2719
|
ttfb: rawPerf?.ttfb,
|
|
2642
|
-
viewport: 'DESKTOP',
|
|
2720
|
+
viewport: opts.viewport ?? 'DESKTOP',
|
|
2643
2721
|
...(lhScores ? {
|
|
2644
2722
|
lighthousePerf: lhScores.perf,
|
|
2645
2723
|
lighthouseA11y: lhScores.a11y,
|
|
@@ -2785,6 +2863,11 @@ export async function crawlProject(opts: {
|
|
|
2785
2863
|
}
|
|
2786
2864
|
}
|
|
2787
2865
|
|
|
2866
|
+
if (pendingSessionRestore) {
|
|
2867
|
+
await pendingSessionRestore();
|
|
2868
|
+
pendingSessionRestore = null;
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2788
2871
|
// P1: URL Pattern LLM Completion — post-crawl LLM prediction of undiscovered URLs.
|
|
2789
2872
|
// Single LLM call predicts missing routes from discovered URL patterns, HEAD-validates each.
|
|
2790
2873
|
// Saves URL_CANDIDATES evidence so the ActivityDrawer shows what was found.
|
|
@@ -3066,8 +3149,7 @@ export async function crawlProject(opts: {
|
|
|
3066
3149
|
logger.info({ url: sessionCheckUrl }, '[P5] Session alive — proceeding with viewport pass');
|
|
3067
3150
|
}
|
|
3068
3151
|
} catch (err) {
|
|
3069
|
-
logger.warn({ err: String(err) }, '[P5] Session health check failed —
|
|
3070
|
-
sessionAliveForViewports = false;
|
|
3152
|
+
logger.warn({ err: String(err) }, '[P5] Session health check navigation failed — proceeding with viewport pass anyway');
|
|
3071
3153
|
}
|
|
3072
3154
|
|
|
3073
3155
|
// Step 5: Multi-viewport pass — screenshot + DOM element extraction at each device profile.
|
|
@@ -3130,7 +3212,8 @@ export async function crawlProject(opts: {
|
|
|
3130
3212
|
mobilePage.screenshot({ path: shotPath, fullPage: true }),
|
|
3131
3213
|
domFallbackElements(mobilePage),
|
|
3132
3214
|
]);
|
|
3133
|
-
await
|
|
3215
|
+
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
|
|
3216
|
+
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, disc.screenId, shotPath, disc.version!, profile, cloudUrl);
|
|
3134
3217
|
AppBrain.autoCreateVisualBaseline(opts.tenantId, opts.projectId, disc.screenId, profile, shotPath, gitInfo.branch, gitInfo.commit || undefined).catch(() => { });
|
|
3135
3218
|
// Save viewport-specific element records (bounding rects differ per viewport)
|
|
3136
3219
|
for (const el of vpElements) {
|
|
@@ -4208,7 +4291,14 @@ async function captureAuthDiscoveryScreen(
|
|
|
4208
4291
|
await page.screenshot({ path: shotPath, fullPage: true }).catch(() => { });
|
|
4209
4292
|
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, name, screenUrl, opts.jobId, source === 'entry' ? 'ENTRY_POINT' : 'SEED_URL').catch(() => null);
|
|
4210
4293
|
if (!screen) return { captured: false, finalUrl, flowType };
|
|
4211
|
-
|
|
4294
|
+
let authCloudUrl: string | undefined;
|
|
4295
|
+
if (opts.storageConfig) {
|
|
4296
|
+
try {
|
|
4297
|
+
const { uploadScreenshotToTenantStorage } = await import('@detiq/app-brain');
|
|
4298
|
+
authCloudUrl = await uploadScreenshotToTenantStorage(opts.storageConfig as any, shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
|
|
4299
|
+
} catch { /* non-fatal */ }
|
|
4300
|
+
}
|
|
4301
|
+
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', authCloudUrl).catch(() => { });
|
|
4212
4302
|
await AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
4213
4303
|
screenId: screen.id,
|
|
4214
4304
|
jobId: opts.jobId,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
@@ -2160,8 +2160,9 @@
|
|
2
|
+
|
|
3
|
+
const [, elements, nextLinks, atElements] = await Promise.all([
|
|
4
|
+
page.screenshot({ path: shotPath, fullPage: true })
|
|
5
|
+
- .then(() => AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP')),
|
|
6
|
+
- extractElements(stagehand, page, elementLLM ?? undefined).catch(() => [] as Awaited<ReturnType<typeof extractElements>>),
|
|
7
|
+
+ .then(() => AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP'))
|
|
8
|
+
+ .catch((err) => console.error('[crawler] page.screenshot failed:', err)),
|
|
9
|
+
+ extractElements(stagehand, page, elementLLM ?? undefined).catch((err) => { console.error('[crawler] extractElements failed:', err); return [] as Awaited<ReturnType<typeof extractElements>> }),
|
|
10
|
+
extractNavigationLinks(page).catch(() => [] as string[]),
|
|
11
|
+
stagehand.page.extractElements({
|
|
12
|
+
|
package/src/http-engine.ts
CHANGED
|
@@ -290,7 +290,8 @@ export async function httpCrawl(opts: HttpCrawlOpts): Promise<HttpCrawlResult> {
|
|
|
290
290
|
const maxScreens = opts.maxScreens ?? 100;
|
|
291
291
|
const cacheDir = opts.cacheDir ?? process.env.CRAWLER_CACHE_DIR ?? '/tmp/zeta-cache';
|
|
292
292
|
const timeoutMs = opts.timeoutMs ?? 15_000;
|
|
293
|
-
const
|
|
293
|
+
const CRAWLER_USER_AGENT = process.env.CRAWLER_USER_AGENT ?? 'ZeTa-Crawler/1.0 (+https://zetaai.io/crawler)';
|
|
294
|
+
const userAgent = opts.userAgent ?? CRAWLER_USER_AGENT;
|
|
294
295
|
const cache = new ResponseCache(cacheDir);
|
|
295
296
|
const robots = opts.respectRobotsTxt !== false ? new RobotsCache() : null;
|
|
296
297
|
const urlFilterRe = opts.urlFilter ? (() => { try { return new RegExp(opts.urlFilter!, 'i'); } catch { return null; } })() : null;
|
package/src/job-queue.ts
CHANGED
|
@@ -621,8 +621,10 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
|
|
|
621
621
|
}
|
|
622
622
|
: undefined;
|
|
623
623
|
|
|
624
|
+
const storageConfigForCrawl = await getTenantStorageConfig(tenantId).catch(() => null);
|
|
624
625
|
const result = await crawlProject({
|
|
625
626
|
...config, credentials: runtimeCreds, abortSignal: controller.signal, jobId,
|
|
627
|
+
storageConfig: storageConfigForCrawl,
|
|
626
628
|
onCheckpoint: async (visitedUrls, pendingQueue) => {
|
|
627
629
|
jobCheckpoints.set(jobId, { visitedUrls, pendingQueue });
|
|
628
630
|
},
|
|
@@ -1325,7 +1327,8 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
|
|
|
1325
1327
|
}
|
|
1326
1328
|
|
|
1327
1329
|
const aiHealingEnabled = await isFeatureEnabled(tenantId, 'AI_SELF_HEALING');
|
|
1328
|
-
const
|
|
1330
|
+
const SCREENSHOT_DIR = process.env.SCREENSHOT_DIR ?? '/tmp/zeta-screenshots';
|
|
1331
|
+
const screenshotDir = config.screenshotDir || platformConfig.screenshotDir || SCREENSHOT_DIR;
|
|
1329
1332
|
const VALID_BROWSERS = ['chromium', 'firefox', 'webkit'];
|
|
1330
1333
|
const validBrowser = VALID_BROWSERS.includes(browser) ? browser : 'chromium';
|
|
1331
1334
|
|
|
@@ -1824,7 +1827,7 @@ async function processMonitorJob(jobId: string, config: any, opts?: ProcessJobOp
|
|
|
1824
1827
|
opts?.onProgress?.(pct);
|
|
1825
1828
|
|
|
1826
1829
|
try {
|
|
1827
|
-
const crawlResult = await httpCrawl({ appUrl: url, maxScreens: 1, respectRobotsTxt: false, timeoutMs:
|
|
1830
|
+
const crawlResult = await httpCrawl({ appUrl: url, maxScreens: 1, respectRobotsTxt: false, timeoutMs: parseInt(process.env.CRAWL_TIMEOUT_MS ?? '20000', 10) });
|
|
1828
1831
|
const screen = crawlResult.screens[0];
|
|
1829
1832
|
if (!screen) {
|
|
1830
1833
|
results.push({ url, changed: false, reason: 'fetch_empty', currentHash: '', previousHash: null });
|