@detiq/crawler 1.1.9 → 1.1.11
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/accessibility-auditor.js +54 -74
- package/dist/ai-login.js +227 -249
- package/dist/auth-token-rotator.js +57 -67
- package/dist/auth.js +299 -336
- package/dist/browser-extension.js +104 -142
- package/dist/captcha-solver.js +98 -125
- package/dist/color-contrast.js +77 -89
- package/dist/content-chunker.js +126 -126
- package/dist/content-dedup.js +54 -71
- package/dist/cookie-consent.js +68 -83
- package/dist/cookie-jar.js +53 -60
- package/dist/crawler.js +4494 -5353
- package/dist/custom-js-injector.js +45 -61
- package/dist/dark-mode-capture.js +67 -81
- package/dist/dead-link-detector.js +47 -55
- package/dist/element-hash.js +8 -12
- package/dist/email-otp.js +154 -195
- package/dist/engine-router.js +87 -98
- package/dist/export/bulk-export.js +70 -55
- package/dist/export/vector-export.js +77 -105
- package/dist/extractors/advanced.js +249 -266
- package/dist/extractors/artifact-envelope.js +59 -58
- package/dist/failure-analysis-agent.js +37 -33
- package/dist/feature-flag-detector.js +51 -66
- package/dist/form-detector.js +55 -75
- package/dist/frontier.js +76 -101
- package/dist/gap-pass.js +68 -89
- package/dist/graphql-detector.js +62 -72
- package/dist/http-engine.js +282 -330
- package/dist/incremental-diff.js +54 -87
- package/dist/index.js +67 -28
- package/dist/input-fuzzer.js +93 -97
- package/dist/interactive-probe.js +715 -827
- package/dist/job-queue.js +2151 -2169
- package/dist/js-coverage.js +52 -56
- package/dist/js-router-detector.js +86 -117
- package/dist/language-detector.js +51 -63
- package/dist/link-classifier.js +32 -50
- package/dist/live-view.js +91 -108
- package/dist/locator-extractor.js +151 -187
- package/dist/logger.js +9 -7
- package/dist/markdown-extract.js +48 -61
- package/dist/native-crawler.js +780 -946
- package/dist/network-observer.js +135 -156
- package/dist/network-throttle.js +29 -40
- package/dist/openapi-generator.js +77 -92
- package/dist/pdf-extractor.js +72 -90
- package/dist/performance-metrics.js +80 -102
- package/dist/phash.js +125 -144
- package/dist/pii-scrubber.js +65 -68
- package/dist/playwright-recorder.js +70 -58
- package/dist/policies/crawl-safety.js +31 -34
- package/dist/profiles/domain-profile.js +130 -125
- package/dist/proxy.js +146 -172
- package/dist/rate-limiter.js +58 -65
- package/dist/redirect-tracker.js +60 -64
- package/dist/redis-frontier.js +116 -136
- package/dist/redis-queue.js +157 -144
- package/dist/response-cache.js +127 -140
- package/dist/responsive-detector.js +42 -46
- package/dist/retry-policy.js +46 -50
- package/dist/robots-txt.js +69 -83
- package/dist/screen-reader.js +45 -70
- package/dist/screenshot-masker.js +59 -77
- package/dist/seo-checker.js +88 -107
- package/dist/session-capture.js +192 -212
- package/dist/session-export.js +107 -133
- package/dist/sitemap-writer.js +38 -41
- package/dist/sitemap.js +141 -202
- package/dist/skyvern.js +123 -148
- package/dist/sqs-queue.js +42 -42
- package/dist/sso-token.js +74 -90
- package/dist/stealth/human-behavior.js +156 -173
- package/dist/telemetry.js +68 -82
- package/dist/test-executor.js +309 -335
- package/dist/tls-fingerprint.js +77 -88
- package/dist/url-pattern-completer.js +71 -90
- package/dist/visual-diff-verdict.js +23 -38
- package/dist/webhook-notifier.js +71 -69
- package/dist/websocket-detector.js +56 -68
- package/dist/worker.js +29 -38
- package/package.json +1 -1
- package/src/job-queue.ts +136 -0
package/dist/test-executor.js
CHANGED
|
@@ -1,358 +1,332 @@
|
|
|
1
|
-
import { chromium, firefox, webkit } from
|
|
2
|
-
import { assertCrawlUrlSafe } from
|
|
3
|
-
import { analyzeFailure } from
|
|
4
|
-
const LOCATOR_STRATEGIES = [
|
|
5
|
-
function buildLocator(page, selector, strategy =
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
import { chromium, firefox, webkit } from "playwright";
|
|
2
|
+
import { assertCrawlUrlSafe } from "@detiq/core";
|
|
3
|
+
import { analyzeFailure } from "./failure-analysis-agent.js";
|
|
4
|
+
const LOCATOR_STRATEGIES = ["text", "label", "role", "css"];
|
|
5
|
+
function buildLocator(page, selector, strategy = "text") {
|
|
6
|
+
switch (strategy) {
|
|
7
|
+
case "css":
|
|
8
|
+
return page.locator(selector);
|
|
9
|
+
case "text":
|
|
10
|
+
return page.getByText(selector, { exact: false });
|
|
11
|
+
case "role":
|
|
12
|
+
return page.getByRole(selector);
|
|
13
|
+
case "label":
|
|
14
|
+
return page.getByLabel(selector);
|
|
15
|
+
default:
|
|
16
|
+
return page.locator(selector);
|
|
17
|
+
}
|
|
13
18
|
}
|
|
14
|
-
/** Scans the live page for visible interactive elements — the "current page elements"
|
|
15
|
-
* fed to the AI Self-Healing Agent when mechanical locator strategies fail. */
|
|
16
19
|
async function extractCurrentPageElements(page) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return { meaning: ariaLabel || textContent || role, role, ariaLabel, textContent };
|
|
30
|
-
});
|
|
20
|
+
return page.evaluate(() => {
|
|
21
|
+
const els = Array.from(document.querySelectorAll(
|
|
22
|
+
'a, button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [aria-label]'
|
|
23
|
+
));
|
|
24
|
+
return els.filter((el) => {
|
|
25
|
+
const rect = el.getBoundingClientRect();
|
|
26
|
+
return rect.width > 0 && rect.height > 0;
|
|
27
|
+
}).slice(0, 200).map((el) => {
|
|
28
|
+
const ariaLabel = el.getAttribute("aria-label") ?? void 0;
|
|
29
|
+
const textContent = (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 100) || void 0;
|
|
30
|
+
const role = el.getAttribute("role") ?? el.tagName.toLowerCase();
|
|
31
|
+
return { meaning: ariaLabel || textContent || role, role, ariaLabel, textContent };
|
|
31
32
|
});
|
|
33
|
+
});
|
|
32
34
|
}
|
|
33
|
-
/**
|
|
34
|
-
* Resolves a step's target element in three tiers:
|
|
35
|
-
* 1. Exact selector + strategy as generated.
|
|
36
|
-
* 2. Mechanical: same selector text, other Playwright locator strategies
|
|
37
|
-
* (handles a strategy mismatch, not an actual UI change).
|
|
38
|
-
* 3. AI: when tenantId/projectId are supplied, asks the Self-Healing Agent to find
|
|
39
|
-
* the current page's best semantic match for the failed step description — the
|
|
40
|
-
* tier that actually survives the app's selector text changing.
|
|
41
|
-
*/
|
|
42
35
|
async function resolveLocator(page, step, healCtx) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
loc: healedLoc,
|
|
76
|
-
healed: true,
|
|
77
|
-
healedVia: 'ai',
|
|
78
|
-
healReasoning: result.reasoning,
|
|
79
|
-
healConfidence: result.confidence,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
catch {
|
|
85
|
-
// AI healing is a best-effort last resort — any failure here (LLM error, bad
|
|
86
|
-
// JSON, etc.) just falls through to the caller's normal Playwright error below.
|
|
36
|
+
const { selector, selectorStrategy = "text" } = step;
|
|
37
|
+
if (!selector) return null;
|
|
38
|
+
const primary = buildLocator(page, selector, selectorStrategy);
|
|
39
|
+
const attached = await primary.first().isVisible({ timeout: parseInt(process.env.TEST_WAIT_TIMEOUT_MS ?? "1500", 10) }).catch(() => false);
|
|
40
|
+
if (attached) return { loc: primary, healed: false };
|
|
41
|
+
for (const strategy of LOCATOR_STRATEGIES) {
|
|
42
|
+
if (strategy === selectorStrategy) continue;
|
|
43
|
+
const loc = buildLocator(page, selector, strategy);
|
|
44
|
+
const visible = await loc.first().isVisible({ timeout: parseInt(process.env.TEST_SHORT_TIMEOUT_MS ?? "800", 10) }).catch(() => false);
|
|
45
|
+
if (visible) return { loc, healed: true, healedVia: "mechanical" };
|
|
46
|
+
}
|
|
47
|
+
if (healCtx) {
|
|
48
|
+
try {
|
|
49
|
+
const { healElement } = await import("@detiq/agents");
|
|
50
|
+
const currentPageElements = await extractCurrentPageElements(page);
|
|
51
|
+
const result = await healElement({
|
|
52
|
+
tenantId: healCtx.tenantId,
|
|
53
|
+
projectId: healCtx.projectId,
|
|
54
|
+
failedElementMeaning: step.description || selector,
|
|
55
|
+
currentPageElements
|
|
56
|
+
});
|
|
57
|
+
if (result.healed && result.updatedTest) {
|
|
58
|
+
const healedLoc = page.getByText(result.updatedTest.elementMeaning, { exact: false });
|
|
59
|
+
const visible = await healedLoc.first().isVisible({ timeout: parseInt(process.env.TEST_WAIT_TIMEOUT_MS ?? "1500", 10) }).catch(() => false);
|
|
60
|
+
if (visible) {
|
|
61
|
+
return {
|
|
62
|
+
loc: healedLoc,
|
|
63
|
+
healed: true,
|
|
64
|
+
healedVia: "ai",
|
|
65
|
+
healReasoning: result.reasoning,
|
|
66
|
+
healConfidence: result.confidence
|
|
67
|
+
};
|
|
87
68
|
}
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
88
71
|
}
|
|
89
|
-
|
|
90
|
-
|
|
72
|
+
}
|
|
73
|
+
return { loc: primary, healed: false };
|
|
91
74
|
}
|
|
92
75
|
function applyHealInfo(result, r) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
76
|
+
result.healed = r.healed;
|
|
77
|
+
result.healedVia = r.healedVia;
|
|
78
|
+
result.healReasoning = r.healReasoning;
|
|
79
|
+
result.healConfidence = r.healConfidence;
|
|
97
80
|
}
|
|
98
81
|
async function runPlan(page, plan, options, consoleLogs, networkErrors) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
await r.loc.first().fill(step.value ?? '', { timeout });
|
|
136
|
-
applyHealInfo(result, r);
|
|
137
|
-
break;
|
|
138
|
-
}
|
|
139
|
-
case 'select': {
|
|
140
|
-
const r = await resolveLocator(page, step, options.healCtx);
|
|
141
|
-
if (!r)
|
|
142
|
-
throw new Error('Could not resolve locator');
|
|
143
|
-
result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
|
|
144
|
-
result.elementSelector = step.selector;
|
|
145
|
-
await r.loc.first().selectOption(step.value ?? '', { timeout });
|
|
146
|
-
applyHealInfo(result, r);
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
case 'hover': {
|
|
150
|
-
const r = await resolveLocator(page, step, options.healCtx);
|
|
151
|
-
if (!r)
|
|
152
|
-
throw new Error('Could not resolve locator');
|
|
153
|
-
result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
|
|
154
|
-
result.elementSelector = step.selector;
|
|
155
|
-
await r.loc.first().hover({ timeout });
|
|
156
|
-
applyHealInfo(result, r);
|
|
157
|
-
break;
|
|
158
|
-
}
|
|
159
|
-
case 'press_key':
|
|
160
|
-
await page.keyboard.press(step.value ?? 'Enter');
|
|
161
|
-
break;
|
|
162
|
-
case 'assert_visible': {
|
|
163
|
-
const r = await resolveLocator(page, step, options.healCtx);
|
|
164
|
-
if (!r)
|
|
165
|
-
throw new Error('Could not resolve locator');
|
|
166
|
-
await r.loc.first().waitFor({ state: 'visible', timeout });
|
|
167
|
-
result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
|
|
168
|
-
result.elementSelector = step.selector;
|
|
169
|
-
applyHealInfo(result, r);
|
|
170
|
-
break;
|
|
171
|
-
}
|
|
172
|
-
case 'assert_text': {
|
|
173
|
-
const r = await resolveLocator(page, step, options.healCtx);
|
|
174
|
-
if (!r)
|
|
175
|
-
throw new Error('Could not resolve locator');
|
|
176
|
-
const text = await r.loc.first().textContent({ timeout });
|
|
177
|
-
if (!text?.includes(step.value ?? '')) {
|
|
178
|
-
throw new Error(`Expected text "${step.value}" not found. Got: "${text?.slice(0, 100)}"`);
|
|
179
|
-
}
|
|
180
|
-
result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
|
|
181
|
-
result.elementSelector = step.selector;
|
|
182
|
-
applyHealInfo(result, r);
|
|
183
|
-
break;
|
|
184
|
-
}
|
|
185
|
-
case 'assert_url': {
|
|
186
|
-
const url = page.url();
|
|
187
|
-
if (!url.includes(step.value ?? '')) {
|
|
188
|
-
throw new Error(`Expected URL to contain "${step.value}". Got: "${url}"`);
|
|
189
|
-
}
|
|
190
|
-
break;
|
|
191
|
-
}
|
|
192
|
-
case 'wait':
|
|
193
|
-
await page.waitForTimeout(step.timeout ?? parseInt(process.env.TEST_STEP_DELAY_MS ?? '2000', 10));
|
|
194
|
-
break;
|
|
195
|
-
case 'screenshot': {
|
|
196
|
-
const ssPath = `${options.screenshotDir}/${options.testRunId}-step-${step.stepNumber}.png`;
|
|
197
|
-
await page.screenshot({ path: ssPath, fullPage: true });
|
|
198
|
-
result.screenshotPath = ssPath;
|
|
199
|
-
break;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
result.status = 'PASSED';
|
|
82
|
+
const stepResults = [];
|
|
83
|
+
let errorStep;
|
|
84
|
+
let finalScreenshotPath;
|
|
85
|
+
for (const step of plan) {
|
|
86
|
+
const stepStart = Date.now();
|
|
87
|
+
const result = {
|
|
88
|
+
stepNumber: step.stepNumber,
|
|
89
|
+
description: step.description,
|
|
90
|
+
status: "FAILED",
|
|
91
|
+
duration: 0
|
|
92
|
+
};
|
|
93
|
+
try {
|
|
94
|
+
const timeout = step.timeout ?? parseInt(process.env.TEST_STEP_TIMEOUT_MS ?? "8000", 10);
|
|
95
|
+
switch (step.action) {
|
|
96
|
+
case "navigate":
|
|
97
|
+
if (!step.url) throw new Error("Navigate step missing url");
|
|
98
|
+
assertCrawlUrlSafe(step.url);
|
|
99
|
+
await page.goto(step.url, { waitUntil: "domcontentloaded", timeout });
|
|
100
|
+
break;
|
|
101
|
+
case "click": {
|
|
102
|
+
const r = await resolveLocator(page, step, options.healCtx);
|
|
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;
|
|
106
|
+
await r.loc.first().click({ timeout });
|
|
107
|
+
applyHealInfo(result, r);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case "fill": {
|
|
111
|
+
const r = await resolveLocator(page, step, options.healCtx);
|
|
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;
|
|
115
|
+
await r.loc.first().fill(step.value ?? "", { timeout });
|
|
116
|
+
applyHealInfo(result, r);
|
|
117
|
+
break;
|
|
203
118
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
119
|
+
case "select": {
|
|
120
|
+
const r = await resolveLocator(page, step, options.healCtx);
|
|
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;
|
|
124
|
+
await r.loc.first().selectOption(step.value ?? "", { timeout });
|
|
125
|
+
applyHealInfo(result, r);
|
|
126
|
+
break;
|
|
210
127
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
128
|
+
case "hover": {
|
|
129
|
+
const r = await resolveLocator(page, step, options.healCtx);
|
|
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;
|
|
133
|
+
await r.loc.first().hover({ timeout });
|
|
134
|
+
applyHealInfo(result, r);
|
|
135
|
+
break;
|
|
218
136
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
137
|
+
case "press_key":
|
|
138
|
+
await page.keyboard.press(step.value ?? "Enter");
|
|
139
|
+
break;
|
|
140
|
+
case "assert_visible": {
|
|
141
|
+
const r = await resolveLocator(page, step, options.healCtx);
|
|
142
|
+
if (!r) throw new Error("Could not resolve locator");
|
|
143
|
+
await r.loc.first().waitFor({ state: "visible", timeout });
|
|
144
|
+
result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
|
|
145
|
+
result.elementSelector = step.selector;
|
|
146
|
+
applyHealInfo(result, r);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case "assert_text": {
|
|
150
|
+
const r = await resolveLocator(page, step, options.healCtx);
|
|
151
|
+
if (!r) throw new Error("Could not resolve locator");
|
|
152
|
+
const text = await r.loc.first().textContent({ timeout });
|
|
153
|
+
if (!text?.includes(step.value ?? "")) {
|
|
154
|
+
throw new Error(`Expected text "${step.value}" not found. Got: "${text?.slice(0, 100)}"`);
|
|
155
|
+
}
|
|
156
|
+
result.elementBoundingRect = await r.loc.first().boundingBox().catch(() => null);
|
|
157
|
+
result.elementSelector = step.selector;
|
|
158
|
+
applyHealInfo(result, r);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case "assert_url": {
|
|
162
|
+
const url = page.url();
|
|
163
|
+
if (!url.includes(step.value ?? "")) {
|
|
164
|
+
throw new Error(`Expected URL to contain "${step.value}". Got: "${url}"`);
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case "wait":
|
|
169
|
+
await page.waitForTimeout(step.timeout ?? parseInt(process.env.TEST_STEP_DELAY_MS ?? "2000", 10));
|
|
170
|
+
break;
|
|
171
|
+
case "screenshot": {
|
|
172
|
+
const ssPath = `${options.screenshotDir}/${options.testRunId}-step-${step.stepNumber}.png`;
|
|
173
|
+
await page.screenshot({ path: ssPath, fullPage: true });
|
|
174
|
+
result.screenshotPath = ssPath;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
result.status = "PASSED";
|
|
179
|
+
} catch (err) {
|
|
180
|
+
result.status = step.optional ? "SKIPPED" : "FAILED";
|
|
181
|
+
result.error = err.message;
|
|
182
|
+
if (!step.optional) {
|
|
183
|
+
errorStep = step.stepNumber;
|
|
184
|
+
}
|
|
225
185
|
}
|
|
226
186
|
try {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
187
|
+
await page.waitForLoadState("networkidle", { timeout: parseInt(process.env.TEST_NETWORK_IDLE_TIMEOUT_MS ?? "1500", 10) }).catch(() => {
|
|
188
|
+
});
|
|
189
|
+
if (!result.screenshotPath) {
|
|
190
|
+
const ssPath = `${options.screenshotDir}/${options.testRunId}-step-${step.stepNumber}.png`;
|
|
191
|
+
await page.screenshot({ path: ssPath, fullPage: true });
|
|
192
|
+
result.screenshotPath = ssPath;
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
230
195
|
}
|
|
231
|
-
|
|
232
|
-
|
|
196
|
+
result.duration = Date.now() - stepStart;
|
|
197
|
+
stepResults.push(result);
|
|
198
|
+
options.onStepComplete?.(result);
|
|
199
|
+
if (result.status === "FAILED") break;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const finalPath = `${options.screenshotDir}/${options.testRunId}-final.png`;
|
|
203
|
+
await page.screenshot({ path: finalPath, fullPage: true });
|
|
204
|
+
finalScreenshotPath = finalPath;
|
|
205
|
+
} catch {
|
|
206
|
+
}
|
|
207
|
+
return { stepResults, errorStep, finalScreenshotPath, consoleLogs, networkErrors };
|
|
233
208
|
}
|
|
234
209
|
function interpolate(template, data) {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => data[key] ?? `{{${key}}}`);
|
|
210
|
+
if (!template) return template;
|
|
211
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => data[key] ?? `{{${key}}}`);
|
|
238
212
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
213
|
+
async function executeTestPlan(plan, options) {
|
|
214
|
+
if (options.dataRow && Object.keys(options.dataRow).length > 0) {
|
|
215
|
+
const data = options.dataRow;
|
|
216
|
+
plan = plan.map((step) => ({
|
|
217
|
+
...step,
|
|
218
|
+
url: interpolate(step.url, data),
|
|
219
|
+
selector: interpolate(step.selector, data),
|
|
220
|
+
value: interpolate(step.value, data),
|
|
221
|
+
description: interpolate(step.description, data) ?? step.description
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
const maxRuns = options.maxRuns ?? 1;
|
|
225
|
+
const browserEngine = { chromium, firefox, webkit }[options.browser ?? "chromium"] ?? chromium;
|
|
226
|
+
const start = Date.now();
|
|
227
|
+
const outcomes = [];
|
|
228
|
+
let lastResult = null;
|
|
229
|
+
let lastNetworkTimings = [];
|
|
230
|
+
for (let run = 0; run < maxRuns; run++) {
|
|
231
|
+
const browser = await browserEngine.launch({
|
|
232
|
+
headless: true,
|
|
233
|
+
// Optional override for environments with a non-standard/pre-provisioned
|
|
234
|
+
// browser install (e.g. a custom base image with only system Chromium).
|
|
235
|
+
...process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}
|
|
236
|
+
});
|
|
237
|
+
let parsedStorageState;
|
|
238
|
+
try {
|
|
239
|
+
parsedStorageState = options.storageState ? JSON.parse(options.storageState) : void 0;
|
|
240
|
+
} catch {
|
|
241
|
+
parsedStorageState = void 0;
|
|
249
242
|
}
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
page.on('request', request => {
|
|
288
|
-
const url = request.url();
|
|
289
|
-
if (url.includes('/api/') || url.match(/\/(graphql|rest|v\d)\//)) {
|
|
290
|
-
requestStartTimes.set(request.url(), Date.now());
|
|
291
|
-
}
|
|
292
|
-
});
|
|
293
|
-
page.on('response', response => {
|
|
294
|
-
const url = response.url();
|
|
295
|
-
const start = requestStartTimes.get(url);
|
|
296
|
-
if (start && networkTimings.length < 200) {
|
|
297
|
-
networkTimings.push({ url: url.slice(0, 300), method: response.request().method(), status: response.status(), duration: Date.now() - start });
|
|
298
|
-
requestStartTimes.delete(url);
|
|
299
|
-
}
|
|
300
|
-
if (!response.ok() && !url.includes('favicon') && networkErrors.length < 50) {
|
|
301
|
-
networkErrors.push({
|
|
302
|
-
url: url.slice(0, 300),
|
|
303
|
-
status: response.status(),
|
|
304
|
-
method: response.request().method(),
|
|
305
|
-
timestamp: Date.now(),
|
|
306
|
-
});
|
|
307
|
-
}
|
|
243
|
+
const context = await browser.newContext({
|
|
244
|
+
viewport: options.viewport ?? { width: 1440, height: 900 },
|
|
245
|
+
...parsedStorageState ? { storageState: parsedStorageState } : {}
|
|
246
|
+
});
|
|
247
|
+
const page = await context.newPage();
|
|
248
|
+
await page.addStyleTag({ content: "*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }" }).catch(() => {
|
|
249
|
+
});
|
|
250
|
+
const consoleLogs = [];
|
|
251
|
+
const networkErrors = [];
|
|
252
|
+
const networkTimings = [];
|
|
253
|
+
const requestStartTimes = /* @__PURE__ */ new Map();
|
|
254
|
+
page.on("console", (msg) => {
|
|
255
|
+
if (consoleLogs.length < 100) {
|
|
256
|
+
consoleLogs.push({ type: msg.type(), text: msg.text().slice(0, 500), timestamp: Date.now() });
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
page.on("request", (request) => {
|
|
260
|
+
const url = request.url();
|
|
261
|
+
if (url.includes("/api/") || url.match(/\/(graphql|rest|v\d)\//)) {
|
|
262
|
+
requestStartTimes.set(request.url(), Date.now());
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
page.on("response", (response) => {
|
|
266
|
+
const url = response.url();
|
|
267
|
+
const start2 = requestStartTimes.get(url);
|
|
268
|
+
if (start2 && networkTimings.length < 200) {
|
|
269
|
+
networkTimings.push({ url: url.slice(0, 300), method: response.request().method(), status: response.status(), duration: Date.now() - start2 });
|
|
270
|
+
requestStartTimes.delete(url);
|
|
271
|
+
}
|
|
272
|
+
if (!response.ok() && !url.includes("favicon") && networkErrors.length < 50) {
|
|
273
|
+
networkErrors.push({
|
|
274
|
+
url: url.slice(0, 300),
|
|
275
|
+
status: response.status(),
|
|
276
|
+
method: response.request().method(),
|
|
277
|
+
timestamp: Date.now()
|
|
308
278
|
});
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
const TEST_EXECUTION_TIMEOUT_MS = 5 * 6e4;
|
|
282
|
+
lastResult = await Promise.race([
|
|
283
|
+
runPlan(page, plan, options, consoleLogs, networkErrors),
|
|
284
|
+
new Promise(
|
|
285
|
+
(_, reject) => setTimeout(() => reject(new Error("Test execution timed out")), TEST_EXECUTION_TIMEOUT_MS)
|
|
286
|
+
)
|
|
287
|
+
]);
|
|
288
|
+
lastNetworkTimings = networkTimings;
|
|
289
|
+
await browser.close();
|
|
290
|
+
const passed = !lastResult.stepResults.some((r) => r.status === "FAILED");
|
|
291
|
+
outcomes.push(passed ? "PASSED" : "FAILED");
|
|
292
|
+
if (run > 0 && outcomes.every((o) => o === outcomes[0])) break;
|
|
293
|
+
}
|
|
294
|
+
const passCount = outcomes.filter((o) => o === "PASSED").length;
|
|
295
|
+
const failCount = outcomes.filter((o) => o === "FAILED").length;
|
|
296
|
+
let status;
|
|
297
|
+
if (maxRuns > 1 && passCount > 0 && failCount > 0) {
|
|
298
|
+
status = "FLAKY";
|
|
299
|
+
} else if (passCount === outcomes.length) {
|
|
300
|
+
status = "PASSED";
|
|
301
|
+
} else {
|
|
302
|
+
status = "FAILED";
|
|
303
|
+
}
|
|
304
|
+
let rootCauseSummary;
|
|
305
|
+
if (status === "FAILED" || status === "FLAKY") {
|
|
306
|
+
const failedStep = lastResult.stepResults.find((s) => s.status === "FAILED");
|
|
307
|
+
rootCauseSummary = await analyzeFailure({
|
|
308
|
+
tenantId: options.healCtx?.tenantId,
|
|
309
|
+
error: failedStep?.error ?? "Unknown error",
|
|
310
|
+
stepDescription: failedStep?.description,
|
|
311
|
+
consoleLogs: lastResult.consoleLogs,
|
|
312
|
+
networkErrors: lastResult.networkErrors,
|
|
313
|
+
screenshotPath: lastResult.finalScreenshotPath
|
|
314
|
+
}).catch(() => void 0) ?? void 0;
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
stepResults: lastResult.stepResults,
|
|
318
|
+
status,
|
|
319
|
+
errorStep: lastResult.errorStep,
|
|
320
|
+
totalDuration: Date.now() - start,
|
|
321
|
+
finalScreenshotPath: lastResult.finalScreenshotPath,
|
|
322
|
+
retryCount: outcomes.length - 1,
|
|
323
|
+
consoleLogs: lastResult.consoleLogs,
|
|
324
|
+
networkErrors: lastResult.networkErrors,
|
|
325
|
+
rootCauseSummary,
|
|
326
|
+
networkTimings: lastNetworkTimings
|
|
327
|
+
};
|
|
358
328
|
}
|
|
329
|
+
export {
|
|
330
|
+
executeTestPlan
|
|
331
|
+
};
|
|
332
|
+
//# sourceMappingURL=test-executor.js.map
|