@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.
Files changed (92) hide show
  1. package/dist/accessibility-auditor.js +74 -54
  2. package/dist/ai-login.js +249 -227
  3. package/dist/auth-token-rotator.js +67 -57
  4. package/dist/auth.js +336 -299
  5. package/dist/browser-extension.js +142 -104
  6. package/dist/captcha-solver.js +125 -98
  7. package/dist/color-contrast.js +89 -77
  8. package/dist/content-chunker.js +126 -126
  9. package/dist/content-dedup.js +71 -54
  10. package/dist/cookie-consent.js +83 -68
  11. package/dist/cookie-jar.js +60 -53
  12. package/dist/crawler.js +5345 -4486
  13. package/dist/custom-js-injector.js +61 -45
  14. package/dist/dark-mode-capture.js +81 -67
  15. package/dist/dead-link-detector.js +55 -47
  16. package/dist/element-hash.js +12 -8
  17. package/dist/email-otp.js +195 -154
  18. package/dist/engine-router.js +98 -87
  19. package/dist/export/bulk-export.js +55 -70
  20. package/dist/export/vector-export.js +105 -77
  21. package/dist/extractors/advanced.d.ts +10 -0
  22. package/dist/extractors/advanced.js +266 -249
  23. package/dist/extractors/artifact-envelope.js +58 -59
  24. package/dist/failure-analysis-agent.js +33 -37
  25. package/dist/feature-flag-detector.js +66 -51
  26. package/dist/form-detector.js +75 -55
  27. package/dist/frontier.js +101 -76
  28. package/dist/gap-pass.js +89 -68
  29. package/dist/graphql-detector.js +72 -62
  30. package/dist/http-engine.js +330 -282
  31. package/dist/incremental-diff.js +87 -54
  32. package/dist/index.d.ts +2 -0
  33. package/dist/index.js +28 -63
  34. package/dist/index.js.map +2 -2
  35. package/dist/input-fuzzer.js +97 -93
  36. package/dist/interactive-probe.d.ts +36 -0
  37. package/dist/interactive-probe.js +827 -715
  38. package/dist/job-queue.js +2169 -2151
  39. package/dist/js-coverage.js +56 -52
  40. package/dist/js-router-detector.js +117 -86
  41. package/dist/language-detector.js +63 -51
  42. package/dist/link-classifier.js +50 -32
  43. package/dist/live-view.js +108 -91
  44. package/dist/locator-extractor.d.ts +37 -0
  45. package/dist/locator-extractor.js +199 -0
  46. package/dist/locator-extractor.js.map +7 -0
  47. package/dist/logger.js +7 -9
  48. package/dist/markdown-extract.js +61 -48
  49. package/dist/native-crawler.js +946 -780
  50. package/dist/network-observer.js +156 -135
  51. package/dist/network-throttle.js +40 -29
  52. package/dist/openapi-generator.js +92 -77
  53. package/dist/pdf-extractor.js +90 -72
  54. package/dist/performance-metrics.js +102 -80
  55. package/dist/phash.js +144 -125
  56. package/dist/pii-scrubber.js +68 -65
  57. package/dist/playwright-recorder.js +58 -70
  58. package/dist/policies/crawl-safety.js +34 -31
  59. package/dist/profiles/domain-profile.js +125 -130
  60. package/dist/proxy.js +172 -146
  61. package/dist/rate-limiter.js +65 -58
  62. package/dist/redirect-tracker.js +64 -60
  63. package/dist/redis-frontier.js +136 -116
  64. package/dist/redis-queue.js +144 -157
  65. package/dist/response-cache.js +140 -127
  66. package/dist/responsive-detector.js +46 -42
  67. package/dist/retry-policy.js +50 -46
  68. package/dist/robots-txt.js +83 -69
  69. package/dist/screen-reader.js +70 -45
  70. package/dist/screenshot-masker.js +77 -59
  71. package/dist/seo-checker.js +107 -88
  72. package/dist/session-capture.js +212 -192
  73. package/dist/session-export.js +133 -107
  74. package/dist/sitemap-writer.js +41 -38
  75. package/dist/sitemap.js +202 -141
  76. package/dist/skyvern.js +148 -123
  77. package/dist/sqs-queue.js +42 -42
  78. package/dist/sso-token.js +90 -74
  79. package/dist/stealth/human-behavior.js +173 -156
  80. package/dist/telemetry.js +82 -68
  81. package/dist/test-executor.js +335 -309
  82. package/dist/tls-fingerprint.js +88 -77
  83. package/dist/url-pattern-completer.js +90 -71
  84. package/dist/visual-diff-verdict.js +38 -23
  85. package/dist/webhook-notifier.js +69 -71
  86. package/dist/websocket-detector.js +68 -56
  87. package/dist/worker.js +38 -29
  88. package/package.json +1 -1
  89. package/src/index.ts +2 -0
  90. package/src/locator-extractor.ts +226 -0
  91. package/types/index.d.ts +2 -0
  92. package/types/locator-extractor.d.ts +37 -0
@@ -1,332 +1,358 @@
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
- }
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
- 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 };
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
- 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
- };
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
- return { loc: primary, healed: false };
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
- result.healed = r.healed;
77
- result.healedVia = r.healedVia;
78
- result.healReasoning = r.healReasoning;
79
- result.healConfidence = r.healConfidence;
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
- 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;
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
- 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;
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
- 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;
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
- 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
- }
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
- 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 {
227
+ const finalPath = `${options.screenshotDir}/${options.testRunId}-final.png`;
228
+ await page.screenshot({ path: finalPath, fullPage: true });
229
+ finalScreenshotPath = finalPath;
195
230
  }
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 };
231
+ catch { /* non-fatal */ }
232
+ return { stepResults, errorStep, finalScreenshotPath, consoleLogs, networkErrors };
208
233
  }
209
234
  function interpolate(template, data) {
210
- if (!template) return template;
211
- return template.replace(/\{\{(\w+)\}\}/g, (_, key) => data[key] ?? `{{${key}}}`);
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
- 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;
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 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()
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
- 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
- };
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