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