@odavl/guardian 0.1.0-rc1
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 +20 -0
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/bin/guardian.js +690 -0
- package/flows/example-login-flow.json +36 -0
- package/flows/example-signup-flow.json +44 -0
- package/guardian-contract-v1.md +149 -0
- package/guardian.config.json +54 -0
- package/guardian.policy.json +12 -0
- package/guardian.profile.docs.yaml +18 -0
- package/guardian.profile.ecommerce.yaml +17 -0
- package/guardian.profile.marketing.yaml +18 -0
- package/guardian.profile.saas.yaml +21 -0
- package/package.json +69 -0
- package/policies/enterprise.json +12 -0
- package/policies/saas.json +12 -0
- package/policies/startup.json +12 -0
- package/src/guardian/attempt-engine.js +454 -0
- package/src/guardian/attempt-registry.js +227 -0
- package/src/guardian/attempt-reporter.js +507 -0
- package/src/guardian/attempt.js +227 -0
- package/src/guardian/auto-attempt-builder.js +283 -0
- package/src/guardian/baseline-reporter.js +143 -0
- package/src/guardian/baseline-storage.js +285 -0
- package/src/guardian/baseline.js +492 -0
- package/src/guardian/behavioral-signals.js +261 -0
- package/src/guardian/breakage-intelligence.js +223 -0
- package/src/guardian/browser.js +92 -0
- package/src/guardian/cli-summary.js +141 -0
- package/src/guardian/crawler.js +142 -0
- package/src/guardian/discovery-engine.js +661 -0
- package/src/guardian/enhanced-html-reporter.js +305 -0
- package/src/guardian/failure-taxonomy.js +169 -0
- package/src/guardian/flow-executor.js +374 -0
- package/src/guardian/flow-registry.js +67 -0
- package/src/guardian/html-reporter.js +414 -0
- package/src/guardian/index.js +218 -0
- package/src/guardian/init-command.js +139 -0
- package/src/guardian/junit-reporter.js +264 -0
- package/src/guardian/market-criticality.js +335 -0
- package/src/guardian/market-reporter.js +305 -0
- package/src/guardian/network-trace.js +178 -0
- package/src/guardian/policy.js +357 -0
- package/src/guardian/preset-loader.js +148 -0
- package/src/guardian/reality.js +547 -0
- package/src/guardian/reporter.js +181 -0
- package/src/guardian/root-cause-analysis.js +171 -0
- package/src/guardian/safety.js +248 -0
- package/src/guardian/scan-presets.js +60 -0
- package/src/guardian/screenshot.js +152 -0
- package/src/guardian/sitemap.js +225 -0
- package/src/guardian/snapshot-schema.js +266 -0
- package/src/guardian/snapshot.js +327 -0
- package/src/guardian/validators.js +323 -0
- package/src/guardian/visual-diff.js +247 -0
- package/src/guardian/webhook.js +206 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guardian Attempt Engine - PHASE 1 + PHASE 2
|
|
3
|
+
* Executes a single user attempt and tracks outcome (SUCCESS, FAILURE, FRICTION)
|
|
4
|
+
* Phase 2: Soft failure detection via validators
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { getAttemptDefinition } = require('./attempt-registry');
|
|
10
|
+
const { runValidators, analyzeSoftFailures } = require('./validators');
|
|
11
|
+
|
|
12
|
+
class AttemptEngine {
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.attemptId = options.attemptId || 'default';
|
|
15
|
+
this.timeout = options.timeout || 30000;
|
|
16
|
+
this.frictionThresholds = options.frictionThresholds || {
|
|
17
|
+
totalDurationMs: 2500, // Total attempt > 2.5s
|
|
18
|
+
stepDurationMs: 1500, // Any single step > 1.5s
|
|
19
|
+
retryCount: 1 // More than 1 retry = friction
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load attempt definition by ID (Phase 3 registry)
|
|
25
|
+
*/
|
|
26
|
+
loadAttemptDefinition(attemptId) {
|
|
27
|
+
return getAttemptDefinition(attemptId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Execute a single attempt
|
|
32
|
+
* Returns: { outcome, steps, timings, friction, error, validators, softFailures }
|
|
33
|
+
*/
|
|
34
|
+
async executeAttempt(page, attemptId, baseUrl, artifactsDir = null, validatorSpecs = null) {
|
|
35
|
+
const attemptDef = this.loadAttemptDefinition(attemptId);
|
|
36
|
+
if (!attemptDef) {
|
|
37
|
+
throw new Error(`Attempt ${attemptId} not found`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const startedAt = new Date();
|
|
41
|
+
const steps = [];
|
|
42
|
+
const frictionSignals = [];
|
|
43
|
+
const consoleMessages = []; // Capture console messages for validators
|
|
44
|
+
let currentStep = null;
|
|
45
|
+
let lastError = null;
|
|
46
|
+
let frictionReasons = [];
|
|
47
|
+
let frictionMetrics = {};
|
|
48
|
+
|
|
49
|
+
// Capture console messages for soft failure detection
|
|
50
|
+
const consoleHandler = (msg) => {
|
|
51
|
+
consoleMessages.push({
|
|
52
|
+
type: msg.type(), // 'log', 'error', 'warning', etc.
|
|
53
|
+
text: msg.text(),
|
|
54
|
+
location: msg.location()
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
page.on('console', consoleHandler);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// Replace $BASEURL placeholder in all steps
|
|
62
|
+
const processedSteps = attemptDef.baseSteps.map(step => {
|
|
63
|
+
if (step.target && step.target === '$BASEURL') {
|
|
64
|
+
return { ...step, target: baseUrl };
|
|
65
|
+
}
|
|
66
|
+
return step;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Execute each step
|
|
70
|
+
for (const stepDef of processedSteps) {
|
|
71
|
+
currentStep = {
|
|
72
|
+
id: stepDef.id,
|
|
73
|
+
type: stepDef.type,
|
|
74
|
+
target: stepDef.target,
|
|
75
|
+
description: stepDef.description,
|
|
76
|
+
startedAt: new Date().toISOString(),
|
|
77
|
+
retries: 0,
|
|
78
|
+
status: 'pending',
|
|
79
|
+
error: null,
|
|
80
|
+
screenshots: []
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const stepStartTime = Date.now();
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
// Execute with retry logic (up to 2 attempts)
|
|
87
|
+
let success = false;
|
|
88
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
89
|
+
try {
|
|
90
|
+
if (attempt > 0) {
|
|
91
|
+
currentStep.retries++;
|
|
92
|
+
// Small backoff before retry
|
|
93
|
+
await page.waitForTimeout(200);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await this._executeStep(page, stepDef);
|
|
97
|
+
success = true;
|
|
98
|
+
break;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
if (attempt === 1) {
|
|
101
|
+
throw err; // Final attempt failed
|
|
102
|
+
}
|
|
103
|
+
// Retry on first failure
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const stepEndTime = Date.now();
|
|
108
|
+
const stepDurationMs = stepEndTime - stepStartTime;
|
|
109
|
+
|
|
110
|
+
currentStep.endedAt = new Date().toISOString();
|
|
111
|
+
currentStep.durationMs = stepDurationMs;
|
|
112
|
+
currentStep.status = 'success';
|
|
113
|
+
|
|
114
|
+
// Check for friction signals in step timing
|
|
115
|
+
if (stepDurationMs > this.frictionThresholds.stepDurationMs) {
|
|
116
|
+
frictionSignals.push({
|
|
117
|
+
id: 'slow_step_execution',
|
|
118
|
+
description: `Step took longer than threshold`,
|
|
119
|
+
metric: 'stepDurationMs',
|
|
120
|
+
threshold: this.frictionThresholds.stepDurationMs,
|
|
121
|
+
observedValue: stepDurationMs,
|
|
122
|
+
affectedStepId: stepDef.id,
|
|
123
|
+
severity: 'medium'
|
|
124
|
+
});
|
|
125
|
+
frictionReasons.push(`Step "${stepDef.id}" took ${stepDurationMs}ms (threshold: ${this.frictionThresholds.stepDurationMs}ms)`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (currentStep.retries > this.frictionThresholds.retryCount) {
|
|
129
|
+
frictionSignals.push({
|
|
130
|
+
id: 'multiple_retries_required',
|
|
131
|
+
description: `Step required multiple retry attempts`,
|
|
132
|
+
metric: 'retryCount',
|
|
133
|
+
threshold: this.frictionThresholds.retryCount,
|
|
134
|
+
observedValue: currentStep.retries,
|
|
135
|
+
affectedStepId: stepDef.id,
|
|
136
|
+
severity: 'high'
|
|
137
|
+
});
|
|
138
|
+
frictionReasons.push(`Step "${stepDef.id}" required ${currentStep.retries} retries`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Capture screenshot on success if artifacts dir provided
|
|
142
|
+
if (artifactsDir) {
|
|
143
|
+
const screenshotPath = await this._captureScreenshot(
|
|
144
|
+
page,
|
|
145
|
+
artifactsDir,
|
|
146
|
+
stepDef.id
|
|
147
|
+
);
|
|
148
|
+
if (screenshotPath) {
|
|
149
|
+
currentStep.screenshots.push(screenshotPath);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
} catch (err) {
|
|
154
|
+
currentStep.endedAt = new Date().toISOString();
|
|
155
|
+
currentStep.durationMs = Date.now() - stepStartTime;
|
|
156
|
+
currentStep.status = stepDef.optional ? 'skipped' : 'failed';
|
|
157
|
+
currentStep.error = err.message;
|
|
158
|
+
|
|
159
|
+
if (stepDef.optional) {
|
|
160
|
+
// Optional steps should not fail the attempt; record and move on
|
|
161
|
+
steps.push(currentStep);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
lastError = err;
|
|
166
|
+
|
|
167
|
+
// Capture screenshot on failure
|
|
168
|
+
if (artifactsDir) {
|
|
169
|
+
const screenshotPath = await this._captureScreenshot(
|
|
170
|
+
page,
|
|
171
|
+
artifactsDir,
|
|
172
|
+
`${stepDef.id}_failure`
|
|
173
|
+
);
|
|
174
|
+
if (screenshotPath) {
|
|
175
|
+
currentStep.screenshots.push(screenshotPath);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
throw err; // Stop attempt on step failure
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
steps.push(currentStep);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// All steps successful, now check success conditions
|
|
186
|
+
const endedAt = new Date();
|
|
187
|
+
const totalDurationMs = endedAt.getTime() - startedAt.getTime();
|
|
188
|
+
|
|
189
|
+
// Check success conditions
|
|
190
|
+
let successMet = false;
|
|
191
|
+
let successReason = null;
|
|
192
|
+
|
|
193
|
+
for (const condition of attemptDef.successConditions) {
|
|
194
|
+
try {
|
|
195
|
+
if (condition.type === 'url') {
|
|
196
|
+
const currentUrl = page.url();
|
|
197
|
+
if (condition.pattern.test(currentUrl)) {
|
|
198
|
+
successMet = true;
|
|
199
|
+
successReason = `URL matched: ${currentUrl}`;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
} else if (condition.type === 'selector') {
|
|
203
|
+
// Wait briefly for selector to become visible
|
|
204
|
+
try {
|
|
205
|
+
await page.waitForSelector(condition.target, { timeout: 3000, state: 'visible' });
|
|
206
|
+
successMet = true;
|
|
207
|
+
successReason = `Success element visible: ${condition.target}`;
|
|
208
|
+
break;
|
|
209
|
+
} catch (e) {
|
|
210
|
+
// Continue to next condition
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch (err) {
|
|
214
|
+
// Continue to next condition
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!successMet) {
|
|
219
|
+
page.removeListener('console', consoleHandler);
|
|
220
|
+
return {
|
|
221
|
+
outcome: 'FAILURE',
|
|
222
|
+
steps,
|
|
223
|
+
startedAt: startedAt.toISOString(),
|
|
224
|
+
endedAt: endedAt.toISOString(),
|
|
225
|
+
totalDurationMs,
|
|
226
|
+
friction: {
|
|
227
|
+
isFriction: false,
|
|
228
|
+
signals: [],
|
|
229
|
+
summary: null,
|
|
230
|
+
reasons: [],
|
|
231
|
+
thresholds: this.frictionThresholds,
|
|
232
|
+
metrics: {}
|
|
233
|
+
},
|
|
234
|
+
error: 'Success conditions not met after all steps completed',
|
|
235
|
+
successReason: null,
|
|
236
|
+
validators: [],
|
|
237
|
+
softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 }
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Run validators for soft failure detection (Phase 2)
|
|
242
|
+
let validatorResults = [];
|
|
243
|
+
let softFailureAnalysis = { hasSoftFailure: false, failureCount: 0, warnCount: 0 };
|
|
244
|
+
|
|
245
|
+
if (validatorSpecs && validatorSpecs.length > 0) {
|
|
246
|
+
const validatorContext = {
|
|
247
|
+
page,
|
|
248
|
+
consoleMessages,
|
|
249
|
+
url: page.url()
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
validatorResults = await runValidators(validatorSpecs, validatorContext);
|
|
253
|
+
softFailureAnalysis = analyzeSoftFailures(validatorResults);
|
|
254
|
+
|
|
255
|
+
// If validators detected soft failures, upgrade outcome
|
|
256
|
+
if (softFailureAnalysis.hasSoftFailure) {
|
|
257
|
+
// Soft failure still counts as FAILURE (outcome), not FRICTION
|
|
258
|
+
// Soft failures are recorded separately for analysis
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Check for friction signals in total duration
|
|
263
|
+
if (totalDurationMs > this.frictionThresholds.totalDurationMs) {
|
|
264
|
+
frictionSignals.push({
|
|
265
|
+
id: 'slow_total_duration',
|
|
266
|
+
description: `Total attempt duration exceeded threshold`,
|
|
267
|
+
metric: 'totalDurationMs',
|
|
268
|
+
threshold: this.frictionThresholds.totalDurationMs,
|
|
269
|
+
observedValue: totalDurationMs,
|
|
270
|
+
affectedStepId: null,
|
|
271
|
+
severity: 'low'
|
|
272
|
+
});
|
|
273
|
+
frictionReasons.push(`Attempt took ${totalDurationMs}ms total (threshold: ${this.frictionThresholds.totalDurationMs}ms)`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
frictionMetrics = {
|
|
277
|
+
totalDurationMs,
|
|
278
|
+
stepCount: steps.length,
|
|
279
|
+
totalRetries: steps.reduce((sum, s) => sum + s.retries, 0),
|
|
280
|
+
maxStepDurationMs: Math.max(...steps.map(s => s.durationMs || 0))
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// Determine outcome based on friction signals
|
|
284
|
+
const isFriction = frictionSignals.length > 0;
|
|
285
|
+
const outcome = isFriction ? 'FRICTION' : 'SUCCESS';
|
|
286
|
+
|
|
287
|
+
// Generate friction summary
|
|
288
|
+
const frictionSummary = isFriction
|
|
289
|
+
? `User succeeded, but encountered ${frictionSignals.length} friction ${frictionSignals.length === 1 ? 'signal' : 'signals'}`
|
|
290
|
+
: null;
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
outcome,
|
|
294
|
+
steps,
|
|
295
|
+
startedAt: startedAt.toISOString(),
|
|
296
|
+
endedAt: endedAt.toISOString(),
|
|
297
|
+
totalDurationMs,
|
|
298
|
+
friction: {
|
|
299
|
+
isFriction,
|
|
300
|
+
signals: frictionSignals,
|
|
301
|
+
summary: frictionSummary,
|
|
302
|
+
reasons: frictionReasons, // Keep for backward compatibility
|
|
303
|
+
thresholds: this.frictionThresholds,
|
|
304
|
+
metrics: frictionMetrics
|
|
305
|
+
},
|
|
306
|
+
error: null,
|
|
307
|
+
successReason,
|
|
308
|
+
validators: validatorResults,
|
|
309
|
+
softFailures: softFailureAnalysis
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
} catch (err) {
|
|
313
|
+
const endedAt = new Date();
|
|
314
|
+
page.removeListener('console', consoleHandler);
|
|
315
|
+
return {
|
|
316
|
+
outcome: 'FAILURE',
|
|
317
|
+
steps,
|
|
318
|
+
startedAt: startedAt.toISOString(),
|
|
319
|
+
endedAt: endedAt.toISOString(),
|
|
320
|
+
totalDurationMs: endedAt.getTime() - startedAt.getTime(),
|
|
321
|
+
friction: {
|
|
322
|
+
isFriction: false,
|
|
323
|
+
reasons: [],
|
|
324
|
+
thresholds: this.frictionThresholds,
|
|
325
|
+
metrics: {}
|
|
326
|
+
},
|
|
327
|
+
error: `Step "${currentStep?.id}" failed: ${err.message}`,
|
|
328
|
+
successReason: null,
|
|
329
|
+
validators: [],
|
|
330
|
+
softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 }
|
|
331
|
+
};
|
|
332
|
+
} finally {
|
|
333
|
+
page.removeListener('console', consoleHandler);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Execute a single step
|
|
339
|
+
*/
|
|
340
|
+
async _executeStep(page, stepDef) {
|
|
341
|
+
const timeout = stepDef.timeout || this.timeout;
|
|
342
|
+
|
|
343
|
+
switch (stepDef.type) {
|
|
344
|
+
case 'navigate':
|
|
345
|
+
await page.goto(stepDef.target, {
|
|
346
|
+
waitUntil: 'domcontentloaded',
|
|
347
|
+
timeout
|
|
348
|
+
});
|
|
349
|
+
break;
|
|
350
|
+
|
|
351
|
+
case 'click':
|
|
352
|
+
// Try each selector in the target (semicolon-separated)
|
|
353
|
+
const selectors = stepDef.target.split(',').map(s => s.trim());
|
|
354
|
+
let clicked = false;
|
|
355
|
+
|
|
356
|
+
for (const selector of selectors) {
|
|
357
|
+
try {
|
|
358
|
+
await page.click(selector, { timeout: 5000 });
|
|
359
|
+
clicked = true;
|
|
360
|
+
break;
|
|
361
|
+
} catch (err) {
|
|
362
|
+
// Try next selector
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!clicked) {
|
|
367
|
+
throw new Error(`Could not click element: ${stepDef.target}`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Wait for navigation if expected
|
|
371
|
+
if (stepDef.waitForNavigation) {
|
|
372
|
+
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
373
|
+
}
|
|
374
|
+
break;
|
|
375
|
+
|
|
376
|
+
case 'type':
|
|
377
|
+
// Try each selector
|
|
378
|
+
const typeSelectors = stepDef.target.split(',').map(s => s.trim());
|
|
379
|
+
let typed = false;
|
|
380
|
+
|
|
381
|
+
for (const selector of typeSelectors) {
|
|
382
|
+
try {
|
|
383
|
+
await page.fill(selector, stepDef.value, { timeout: 5000 });
|
|
384
|
+
typed = true;
|
|
385
|
+
break;
|
|
386
|
+
} catch (err) {
|
|
387
|
+
// Try next selector
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!typed) {
|
|
392
|
+
throw new Error(`Could not type into element: ${stepDef.target}`);
|
|
393
|
+
}
|
|
394
|
+
break;
|
|
395
|
+
|
|
396
|
+
case 'waitFor':
|
|
397
|
+
const waitSelectors = stepDef.target.split(',').map(s => s.trim());
|
|
398
|
+
let found = false;
|
|
399
|
+
|
|
400
|
+
for (const selector of waitSelectors) {
|
|
401
|
+
try {
|
|
402
|
+
await page.waitForSelector(selector, {
|
|
403
|
+
timeout: stepDef.timeout || 5000,
|
|
404
|
+
state: stepDef.state || 'visible'
|
|
405
|
+
});
|
|
406
|
+
found = true;
|
|
407
|
+
break;
|
|
408
|
+
} catch (err) {
|
|
409
|
+
// Try next selector
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!found) {
|
|
414
|
+
throw new Error(`Element not found: ${stepDef.target}`);
|
|
415
|
+
}
|
|
416
|
+
break;
|
|
417
|
+
|
|
418
|
+
case 'wait':
|
|
419
|
+
await page.waitForTimeout(stepDef.duration || 1000);
|
|
420
|
+
break;
|
|
421
|
+
|
|
422
|
+
default:
|
|
423
|
+
throw new Error(`Unknown step type: ${stepDef.type}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Capture screenshot
|
|
429
|
+
*/
|
|
430
|
+
async _captureScreenshot(page, artifactsDir, stepId) {
|
|
431
|
+
try {
|
|
432
|
+
const screenshotsDir = path.join(artifactsDir, 'attempt-screenshots');
|
|
433
|
+
if (!fs.existsSync(screenshotsDir)) {
|
|
434
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const filename = `${stepId}.jpeg`;
|
|
438
|
+
const fullPath = path.join(screenshotsDir, filename);
|
|
439
|
+
|
|
440
|
+
await page.screenshot({
|
|
441
|
+
path: fullPath,
|
|
442
|
+
type: 'jpeg',
|
|
443
|
+
quality: 80,
|
|
444
|
+
fullPage: true
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
return filename;
|
|
448
|
+
} catch (err) {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
module.exports = { AttemptEngine };
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central registry for attempt definitions
|
|
3
|
+
* Phase 3: supports multiple curated attempts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DEFAULT_ATTEMPTS = ['contact_form', 'language_switch', 'newsletter_signup', 'signup', 'login', 'checkout'];
|
|
7
|
+
|
|
8
|
+
const attemptDefinitions = {
|
|
9
|
+
contact_form: {
|
|
10
|
+
id: 'contact_form',
|
|
11
|
+
name: 'Contact Form Submission',
|
|
12
|
+
goal: 'User submits the contact form successfully',
|
|
13
|
+
riskCategory: 'LEAD',
|
|
14
|
+
baseSteps: [
|
|
15
|
+
{ id: 'navigate_form', type: 'navigate', target: '$BASEURL', description: 'Navigate to contact form' },
|
|
16
|
+
{
|
|
17
|
+
id: 'open_contact_page',
|
|
18
|
+
type: 'click',
|
|
19
|
+
target: 'a[data-guardian="contact-link"], a[data-testid="contact-link"], a:has-text("Contact"), a[href*="/contact"]',
|
|
20
|
+
description: 'Open contact page',
|
|
21
|
+
optional: true,
|
|
22
|
+
waitForNavigation: true
|
|
23
|
+
},
|
|
24
|
+
{ id: 'fill_name', type: 'type', target: 'input[name="name"], input[data-testid="name"], input[data-guardian="name"]', value: 'Test User', description: 'Enter name', timeout: 5000 },
|
|
25
|
+
{ id: 'fill_email', type: 'type', target: 'input[name="email"], input[data-testid="email"], input[type="email"], input[data-guardian="email"]', value: 'test@example.com', description: 'Enter email', timeout: 5000 },
|
|
26
|
+
{ id: 'fill_message', type: 'type', target: 'textarea[name="message"], textarea[data-testid="message"], input[name="message"], textarea[data-guardian="message"]', value: 'This is a test message.', description: 'Enter message', timeout: 5000 },
|
|
27
|
+
{ id: 'submit_form', type: 'click', target: 'button[type="submit"], button:has-text("Submit"), button:has-text("Send"), button[data-guardian="submit"]', description: 'Submit form', timeout: 5000 }
|
|
28
|
+
],
|
|
29
|
+
successConditions: [
|
|
30
|
+
{ type: 'selector', target: '[data-guardian="success"], [data-testid="success"]', description: 'Success message element visible' }
|
|
31
|
+
],
|
|
32
|
+
// Phase 2: Soft failure validators
|
|
33
|
+
validators: [
|
|
34
|
+
// Success indicators
|
|
35
|
+
{ type: 'elementVisible', selector: '[data-guardian="success"], [data-testid="success"], .success-message, .alert-success' },
|
|
36
|
+
{ type: 'pageContainsAnyText', textList: ['success', 'submitted', 'thank you', 'message received'] },
|
|
37
|
+
// Error/failure indicators that would make this a SOFT FAILURE
|
|
38
|
+
{ type: 'elementNotVisible', selector: '.error, [role="alert"], .form-error, .error-message' }
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
language_switch: {
|
|
43
|
+
id: 'language_switch',
|
|
44
|
+
name: 'Language Toggle',
|
|
45
|
+
goal: 'User switches language successfully',
|
|
46
|
+
riskCategory: 'TRUST/UX',
|
|
47
|
+
baseSteps: [
|
|
48
|
+
{ id: 'navigate_home', type: 'navigate', target: '$BASEURL', description: 'Navigate to home page' },
|
|
49
|
+
{ id: 'open_language_page', type: 'click', target: 'a[data-guardian="language-link"], a:has-text("Language Switch")', description: 'Open language switch page', timeout: 5000 },
|
|
50
|
+
{ id: 'open_language_toggle', type: 'click', target: '[data-guardian="lang-toggle"], button:has-text("Language"), button:has-text("Lang")', description: 'Open language toggle', timeout: 5000 },
|
|
51
|
+
{ id: 'select_language', type: 'click', target: '[data-guardian="lang-option-de"], [data-testid="lang-option-de"], button:has-text("DE")', description: 'Switch to German', timeout: 5000 },
|
|
52
|
+
{ id: 'verify_language', type: 'waitFor', target: '[data-guardian="lang-current"]:has-text("DE")', description: 'Wait for language to switch to DE', timeout: 5000 }
|
|
53
|
+
],
|
|
54
|
+
successConditions: [
|
|
55
|
+
{ type: 'selector', target: '[data-guardian="lang-current"]:has-text("DE")', description: 'Current language shows DE' },
|
|
56
|
+
{ type: 'selector', target: '[data-guardian="lang-current"], [data-testid="lang-current"]', description: 'Language indicator visible' }
|
|
57
|
+
],
|
|
58
|
+
// Phase 2: Language switch validators
|
|
59
|
+
validators: [
|
|
60
|
+
{ type: 'htmlLangAttribute', lang: 'de' },
|
|
61
|
+
{ type: 'pageContainsAnyText', textList: ['Deutsch', 'German', 'Sprache'] }
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
newsletter_signup: {
|
|
66
|
+
id: 'newsletter_signup',
|
|
67
|
+
name: 'Newsletter Signup',
|
|
68
|
+
goal: 'User signs up for newsletter',
|
|
69
|
+
riskCategory: 'LEAD',
|
|
70
|
+
baseSteps: [
|
|
71
|
+
{ id: 'navigate_home', type: 'navigate', target: '$BASEURL', description: 'Navigate to home page' },
|
|
72
|
+
{
|
|
73
|
+
id: 'open_signup_page',
|
|
74
|
+
type: 'click',
|
|
75
|
+
target: 'a[data-guardian="signup-link"], a[data-testid="signup-link"], a:has-text("Sign up"), a:has-text("Signup"), a[href*="signup"], a[href*="newsletter"]',
|
|
76
|
+
description: 'Open signup page',
|
|
77
|
+
optional: true,
|
|
78
|
+
waitForNavigation: true
|
|
79
|
+
},
|
|
80
|
+
{ id: 'fill_email', type: 'type', target: 'input[type="email"], input[data-guardian="signup-email"], input[data-testid="signup-email"]', value: 'subscriber@example.com', description: 'Enter email', timeout: 5000 },
|
|
81
|
+
{ id: 'submit_signup', type: 'click', target: 'button[type="submit"], button[data-guardian="signup-submit"], button:has-text("Subscribe"), button:has-text("Sign up")', description: 'Submit signup', timeout: 5000 }
|
|
82
|
+
],
|
|
83
|
+
successConditions: [
|
|
84
|
+
{ type: 'selector', target: '[data-guardian="signup-success"], [data-testid="signup-success"]', description: 'Signup success message visible' }
|
|
85
|
+
],
|
|
86
|
+
// Phase 2: Newsletter signup validators
|
|
87
|
+
validators: [
|
|
88
|
+
{ type: 'elementVisible', selector: '[data-guardian="signup-success"], [data-testid="signup-success"], .signup-success, .toast-success' },
|
|
89
|
+
{ type: 'pageContainsAnyText', textList: ['confirmed', 'subscribed', 'thank you', 'welcome', 'subscription'] },
|
|
90
|
+
{ type: 'elementNotVisible', selector: '.error, [role="alert"], .signup-error, .error-message' }
|
|
91
|
+
]
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
signup: {
|
|
95
|
+
id: 'signup',
|
|
96
|
+
name: 'Account Signup',
|
|
97
|
+
goal: 'User creates an account successfully',
|
|
98
|
+
riskCategory: 'LEAD',
|
|
99
|
+
baseSteps: [
|
|
100
|
+
{ id: 'navigate_home', type: 'navigate', target: '$BASEURL', description: 'Navigate to home page' },
|
|
101
|
+
{ id: 'open_account_signup', type: 'click', target: 'a[data-guardian="account-signup-link"], a:has-text("Account Signup")', description: 'Open account signup', optional: true, waitForNavigation: true, timeout: 5000 },
|
|
102
|
+
{ id: 'fill_signup_email', type: 'type', target: '[data-guardian="signup-email"]', value: 'newuser@example.com', description: 'Enter signup email', timeout: 5000 },
|
|
103
|
+
{ id: 'fill_signup_password', type: 'type', target: '[data-guardian="signup-password"]', value: 'P@ssword123', description: 'Enter signup password', timeout: 5000 },
|
|
104
|
+
{ id: 'submit_signup_account', type: 'click', target: '[data-guardian="signup-account-submit"], button:has-text("Sign up")', description: 'Submit account signup', timeout: 5000 },
|
|
105
|
+
{ id: 'wait_signup_success', type: 'waitFor', target: '[data-guardian="signup-account-success"]', description: 'Wait for signup success', timeout: 7000 }
|
|
106
|
+
],
|
|
107
|
+
successConditions: [
|
|
108
|
+
{ type: 'selector', target: '[data-guardian="signup-account-success"]', description: 'Account signup success visible' }
|
|
109
|
+
],
|
|
110
|
+
validators: [
|
|
111
|
+
{ type: 'elementVisible', selector: '[data-guardian="signup-account-success"]' },
|
|
112
|
+
{ type: 'pageContainsAnyText', textList: ['Account created', 'created', 'welcome aboard'] },
|
|
113
|
+
{ type: 'elementNotVisible', selector: '[data-guardian="signup-account-error"], .error, [role="alert"]' }
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
login: {
|
|
118
|
+
id: 'login',
|
|
119
|
+
name: 'Account Login',
|
|
120
|
+
goal: 'User logs in successfully (single session)',
|
|
121
|
+
riskCategory: 'TRUST/UX',
|
|
122
|
+
baseSteps: [
|
|
123
|
+
{ id: 'navigate_home', type: 'navigate', target: '$BASEURL', description: 'Navigate to home page' },
|
|
124
|
+
{ id: 'open_login_page', type: 'click', target: 'a[data-guardian="account-login-link"], a:has-text("Login"), a[href*="login"]', description: 'Open login page', optional: true, waitForNavigation: true, timeout: 5000 },
|
|
125
|
+
{ id: 'fill_login_email', type: 'type', target: '[data-guardian="login-email"]', value: 'user@example.com', description: 'Enter login email', timeout: 5000 },
|
|
126
|
+
{ id: 'fill_login_password', type: 'type', target: '[data-guardian="login-password"]', value: 'password123', description: 'Enter login password', timeout: 5000 },
|
|
127
|
+
{ id: 'submit_login', type: 'click', target: '[data-guardian="login-submit"], button:has-text("Login")', description: 'Submit login', timeout: 5000 },
|
|
128
|
+
{ id: 'wait_login_success', type: 'waitFor', target: '[data-guardian="login-success"]', description: 'Wait for login success', timeout: 7000 }
|
|
129
|
+
],
|
|
130
|
+
successConditions: [
|
|
131
|
+
{ type: 'selector', target: '[data-guardian="login-success"]', description: 'Login success visible' }
|
|
132
|
+
],
|
|
133
|
+
validators: [
|
|
134
|
+
{ type: 'elementVisible', selector: '[data-guardian="login-success"]' },
|
|
135
|
+
{ type: 'pageContainsAnyText', textList: ['logged in', 'welcome back', 'logged'] },
|
|
136
|
+
{ type: 'elementNotVisible', selector: '[data-guardian="login-error"], .error, [role="alert"]' }
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
checkout: {
|
|
141
|
+
id: 'checkout',
|
|
142
|
+
name: 'Checkout Review',
|
|
143
|
+
goal: 'User places order without payment',
|
|
144
|
+
riskCategory: 'REVENUE',
|
|
145
|
+
baseSteps: [
|
|
146
|
+
{ id: 'navigate_home', type: 'navigate', target: '$BASEURL', description: 'Navigate to home page' },
|
|
147
|
+
{ id: 'open_checkout', type: 'click', target: 'a[data-guardian="checkout-link"], a:has-text("Checkout"), a[href*="checkout"]', description: 'Open checkout page', optional: true, waitForNavigation: true, timeout: 5000 },
|
|
148
|
+
{ id: 'place_order', type: 'click', target: '[data-guardian="checkout-place-order"], button:has-text("Place order"), button:has-text("Place Order")', description: 'Place order', timeout: 5000 },
|
|
149
|
+
{ id: 'wait_order_success', type: 'waitFor', target: '[data-guardian="checkout-success"]', description: 'Wait for order confirmation', timeout: 7000 }
|
|
150
|
+
],
|
|
151
|
+
successConditions: [
|
|
152
|
+
{ type: 'selector', target: '[data-guardian="checkout-success"]', description: 'Checkout success visible' }
|
|
153
|
+
],
|
|
154
|
+
validators: [
|
|
155
|
+
{ type: 'elementVisible', selector: '[data-guardian="checkout-success"]' },
|
|
156
|
+
{ type: 'pageContainsAnyText', textList: ['order placed', 'order confirmed', 'order confirmed'] },
|
|
157
|
+
{ type: 'elementNotVisible', selector: '[data-guardian="checkout-error"], .error, [role="alert"]' }
|
|
158
|
+
]
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
// Universal Reality Pack: deterministic, zero-config safety checks
|
|
162
|
+
universal_reality: {
|
|
163
|
+
id: 'universal_reality',
|
|
164
|
+
name: 'Universal Reality Pack',
|
|
165
|
+
goal: 'Basic site usability and safety checks',
|
|
166
|
+
riskCategory: 'TRUST/UX',
|
|
167
|
+
baseSteps: [
|
|
168
|
+
{ id: 'navigate_home', type: 'navigate', target: '$BASEURL', description: 'Navigate to home page' },
|
|
169
|
+
{ id: 'wait_for_body', type: 'waitFor', target: 'body', description: 'Ensure page body is visible', timeout: 5000 },
|
|
170
|
+
// Safe navigation probe (optional)
|
|
171
|
+
{
|
|
172
|
+
id: 'probe_safe_nav',
|
|
173
|
+
type: 'click',
|
|
174
|
+
target: 'nav a[href], header a[href], a[href]:not([href^="mailto:"]):not([href^="tel:"]):not([href^="javascript:"])',
|
|
175
|
+
description: 'Click a safe visible link',
|
|
176
|
+
optional: true,
|
|
177
|
+
waitForNavigation: true
|
|
178
|
+
}
|
|
179
|
+
],
|
|
180
|
+
successConditions: [
|
|
181
|
+
// Consider success if body is visible (page loaded) or URL still valid
|
|
182
|
+
{ type: 'selector', target: 'body', description: 'Page loaded and visible' }
|
|
183
|
+
],
|
|
184
|
+
// Deterministic validators for soft issues
|
|
185
|
+
validators: [
|
|
186
|
+
// Minimal content heuristics
|
|
187
|
+
{ type: 'elementVisible', selector: 'nav, header' },
|
|
188
|
+
{ type: 'pageContainsAnyText', textList: ['home', 'about', 'contact', 'privacy', 'terms'] },
|
|
189
|
+
// Console errors as soft failures (WARN/FAIL recorded, outcome unchanged)
|
|
190
|
+
{ type: 'noConsoleErrorsAbove', minSeverity: 'error' }
|
|
191
|
+
]
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
function getAttemptDefinition(id) {
|
|
196
|
+
return attemptDefinitions[id] || null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getDefaultAttemptIds() {
|
|
200
|
+
return DEFAULT_ATTEMPTS.slice();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function listAttemptDefinitions() {
|
|
204
|
+
return Object.values(attemptDefinitions);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Phase 2: Dynamically register an auto-generated attempt
|
|
209
|
+
* @param {Object} attemptDef - Attempt definition with id, name, baseSteps, validators
|
|
210
|
+
*/
|
|
211
|
+
function registerAttempt(attemptDef) {
|
|
212
|
+
if (!attemptDef || !attemptDef.id) {
|
|
213
|
+
throw new Error('Cannot register attempt: missing id');
|
|
214
|
+
}
|
|
215
|
+
if (attemptDefinitions[attemptDef.id]) {
|
|
216
|
+
console.warn(`⚠️ Attempt ${attemptDef.id} already registered, skipping`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
attemptDefinitions[attemptDef.id] = attemptDef;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = {
|
|
223
|
+
getAttemptDefinition,
|
|
224
|
+
getDefaultAttemptIds,
|
|
225
|
+
listAttemptDefinitions,
|
|
226
|
+
registerAttempt
|
|
227
|
+
};
|