@veraxhq/verax 0.1.0 → 0.2.1
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/README.md +123 -88
- package/bin/verax.js +11 -452
- package/package.json +24 -36
- package/src/cli/commands/default.js +681 -0
- package/src/cli/commands/doctor.js +197 -0
- package/src/cli/commands/inspect.js +109 -0
- package/src/cli/commands/run.js +586 -0
- package/src/cli/entry.js +196 -0
- package/src/cli/util/atomic-write.js +37 -0
- package/src/cli/util/detection-engine.js +297 -0
- package/src/cli/util/env-url.js +33 -0
- package/src/cli/util/errors.js +44 -0
- package/src/cli/util/events.js +110 -0
- package/src/cli/util/expectation-extractor.js +388 -0
- package/src/cli/util/findings-writer.js +32 -0
- package/src/cli/util/idgen.js +87 -0
- package/src/cli/util/learn-writer.js +39 -0
- package/src/cli/util/observation-engine.js +412 -0
- package/src/cli/util/observe-writer.js +25 -0
- package/src/cli/util/paths.js +30 -0
- package/src/cli/util/project-discovery.js +297 -0
- package/src/cli/util/project-writer.js +26 -0
- package/src/cli/util/redact.js +128 -0
- package/src/cli/util/run-id.js +30 -0
- package/src/cli/util/runtime-budget.js +147 -0
- package/src/cli/util/summary-writer.js +43 -0
- package/src/types/global.d.ts +28 -0
- package/src/types/ts-ast.d.ts +24 -0
- package/src/verax/cli/ci-summary.js +35 -0
- package/src/verax/cli/context-explanation.js +89 -0
- package/src/verax/cli/doctor.js +277 -0
- package/src/verax/cli/error-normalizer.js +154 -0
- package/src/verax/cli/explain-output.js +105 -0
- package/src/verax/cli/finding-explainer.js +130 -0
- package/src/verax/cli/init.js +237 -0
- package/src/verax/cli/run-overview.js +163 -0
- package/src/verax/cli/url-safety.js +111 -0
- package/src/verax/cli/wizard.js +109 -0
- package/src/verax/cli/zero-findings-explainer.js +57 -0
- package/src/verax/cli/zero-interaction-explainer.js +127 -0
- package/src/verax/core/action-classifier.js +86 -0
- package/src/verax/core/budget-engine.js +218 -0
- package/src/verax/core/canonical-outcomes.js +157 -0
- package/src/verax/core/decision-snapshot.js +335 -0
- package/src/verax/core/determinism-model.js +432 -0
- package/src/verax/core/incremental-store.js +245 -0
- package/src/verax/core/invariants.js +356 -0
- package/src/verax/core/promise-model.js +230 -0
- package/src/verax/core/replay-validator.js +350 -0
- package/src/verax/core/replay.js +222 -0
- package/src/verax/core/run-id.js +175 -0
- package/src/verax/core/run-manifest.js +99 -0
- package/src/verax/core/silence-impact.js +369 -0
- package/src/verax/core/silence-model.js +523 -0
- package/src/verax/detect/comparison.js +7 -34
- package/src/verax/detect/confidence-engine.js +764 -329
- package/src/verax/detect/detection-engine.js +293 -0
- package/src/verax/detect/evidence-index.js +127 -0
- package/src/verax/detect/expectation-model.js +241 -168
- package/src/verax/detect/explanation-helpers.js +187 -0
- package/src/verax/detect/finding-detector.js +450 -0
- package/src/verax/detect/findings-writer.js +41 -12
- package/src/verax/detect/flow-detector.js +366 -0
- package/src/verax/detect/index.js +200 -288
- package/src/verax/detect/interactive-findings.js +612 -0
- package/src/verax/detect/signal-mapper.js +308 -0
- package/src/verax/detect/skip-classifier.js +4 -4
- package/src/verax/detect/verdict-engine.js +561 -0
- package/src/verax/evidence-index-writer.js +61 -0
- package/src/verax/flow/flow-engine.js +3 -2
- package/src/verax/flow/flow-spec.js +1 -2
- package/src/verax/index.js +103 -15
- package/src/verax/intel/effect-detector.js +368 -0
- package/src/verax/intel/handler-mapper.js +249 -0
- package/src/verax/intel/index.js +281 -0
- package/src/verax/intel/route-extractor.js +280 -0
- package/src/verax/intel/ts-program.js +256 -0
- package/src/verax/intel/vue-navigation-extractor.js +642 -0
- package/src/verax/intel/vue-router-extractor.js +325 -0
- package/src/verax/learn/action-contract-extractor.js +338 -104
- package/src/verax/learn/ast-contract-extractor.js +148 -6
- package/src/verax/learn/flow-extractor.js +172 -0
- package/src/verax/learn/index.js +36 -2
- package/src/verax/learn/manifest-writer.js +122 -58
- package/src/verax/learn/project-detector.js +40 -0
- package/src/verax/learn/route-extractor.js +28 -97
- package/src/verax/learn/route-validator.js +8 -7
- package/src/verax/learn/state-extractor.js +212 -0
- package/src/verax/learn/static-extractor-navigation.js +114 -0
- package/src/verax/learn/static-extractor-validation.js +88 -0
- package/src/verax/learn/static-extractor.js +119 -10
- package/src/verax/learn/truth-assessor.js +24 -21
- package/src/verax/learn/ts-contract-resolver.js +14 -12
- package/src/verax/observe/aria-sensor.js +211 -0
- package/src/verax/observe/browser.js +30 -6
- package/src/verax/observe/console-sensor.js +2 -18
- package/src/verax/observe/domain-boundary.js +10 -1
- package/src/verax/observe/expectation-executor.js +513 -0
- package/src/verax/observe/flow-matcher.js +143 -0
- package/src/verax/observe/focus-sensor.js +196 -0
- package/src/verax/observe/human-driver.js +660 -273
- package/src/verax/observe/index.js +910 -26
- package/src/verax/observe/interaction-discovery.js +378 -15
- package/src/verax/observe/interaction-runner.js +562 -197
- package/src/verax/observe/loading-sensor.js +145 -0
- package/src/verax/observe/navigation-sensor.js +255 -0
- package/src/verax/observe/network-sensor.js +55 -7
- package/src/verax/observe/observed-expectation-deriver.js +186 -0
- package/src/verax/observe/observed-expectation.js +305 -0
- package/src/verax/observe/page-frontier.js +234 -0
- package/src/verax/observe/settle.js +38 -17
- package/src/verax/observe/state-sensor.js +393 -0
- package/src/verax/observe/state-ui-sensor.js +7 -1
- package/src/verax/observe/timing-sensor.js +228 -0
- package/src/verax/observe/traces-writer.js +73 -21
- package/src/verax/observe/ui-signal-sensor.js +143 -17
- package/src/verax/scan-summary-writer.js +80 -15
- package/src/verax/shared/artifact-manager.js +111 -9
- package/src/verax/shared/budget-profiles.js +136 -0
- package/src/verax/shared/caching.js +1 -1
- package/src/verax/shared/ci-detection.js +39 -0
- package/src/verax/shared/config-loader.js +169 -0
- package/src/verax/shared/dynamic-route-utils.js +224 -0
- package/src/verax/shared/expectation-coverage.js +44 -0
- package/src/verax/shared/expectation-prover.js +81 -0
- package/src/verax/shared/expectation-tracker.js +201 -0
- package/src/verax/shared/expectations-writer.js +60 -0
- package/src/verax/shared/first-run.js +44 -0
- package/src/verax/shared/progress-reporter.js +171 -0
- package/src/verax/shared/retry-policy.js +9 -1
- package/src/verax/shared/root-artifacts.js +49 -0
- package/src/verax/shared/scan-budget.js +86 -0
- package/src/verax/shared/url-normalizer.js +162 -0
- package/src/verax/shared/zip-artifacts.js +66 -0
- package/src/verax/validate/context-validator.js +244 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import { writeFileSync, mkdirSync } from 'fs';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { redactHeaders, redactUrl, redactBody, redactConsole, getRedactionCounters } from './redact.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Real Browser Observation Engine
|
|
8
|
+
* Monitors expectations from learn.json using actual Playwright browser
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export async function observeExpectations(expectations, url, evidencePath, onProgress) {
|
|
12
|
+
const observations = [];
|
|
13
|
+
let observed = 0;
|
|
14
|
+
let notObserved = 0;
|
|
15
|
+
const redactionCounters = { headersRedacted: 0, tokensRedacted: 0 };
|
|
16
|
+
let browser = null;
|
|
17
|
+
let page = null;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
// Launch browser
|
|
21
|
+
browser = await chromium.launch({
|
|
22
|
+
headless: true,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
page = await browser.newPage({
|
|
26
|
+
viewport: { width: 1280, height: 800 },
|
|
27
|
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Set up network and console monitoring
|
|
31
|
+
const networkLogs = [];
|
|
32
|
+
const consoleLogs = [];
|
|
33
|
+
|
|
34
|
+
page.on('request', (request) => {
|
|
35
|
+
const redactedHeaders = redactHeaders(request.headers(), redactionCounters);
|
|
36
|
+
const redactedUrl = redactUrl(request.url(), redactionCounters);
|
|
37
|
+
let redactedBody = null;
|
|
38
|
+
try {
|
|
39
|
+
const body = request.postData();
|
|
40
|
+
redactedBody = body ? redactBody(body, redactionCounters) : null;
|
|
41
|
+
} catch {
|
|
42
|
+
redactedBody = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
networkLogs.push({
|
|
46
|
+
url: redactedUrl,
|
|
47
|
+
method: request.method(),
|
|
48
|
+
headers: redactedHeaders,
|
|
49
|
+
body: redactedBody,
|
|
50
|
+
timestamp: new Date().toISOString(),
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
page.on('console', (msg) => {
|
|
55
|
+
const redactedText = redactConsole(msg.text(), redactionCounters);
|
|
56
|
+
consoleLogs.push({
|
|
57
|
+
type: msg.type(),
|
|
58
|
+
text: redactedText,
|
|
59
|
+
timestamp: new Date().toISOString(),
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Navigate to base URL first with explicit timeout
|
|
64
|
+
try {
|
|
65
|
+
await page.goto(url, {
|
|
66
|
+
waitUntil: 'domcontentloaded', // Use domcontentloaded instead of networkidle for faster timeout
|
|
67
|
+
timeout: 30000
|
|
68
|
+
});
|
|
69
|
+
// Wait for network idle with separate timeout
|
|
70
|
+
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {
|
|
71
|
+
// Network idle timeout is acceptable, continue
|
|
72
|
+
});
|
|
73
|
+
} catch (error) {
|
|
74
|
+
// Continue even if initial load fails
|
|
75
|
+
if (onProgress) {
|
|
76
|
+
onProgress({
|
|
77
|
+
event: 'observe:warning',
|
|
78
|
+
message: `Failed to load base URL: ${error.message}`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Track visited URLs for navigation observations
|
|
84
|
+
const visitedUrls = new Set([url]);
|
|
85
|
+
|
|
86
|
+
// Process each expectation
|
|
87
|
+
for (let i = 0; i < expectations.length; i++) {
|
|
88
|
+
const exp = expectations[i];
|
|
89
|
+
const expNum = i + 1;
|
|
90
|
+
|
|
91
|
+
if (onProgress) {
|
|
92
|
+
onProgress({
|
|
93
|
+
event: 'observe:attempt',
|
|
94
|
+
index: expNum,
|
|
95
|
+
total: expectations.length,
|
|
96
|
+
type: exp.type,
|
|
97
|
+
promise: exp.promise,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const observation = {
|
|
102
|
+
id: exp.id,
|
|
103
|
+
type: exp.type,
|
|
104
|
+
promise: exp.promise,
|
|
105
|
+
source: exp.source,
|
|
106
|
+
attempted: false,
|
|
107
|
+
observed: false,
|
|
108
|
+
observedAt: null,
|
|
109
|
+
evidenceFiles: [],
|
|
110
|
+
reason: null,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
let result = false;
|
|
115
|
+
let evidence = null;
|
|
116
|
+
|
|
117
|
+
if (exp.type === 'navigation') {
|
|
118
|
+
observation.attempted = true; // Mark as attempted
|
|
119
|
+
result = await observeNavigation(
|
|
120
|
+
page,
|
|
121
|
+
exp,
|
|
122
|
+
url,
|
|
123
|
+
visitedUrls,
|
|
124
|
+
evidencePath,
|
|
125
|
+
expNum
|
|
126
|
+
);
|
|
127
|
+
evidence = result ? `nav_${expNum}_after.png` : null;
|
|
128
|
+
} else if (exp.type === 'network') {
|
|
129
|
+
observation.attempted = true; // Mark as attempted
|
|
130
|
+
result = await observeNetwork(page, exp, networkLogs, 5000);
|
|
131
|
+
if (result) {
|
|
132
|
+
const evidenceFile = `network_${expNum}.json`;
|
|
133
|
+
try {
|
|
134
|
+
mkdirSync(evidencePath, { recursive: true });
|
|
135
|
+
const targetUrl = exp.promise.value;
|
|
136
|
+
const relevant = networkLogs.filter((log) =>
|
|
137
|
+
log.url === targetUrl || log.url.includes(targetUrl) || targetUrl.includes(log.url)
|
|
138
|
+
);
|
|
139
|
+
writeFileSync(resolve(evidencePath, evidenceFile), JSON.stringify(relevant, null, 2), 'utf-8');
|
|
140
|
+
} catch {
|
|
141
|
+
// best effort
|
|
142
|
+
}
|
|
143
|
+
evidence = evidenceFile;
|
|
144
|
+
} else {
|
|
145
|
+
evidence = null;
|
|
146
|
+
}
|
|
147
|
+
} else if (exp.type === 'state') {
|
|
148
|
+
observation.attempted = true; // Mark as attempted
|
|
149
|
+
result = await observeState(page, exp, evidencePath, expNum);
|
|
150
|
+
evidence = result ? `state_${expNum}_after.png` : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (result) {
|
|
154
|
+
observation.observed = true;
|
|
155
|
+
observation.observedAt = new Date().toISOString();
|
|
156
|
+
if (evidence) observation.evidenceFiles.push(evidence);
|
|
157
|
+
observed++;
|
|
158
|
+
} else {
|
|
159
|
+
observation.reason = 'No matching event observed';
|
|
160
|
+
notObserved++;
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
observation.reason = `Error: ${error.message}`;
|
|
164
|
+
notObserved++;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
observations.push(observation);
|
|
168
|
+
|
|
169
|
+
if (onProgress) {
|
|
170
|
+
onProgress({
|
|
171
|
+
event: 'observe:result',
|
|
172
|
+
index: expNum,
|
|
173
|
+
observed: observation.observed,
|
|
174
|
+
reason: observation.reason,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Persist shared evidence
|
|
180
|
+
try {
|
|
181
|
+
mkdirSync(evidencePath, { recursive: true });
|
|
182
|
+
const networkPath = resolve(evidencePath, 'network_logs.json');
|
|
183
|
+
writeFileSync(networkPath, JSON.stringify(networkLogs, null, 2), 'utf-8');
|
|
184
|
+
const consolePath = resolve(evidencePath, 'console_logs.json');
|
|
185
|
+
writeFileSync(consolePath, JSON.stringify(consoleLogs, null, 2), 'utf-8');
|
|
186
|
+
} catch {
|
|
187
|
+
// Best effort; do not throw
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
observations,
|
|
192
|
+
stats: {
|
|
193
|
+
attempted: expectations.length,
|
|
194
|
+
observed,
|
|
195
|
+
notObserved,
|
|
196
|
+
},
|
|
197
|
+
redaction: getRedactionCounters(redactionCounters),
|
|
198
|
+
observedAt: new Date().toISOString(),
|
|
199
|
+
};
|
|
200
|
+
} finally {
|
|
201
|
+
// Robust cleanup: ensure browser/context/page are closed
|
|
202
|
+
// Remove all event listeners to prevent leaks
|
|
203
|
+
if (page) {
|
|
204
|
+
try {
|
|
205
|
+
// Remove all listeners
|
|
206
|
+
page.removeAllListeners();
|
|
207
|
+
// @ts-expect-error - Playwright page.close() doesn't accept timeout option, but we use it for safety
|
|
208
|
+
await page.close({ timeout: 5000 }).catch(() => {});
|
|
209
|
+
} catch (e) {
|
|
210
|
+
// Ignore close errors but emit warning if onProgress available
|
|
211
|
+
if (onProgress) {
|
|
212
|
+
onProgress({
|
|
213
|
+
event: 'observe:warning',
|
|
214
|
+
message: `Page cleanup warning: ${e.message}`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Close browser context if it exists
|
|
221
|
+
if (browser) {
|
|
222
|
+
try {
|
|
223
|
+
const contexts = browser.contexts();
|
|
224
|
+
for (const context of contexts) {
|
|
225
|
+
try {
|
|
226
|
+
// @ts-expect-error - Playwright context.close() doesn't accept timeout option, but we use it for safety
|
|
227
|
+
await context.close({ timeout: 5000 }).catch(() => {});
|
|
228
|
+
} catch (e) {
|
|
229
|
+
// Ignore context close errors
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// @ts-expect-error - Playwright browser.close() doesn't accept timeout option, but we use it for safety
|
|
233
|
+
await browser.close({ timeout: 5000 }).catch(() => {});
|
|
234
|
+
} catch (e) {
|
|
235
|
+
// Ignore browser close errors but emit warning if onProgress available
|
|
236
|
+
if (onProgress) {
|
|
237
|
+
onProgress({
|
|
238
|
+
event: 'observe:warning',
|
|
239
|
+
message: `Browser cleanup warning: ${e.message}`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Observe navigation expectation
|
|
249
|
+
* Attempts to find and click element, observes URL/SPA changes
|
|
250
|
+
*/
|
|
251
|
+
async function observeNavigation(page, expectation, baseUrl, visitedUrls, evidencePath, expNum) {
|
|
252
|
+
const targetPath = expectation.promise.value;
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
// Screenshot before interaction
|
|
256
|
+
const beforePath = resolve(evidencePath, `nav_${expNum}_before.png`);
|
|
257
|
+
await page.screenshot({ path: beforePath }).catch(() => {});
|
|
258
|
+
|
|
259
|
+
// Find element by searching all anchor tags
|
|
260
|
+
const element = await page.evaluate((path) => {
|
|
261
|
+
const anchors = Array.from(document.querySelectorAll('a'));
|
|
262
|
+
const found = anchors.find(a => {
|
|
263
|
+
const href = a.getAttribute('href');
|
|
264
|
+
return href === path || href.includes(path);
|
|
265
|
+
});
|
|
266
|
+
return found ? { tag: 'a', href: found.getAttribute('href') } : null;
|
|
267
|
+
}, targetPath);
|
|
268
|
+
|
|
269
|
+
if (!element) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const urlBefore = page.url();
|
|
274
|
+
const contentBefore = await page.content();
|
|
275
|
+
|
|
276
|
+
// Click the element - try multiple approaches
|
|
277
|
+
try {
|
|
278
|
+
await page.locator(`a[href="${element.href}"]`).click({ timeout: 3000 });
|
|
279
|
+
} catch (e) {
|
|
280
|
+
try {
|
|
281
|
+
await page.click(`a[href="${element.href}"]`);
|
|
282
|
+
} catch (e2) {
|
|
283
|
+
// Try clicking by text content
|
|
284
|
+
// eslint-disable-next-line no-undef
|
|
285
|
+
const text = await page.evaluate((href) => {
|
|
286
|
+
const anchors = Array.from(document.querySelectorAll('a'));
|
|
287
|
+
const found = anchors.find(a => a.getAttribute('href') === href);
|
|
288
|
+
return found ? found.textContent : null;
|
|
289
|
+
}, element.href);
|
|
290
|
+
|
|
291
|
+
if (text) {
|
|
292
|
+
await page.click(`a:has-text("${text}")`).catch(() => {});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Wait for navigation or SPA update with explicit timeout
|
|
298
|
+
try {
|
|
299
|
+
await page.waitForNavigation({
|
|
300
|
+
waitUntil: 'domcontentloaded',
|
|
301
|
+
timeout: 5000
|
|
302
|
+
}).catch(() => {
|
|
303
|
+
// Navigation timeout is acceptable for SPAs
|
|
304
|
+
});
|
|
305
|
+
// Wait for network idle with separate timeout
|
|
306
|
+
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
|
307
|
+
// Network idle timeout is acceptable
|
|
308
|
+
});
|
|
309
|
+
} catch (e) {
|
|
310
|
+
// Navigation might not happen, continue
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Wait for potential SPA updates (bounded)
|
|
314
|
+
await page.waitForTimeout(300);
|
|
315
|
+
|
|
316
|
+
// Screenshot after interaction
|
|
317
|
+
const afterPath = resolve(evidencePath, `nav_${expNum}_after.png`);
|
|
318
|
+
await page.screenshot({ path: afterPath }).catch(() => {});
|
|
319
|
+
|
|
320
|
+
const urlAfter = page.url();
|
|
321
|
+
const contentAfter = await page.content();
|
|
322
|
+
|
|
323
|
+
// Check if URL changed or content changed
|
|
324
|
+
if (urlBefore !== urlAfter || contentBefore !== contentAfter) {
|
|
325
|
+
visitedUrls.add(urlAfter);
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return false;
|
|
330
|
+
} catch (error) {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Observe network expectation
|
|
337
|
+
* Checks if matching request was made
|
|
338
|
+
*/
|
|
339
|
+
async function observeNetwork(page, expectation, networkLogs, timeoutMs) {
|
|
340
|
+
const targetUrl = expectation.promise.value;
|
|
341
|
+
const startTime = Date.now();
|
|
342
|
+
|
|
343
|
+
return new Promise((resolve) => {
|
|
344
|
+
const checkTimer = setInterval(() => {
|
|
345
|
+
const found = networkLogs.some((log) => {
|
|
346
|
+
return (
|
|
347
|
+
log.url === targetUrl ||
|
|
348
|
+
log.url.includes(targetUrl) ||
|
|
349
|
+
targetUrl.includes(log.url)
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
if (found) {
|
|
354
|
+
clearInterval(checkTimer);
|
|
355
|
+
resolve(true);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (Date.now() - startTime > timeoutMs) {
|
|
360
|
+
clearInterval(checkTimer);
|
|
361
|
+
resolve(false);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
}, 100);
|
|
365
|
+
|
|
366
|
+
// CRITICAL: Unref the interval so it doesn't keep the process alive
|
|
367
|
+
// This allows tests to exit cleanly even if interval is not cleared
|
|
368
|
+
if (checkTimer && checkTimer.unref) {
|
|
369
|
+
checkTimer.unref();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Observe state expectation
|
|
376
|
+
* Detects DOM changes or loading indicators
|
|
377
|
+
*/
|
|
378
|
+
async function observeState(page, expectation, evidencePath, expNum) {
|
|
379
|
+
try {
|
|
380
|
+
// Screenshot before
|
|
381
|
+
const beforePath = resolve(evidencePath, `state_${expNum}_before.png`);
|
|
382
|
+
await page.screenshot({ path: beforePath });
|
|
383
|
+
|
|
384
|
+
const htmlBefore = await page.content();
|
|
385
|
+
|
|
386
|
+
// Wait briefly for potential state changes
|
|
387
|
+
await page.waitForTimeout(2000);
|
|
388
|
+
|
|
389
|
+
const htmlAfter = await page.content();
|
|
390
|
+
|
|
391
|
+
// Screenshot after
|
|
392
|
+
const afterPath = resolve(evidencePath, `state_${expNum}_after.png`);
|
|
393
|
+
await page.screenshot({ path: afterPath });
|
|
394
|
+
|
|
395
|
+
// Check if DOM changed
|
|
396
|
+
if (htmlBefore !== htmlAfter) {
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Check for common state indicators (loading, error, success messages)
|
|
401
|
+
const hasStateIndicators =
|
|
402
|
+
(await page.$('.loading')) ||
|
|
403
|
+
(await page.$('[role="status"]')) ||
|
|
404
|
+
(await page.$('.toast')) ||
|
|
405
|
+
(await page.$('[aria-live]'));
|
|
406
|
+
|
|
407
|
+
return !!hasStateIndicators;
|
|
408
|
+
} catch (error) {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { atomicWriteJson } from './atomic-write.js';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Write observe.json artifact
|
|
6
|
+
*/
|
|
7
|
+
export function writeObserveJson(runDir, observeData) {
|
|
8
|
+
const observePath = resolve(runDir, 'observe.json');
|
|
9
|
+
|
|
10
|
+
const payload = {
|
|
11
|
+
observations: observeData.observations || [],
|
|
12
|
+
stats: {
|
|
13
|
+
attempted: observeData.stats?.attempted || 0,
|
|
14
|
+
observed: observeData.stats?.observed || 0,
|
|
15
|
+
notObserved: observeData.stats?.notObserved || 0,
|
|
16
|
+
},
|
|
17
|
+
redaction: {
|
|
18
|
+
headersRedacted: observeData.redaction?.headersRedacted || 0,
|
|
19
|
+
tokensRedacted: observeData.redaction?.tokensRedacted || 0,
|
|
20
|
+
},
|
|
21
|
+
observedAt: observeData.observedAt || new Date().toISOString(),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
atomicWriteJson(observePath, payload);
|
|
25
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { join, isAbsolute } from 'path';
|
|
2
|
+
import { mkdirSync } from 'fs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build run artifact paths
|
|
6
|
+
*/
|
|
7
|
+
export function getRunPaths(projectRoot, outDir, runId) {
|
|
8
|
+
const outBase = isAbsolute(outDir) ? outDir : join(projectRoot, outDir);
|
|
9
|
+
const baseDir = join(outBase, 'runs', runId);
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
baseDir,
|
|
13
|
+
runStatusJson: join(baseDir, 'run.status.json'),
|
|
14
|
+
runMetaJson: join(baseDir, 'run.meta.json'),
|
|
15
|
+
summaryJson: join(baseDir, 'summary.json'),
|
|
16
|
+
findingsJson: join(baseDir, 'findings.json'),
|
|
17
|
+
tracesJsonl: join(baseDir, 'traces.jsonl'),
|
|
18
|
+
evidenceDir: join(baseDir, 'evidence'),
|
|
19
|
+
learnJson: join(baseDir, 'learn.json'),
|
|
20
|
+
observeJson: join(baseDir, 'observe.json'),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ensure all required directories exist
|
|
26
|
+
*/
|
|
27
|
+
export function ensureRunDirectories(paths) {
|
|
28
|
+
mkdirSync(paths.baseDir, { recursive: true });
|
|
29
|
+
mkdirSync(paths.evidenceDir, { recursive: true });
|
|
30
|
+
}
|