@odavl/guardian 0.1.0-rc1 → 1.0.0
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/CHANGELOG.md +146 -0
- package/README.md +155 -97
- package/bin/guardian.js +1544 -55
- package/config/README.md +59 -0
- package/config/profiles/landing-demo.yaml +16 -0
- package/package.json +26 -11
- package/policies/landing-demo.json +22 -0
- package/src/enterprise/audit-logger.js +166 -0
- package/src/enterprise/pdf-exporter.js +267 -0
- package/src/enterprise/rbac-gate.js +142 -0
- package/src/enterprise/rbac.js +239 -0
- package/src/enterprise/site-manager.js +180 -0
- package/src/founder/feedback-system.js +156 -0
- package/src/founder/founder-tracker.js +213 -0
- package/src/founder/usage-signals.js +141 -0
- package/src/guardian/alert-ledger.js +121 -0
- package/src/guardian/attempt-engine.js +587 -12
- package/src/guardian/attempt-registry.js +42 -1
- package/src/guardian/attempt-relevance.js +106 -0
- package/src/guardian/attempt.js +85 -39
- package/src/guardian/attempts-filter.js +63 -0
- package/src/guardian/baseline.js +50 -8
- package/src/guardian/breakage-intelligence.js +1 -0
- package/src/guardian/browser-pool.js +131 -0
- package/src/guardian/browser.js +28 -1
- package/src/guardian/ci-cli.js +121 -0
- package/src/guardian/ci-mode.js +15 -0
- package/src/guardian/ci-output.js +38 -0
- package/src/guardian/cli-summary.js +167 -67
- package/src/guardian/config-loader.js +162 -0
- package/src/guardian/data-guardian-detector.js +189 -0
- package/src/guardian/detection-layers.js +271 -0
- package/src/guardian/drift-detector.js +100 -0
- package/src/guardian/enhanced-html-reporter.js +221 -4
- package/src/guardian/env-guard.js +127 -0
- package/src/guardian/failure-intelligence.js +173 -0
- package/src/guardian/first-run-profile.js +89 -0
- package/src/guardian/first-run.js +54 -0
- package/src/guardian/flag-validator.js +111 -0
- package/src/guardian/flow-executor.js +309 -44
- package/src/guardian/html-reporter.js +2 -0
- package/src/guardian/human-reporter.js +431 -0
- package/src/guardian/index.js +22 -19
- package/src/guardian/init-command.js +9 -5
- package/src/guardian/intent-detector.js +146 -0
- package/src/guardian/journey-definitions.js +132 -0
- package/src/guardian/journey-scan-cli.js +145 -0
- package/src/guardian/journey-scanner.js +583 -0
- package/src/guardian/junit-reporter.js +18 -1
- package/src/guardian/language-detection.js +99 -0
- package/src/guardian/live-cli.js +95 -0
- package/src/guardian/live-scheduler-runner.js +137 -0
- package/src/guardian/live-scheduler.js +146 -0
- package/src/guardian/market-reporter.js +357 -82
- package/src/guardian/parallel-executor.js +116 -0
- package/src/guardian/pattern-analyzer.js +348 -0
- package/src/guardian/policy.js +80 -3
- package/src/guardian/prerequisite-checker.js +101 -0
- package/src/guardian/preset-loader.js +27 -18
- package/src/guardian/profile-loader.js +96 -0
- package/src/guardian/reality.js +1612 -115
- package/src/guardian/reporter.js +27 -41
- package/src/guardian/run-artifacts.js +212 -0
- package/src/guardian/run-cleanup.js +207 -0
- package/src/guardian/run-latest.js +90 -0
- package/src/guardian/run-list.js +211 -0
- package/src/guardian/run-summary.js +20 -0
- package/src/guardian/scan-presets.js +100 -11
- package/src/guardian/selector-fallbacks.js +394 -0
- package/src/guardian/semantic-contact-detection.js +255 -0
- package/src/guardian/semantic-contact-finder.js +201 -0
- package/src/guardian/semantic-targets.js +234 -0
- package/src/guardian/site-introspection.js +257 -0
- package/src/guardian/smoke.js +258 -0
- package/src/guardian/snapshot-schema.js +25 -1
- package/src/guardian/snapshot.js +69 -3
- package/src/guardian/stability-scorer.js +169 -0
- package/src/guardian/success-evaluator.js +214 -0
- package/src/guardian/template-command.js +184 -0
- package/src/guardian/text-formatters.js +426 -0
- package/src/guardian/timeout-profiles.js +57 -0
- package/src/guardian/verdict.js +320 -0
- package/src/guardian/verdicts.js +74 -0
- package/src/guardian/wait-for-outcome.js +120 -0
- package/src/guardian/watch-runner.js +181 -0
- package/src/payments/stripe-checkout.js +169 -0
- package/src/plans/plan-definitions.js +148 -0
- package/src/plans/plan-manager.js +211 -0
- package/src/plans/usage-tracker.js +210 -0
- package/src/recipes/recipe-engine.js +188 -0
- package/src/recipes/recipe-failure-analysis.js +159 -0
- package/src/recipes/recipe-registry.js +134 -0
- package/src/recipes/recipe-runtime.js +507 -0
- package/src/recipes/recipe-store.js +410 -0
- package/guardian-contract-v1.md +0 -149
- /package/{guardian.config.json → config/guardian.config.json} +0 -0
- /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
- /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
- /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
- /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
- /package/{guardian.profile.saas.yaml → config/profiles/saas.yaml} +0 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase B: Recipe Runtime Enforcement
|
|
3
|
+
*
|
|
4
|
+
* Executes recipes as strict, enforced runtime journeys.
|
|
5
|
+
* Each step maps to a browser action; deviations trigger deterministic failure.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { GuardianBrowser } = require('../guardian/browser');
|
|
9
|
+
const { getRecipe } = require('./recipe-store');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Execute a recipe as an enforced runtime journey
|
|
13
|
+
*
|
|
14
|
+
* @param {string} recipeId - ID of recipe to execute
|
|
15
|
+
* @param {string} baseUrl - Target URL
|
|
16
|
+
* @param {Object} options - Execution options
|
|
17
|
+
* @returns {Promise<Object>} Structured result: { success, steps, failedStep, failureReason, evidence }
|
|
18
|
+
*/
|
|
19
|
+
async function executeRecipeRuntime(recipeId, baseUrl, options = {}) {
|
|
20
|
+
const recipe = getRecipe(recipeId);
|
|
21
|
+
if (!recipe) {
|
|
22
|
+
return {
|
|
23
|
+
success: false,
|
|
24
|
+
recipe: recipeId,
|
|
25
|
+
baseUrl,
|
|
26
|
+
steps: [],
|
|
27
|
+
failedStep: null,
|
|
28
|
+
failureReason: `Recipe not found: ${recipeId}`,
|
|
29
|
+
evidence: [],
|
|
30
|
+
startedAt: new Date().toISOString(),
|
|
31
|
+
endedAt: new Date().toISOString(),
|
|
32
|
+
duration: 0
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const timeout = options.timeout || 20000;
|
|
37
|
+
const headless = options.headless !== false;
|
|
38
|
+
const screenshotDir = options.screenshotDir;
|
|
39
|
+
|
|
40
|
+
const browser = new GuardianBrowser();
|
|
41
|
+
const executionSteps = [];
|
|
42
|
+
let failedStepId = null;
|
|
43
|
+
let failureReason = null;
|
|
44
|
+
const startedAt = new Date();
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
// Launch browser
|
|
48
|
+
await browser.launch(timeout, { headless });
|
|
49
|
+
|
|
50
|
+
// Execute each recipe step in strict order
|
|
51
|
+
let stepIndex = 0;
|
|
52
|
+
for (const stepDef of recipe.steps) {
|
|
53
|
+
const stepId = `${recipe.id}-step-${stepIndex}`;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
// Parse step definition (string format for now)
|
|
57
|
+
const actionObj = parseRecipeStep(stepDef, stepIndex);
|
|
58
|
+
|
|
59
|
+
// Execute action
|
|
60
|
+
const result = await executeRecipeAction(browser, actionObj, baseUrl, timeout);
|
|
61
|
+
|
|
62
|
+
executionSteps.push({
|
|
63
|
+
id: stepId,
|
|
64
|
+
index: stepIndex,
|
|
65
|
+
text: stepDef,
|
|
66
|
+
action: actionObj.action,
|
|
67
|
+
success: result.success,
|
|
68
|
+
duration: result.duration || 0,
|
|
69
|
+
evidence: result.evidence || {}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!result.success) {
|
|
73
|
+
failedStepId = stepId;
|
|
74
|
+
failureReason = result.error || `Step failed: ${stepDef}`;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
executionSteps.push({
|
|
79
|
+
id: stepId,
|
|
80
|
+
index: stepIndex,
|
|
81
|
+
text: stepDef,
|
|
82
|
+
success: false,
|
|
83
|
+
error: err.message
|
|
84
|
+
});
|
|
85
|
+
failedStepId = stepId;
|
|
86
|
+
failureReason = `Step execution error: ${err.message}`;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
stepIndex++;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Evaluate goal if all steps succeeded
|
|
94
|
+
let goalReached = false;
|
|
95
|
+
if (!failedStepId) {
|
|
96
|
+
try {
|
|
97
|
+
goalReached = await evaluateRecipeGoal(browser, recipe, baseUrl);
|
|
98
|
+
if (!goalReached) {
|
|
99
|
+
failureReason = `Goal not reached: ${recipe.expectedGoal}`;
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
failureReason = `Goal evaluation error: ${err.message}`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const endedAt = new Date();
|
|
107
|
+
const duration = Math.round((endedAt - startedAt) / 1000);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
success: !failedStepId && goalReached,
|
|
111
|
+
recipe: recipe.id,
|
|
112
|
+
recipeName: recipe.name,
|
|
113
|
+
baseUrl,
|
|
114
|
+
steps: executionSteps,
|
|
115
|
+
failedStep: failedStepId,
|
|
116
|
+
failureReason,
|
|
117
|
+
goalReached,
|
|
118
|
+
evidence: {
|
|
119
|
+
stepCount: executionSteps.length,
|
|
120
|
+
successCount: executionSteps.filter(s => s.success).length
|
|
121
|
+
},
|
|
122
|
+
startedAt: startedAt.toISOString(),
|
|
123
|
+
endedAt: endedAt.toISOString(),
|
|
124
|
+
duration
|
|
125
|
+
};
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const endedAt = new Date();
|
|
128
|
+
return {
|
|
129
|
+
success: false,
|
|
130
|
+
recipe: recipe.id,
|
|
131
|
+
baseUrl,
|
|
132
|
+
steps: executionSteps,
|
|
133
|
+
failedStep: null,
|
|
134
|
+
failureReason: `Runtime error: ${err.message}`,
|
|
135
|
+
evidence: {},
|
|
136
|
+
startedAt: startedAt.toISOString(),
|
|
137
|
+
endedAt: endedAt.toISOString(),
|
|
138
|
+
duration: Math.round((endedAt - startedAt) / 1000)
|
|
139
|
+
};
|
|
140
|
+
} finally {
|
|
141
|
+
await browser.close();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parse a recipe step string into an action object
|
|
147
|
+
*
|
|
148
|
+
* Step format:
|
|
149
|
+
* "Navigate to product page"
|
|
150
|
+
* "Navigate to the homepage"
|
|
151
|
+
* "Click on 'Add to Cart' button"
|
|
152
|
+
* "Click on 'Contact' button"
|
|
153
|
+
* "Fill email with 'test@example.com'"
|
|
154
|
+
* "Assert visible 'Success message'"
|
|
155
|
+
*/
|
|
156
|
+
function parseRecipeStep(stepText, index) {
|
|
157
|
+
const text = stepText.toLowerCase().trim();
|
|
158
|
+
|
|
159
|
+
// Navigate: "Navigate to X" or "Go to X"
|
|
160
|
+
if (text.startsWith('navigate') || text.startsWith('go to ')) {
|
|
161
|
+
// Try to extract something meaningful
|
|
162
|
+
let target = 'homepage';
|
|
163
|
+
if (text.includes('homepage') || text.includes('home')) {
|
|
164
|
+
target = 'homepage';
|
|
165
|
+
} else if (text.includes('product')) {
|
|
166
|
+
target = 'product';
|
|
167
|
+
} else {
|
|
168
|
+
// Get last noun-like word
|
|
169
|
+
const quoted = stepText.match(/'([^']+)'/) || stepText.match(/"([^"]+)"/);
|
|
170
|
+
if (quoted) {
|
|
171
|
+
target = quoted[1];
|
|
172
|
+
} else {
|
|
173
|
+
const words = stepText.split(/\s+/).slice(2); // Skip "Navigate to"
|
|
174
|
+
target = words.join('-').toLowerCase();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
action: 'navigate',
|
|
179
|
+
target,
|
|
180
|
+
index
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Click: "Click on X" or "Click 'Button text'"
|
|
185
|
+
if (text.includes('click')) {
|
|
186
|
+
let selector = 'button';
|
|
187
|
+
|
|
188
|
+
// Try to find quoted button text
|
|
189
|
+
const quoted = stepText.match(/'([^']+)'/) || stepText.match(/"([^"]+)"/);
|
|
190
|
+
if (quoted) {
|
|
191
|
+
selector = quoted[1];
|
|
192
|
+
} else {
|
|
193
|
+
// Extract button description from text
|
|
194
|
+
if (text.includes('contact')) {
|
|
195
|
+
selector = 'Contact';
|
|
196
|
+
} else if (text.includes('submit')) {
|
|
197
|
+
selector = 'Submit';
|
|
198
|
+
} else if (text.includes('add')) {
|
|
199
|
+
selector = 'Add';
|
|
200
|
+
} else {
|
|
201
|
+
// Last word after "click"
|
|
202
|
+
const match = stepText.match(/click(?:\s+on)?\s+(.+)/i);
|
|
203
|
+
selector = match ? match[1].replace('button', '').trim() : 'button';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
action: 'click',
|
|
209
|
+
selector,
|
|
210
|
+
index
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Fill: "Fill X with 'value'" or "Enter email"
|
|
215
|
+
if (text.startsWith('fill ') || text.startsWith('enter ')) {
|
|
216
|
+
let field = 'email';
|
|
217
|
+
let value = 'test-data';
|
|
218
|
+
|
|
219
|
+
const parts = stepText.split(' with ');
|
|
220
|
+
|
|
221
|
+
// Extract field name
|
|
222
|
+
if (parts[0].includes('email')) {
|
|
223
|
+
field = 'email';
|
|
224
|
+
} else if (parts[0].includes('name')) {
|
|
225
|
+
field = 'name';
|
|
226
|
+
} else if (parts[0].includes('password')) {
|
|
227
|
+
field = 'password';
|
|
228
|
+
} else {
|
|
229
|
+
const quoted = parts[0].match(/'([^']+)'/) || parts[0].match(/"([^"]+)"/);
|
|
230
|
+
field = quoted ? quoted[1] : 'email';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Extract value
|
|
234
|
+
if (parts[1]) {
|
|
235
|
+
const quoted = parts[1].match(/'([^']+)'/) || parts[1].match(/"([^"]+)"/);
|
|
236
|
+
value = quoted ? quoted[1] : parts[1].trim();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
action: 'fill',
|
|
241
|
+
field,
|
|
242
|
+
value,
|
|
243
|
+
index
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Submit: "Submit form" or "Submit"
|
|
248
|
+
if (text.includes('submit')) {
|
|
249
|
+
return {
|
|
250
|
+
action: 'submit',
|
|
251
|
+
selector: 'button[type="submit"], input[type="submit"]',
|
|
252
|
+
index
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Wait: "Wait for X" or "Wait until X"
|
|
257
|
+
if (text.includes('wait')) {
|
|
258
|
+
const quoted = stepText.match(/'([^']+)'/) || stepText.match(/"([^"]+)"/);
|
|
259
|
+
const target = quoted ? quoted[1] : 'element';
|
|
260
|
+
return {
|
|
261
|
+
action: 'waitFor',
|
|
262
|
+
selector: target,
|
|
263
|
+
index
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Assert: "Assert visible X" or "Verify X"
|
|
268
|
+
if (text.includes('assert') || text.includes('verify')) {
|
|
269
|
+
const quoted = stepText.match(/'([^']+)'/) || stepText.match(/"([^"]+)"/);
|
|
270
|
+
const target = quoted ? quoted[1] : 'element';
|
|
271
|
+
return {
|
|
272
|
+
action: 'assertVisible',
|
|
273
|
+
selector: target,
|
|
274
|
+
index
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Default: treat as navigation
|
|
279
|
+
return {
|
|
280
|
+
action: 'navigate',
|
|
281
|
+
target: stepText,
|
|
282
|
+
index
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Extract quoted text or last word from a string
|
|
288
|
+
*/
|
|
289
|
+
function extractQuotedOrLastWord(text) {
|
|
290
|
+
const quoted = text.match(/'([^']+)'/) || text.match(/"([^"]+)"/);
|
|
291
|
+
if (quoted) return quoted[1];
|
|
292
|
+
|
|
293
|
+
const words = text.trim().split(/\s+/);
|
|
294
|
+
return words[words.length - 1];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Execute a single recipe action against the browser
|
|
299
|
+
*/
|
|
300
|
+
async function executeRecipeAction(browser, action, baseUrl, timeout) {
|
|
301
|
+
const startTime = Date.now();
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
if (action.action === 'navigate') {
|
|
305
|
+
const url = action.target.startsWith('http')
|
|
306
|
+
? action.target
|
|
307
|
+
: new URL(action.target, baseUrl).href;
|
|
308
|
+
|
|
309
|
+
const response = await browser.navigate(url, timeout);
|
|
310
|
+
if (!response.success) {
|
|
311
|
+
return {
|
|
312
|
+
success: false,
|
|
313
|
+
error: response.error || `Navigation failed to ${url}`,
|
|
314
|
+
duration: Date.now() - startTime
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
success: true,
|
|
320
|
+
duration: Date.now() - startTime,
|
|
321
|
+
evidence: { url: response.url, status: response.status }
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (action.action === 'click') {
|
|
326
|
+
const selector = action.selector.toLowerCase().trim();
|
|
327
|
+
let targetElement = null;
|
|
328
|
+
|
|
329
|
+
// Find all clickable elements
|
|
330
|
+
try {
|
|
331
|
+
const allElements = await browser.page.$$('button, a, [role="button"], input[type="button"]');
|
|
332
|
+
|
|
333
|
+
for (const el of allElements) {
|
|
334
|
+
try {
|
|
335
|
+
const text = await browser.page.evaluate(elem => (elem.textContent || elem.value || '').toLowerCase().trim(), el);
|
|
336
|
+
const isVisible = await browser.page.evaluate(elem => elem.offsetParent !== null, el);
|
|
337
|
+
|
|
338
|
+
if (isVisible && (text === selector || text.includes(selector) || selector.includes(text))) {
|
|
339
|
+
targetElement = el;
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
} catch (e) {
|
|
343
|
+
// Skip this element
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} catch (e) {
|
|
347
|
+
// Fallback: try as selector
|
|
348
|
+
targetElement = await browser.page.$(selector).catch(() => null);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!targetElement) {
|
|
352
|
+
return {
|
|
353
|
+
success: false,
|
|
354
|
+
error: `Element not found: ${selector}`,
|
|
355
|
+
duration: Date.now() - startTime
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Perform click
|
|
360
|
+
const initialUrl = browser.page.url();
|
|
361
|
+
try {
|
|
362
|
+
await browser.page.evaluate(el => el.click(), targetElement);
|
|
363
|
+
} catch (e) {
|
|
364
|
+
return {
|
|
365
|
+
success: false,
|
|
366
|
+
error: `Click failed: ${e.message}`,
|
|
367
|
+
duration: Date.now() - startTime
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Wait for navigation or content change
|
|
372
|
+
await new Promise(r => setTimeout(r, 500));
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
success: true,
|
|
376
|
+
duration: Date.now() - startTime,
|
|
377
|
+
evidence: { clicked: selector, urlChanged: initialUrl !== browser.page.url() }
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (action.action === 'fill') {
|
|
382
|
+
const field = action.field;
|
|
383
|
+
const value = action.value;
|
|
384
|
+
const locator = browser.page.locator(`input[name="${field}"], textarea[name="${field}"]`);
|
|
385
|
+
|
|
386
|
+
const count = await locator.count();
|
|
387
|
+
if (count === 0) {
|
|
388
|
+
return {
|
|
389
|
+
success: false,
|
|
390
|
+
error: `Field not found: ${field}`,
|
|
391
|
+
duration: Date.now() - startTime
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
await locator.first().fill(value, { timeout });
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
success: true,
|
|
399
|
+
duration: Date.now() - startTime,
|
|
400
|
+
evidence: { field, valueFilled: true }
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (action.action === 'submit') {
|
|
405
|
+
const locator = browser.page.locator(action.selector);
|
|
406
|
+
const count = await locator.count();
|
|
407
|
+
|
|
408
|
+
if (count === 0) {
|
|
409
|
+
return {
|
|
410
|
+
success: false,
|
|
411
|
+
error: `Submit button not found: ${action.selector}`,
|
|
412
|
+
duration: Date.now() - startTime
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
await locator.first().click({ timeout });
|
|
417
|
+
|
|
418
|
+
// Wait for response
|
|
419
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
success: true,
|
|
423
|
+
duration: Date.now() - startTime,
|
|
424
|
+
evidence: { submitted: true }
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (action.action === 'waitFor') {
|
|
429
|
+
const locator = browser.page.locator(action.selector);
|
|
430
|
+
await locator.waitFor({ timeout, state: 'visible' });
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
success: true,
|
|
434
|
+
duration: Date.now() - startTime,
|
|
435
|
+
evidence: { elementVisible: action.selector }
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (action.action === 'assertVisible') {
|
|
440
|
+
const locator = browser.page.locator(action.selector);
|
|
441
|
+
const isVisible = await locator.isVisible();
|
|
442
|
+
|
|
443
|
+
if (!isVisible) {
|
|
444
|
+
return {
|
|
445
|
+
success: false,
|
|
446
|
+
error: `Assertion failed: element not visible: ${action.selector}`,
|
|
447
|
+
duration: Date.now() - startTime
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
success: true,
|
|
453
|
+
duration: Date.now() - startTime,
|
|
454
|
+
evidence: { assertion: action.selector, visible: true }
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
success: false,
|
|
460
|
+
error: `Unknown action: ${action.action}`,
|
|
461
|
+
duration: Date.now() - startTime
|
|
462
|
+
};
|
|
463
|
+
} catch (err) {
|
|
464
|
+
return {
|
|
465
|
+
success: false,
|
|
466
|
+
error: `Action error: ${err.message}`,
|
|
467
|
+
duration: Date.now() - startTime
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Evaluate if recipe goal was reached
|
|
474
|
+
*/
|
|
475
|
+
async function evaluateRecipeGoal(browser, recipe, baseUrl) {
|
|
476
|
+
const url = browser.page.url();
|
|
477
|
+
const title = await browser.page.title();
|
|
478
|
+
const bodyText = await browser.page.textContent('body');
|
|
479
|
+
|
|
480
|
+
// Simple heuristics for goal validation
|
|
481
|
+
const goal = recipe.expectedGoal.toLowerCase();
|
|
482
|
+
const pageContent = `${url} ${title} ${bodyText}`.toLowerCase();
|
|
483
|
+
|
|
484
|
+
// Check for goal keywords in page
|
|
485
|
+
const keywordMatches = [
|
|
486
|
+
'success', 'confirm', 'thank', 'order', 'dashboard',
|
|
487
|
+
'welcome', 'complete', 'verified', 'registration',
|
|
488
|
+
'payment', 'checkout'
|
|
489
|
+
];
|
|
490
|
+
|
|
491
|
+
for (const keyword of keywordMatches) {
|
|
492
|
+
if (goal.includes(keyword) && pageContent.includes(keyword)) {
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Check for goal phrases directly in page
|
|
498
|
+
return pageContent.includes(goal);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
module.exports = {
|
|
502
|
+
executeRecipeRuntime,
|
|
503
|
+
parseRecipeStep,
|
|
504
|
+
extractQuotedOrLastWord,
|
|
505
|
+
executeRecipeAction,
|
|
506
|
+
evaluateRecipeGoal
|
|
507
|
+
};
|