docs: enterprise refactor plan with ralph specs

This commit is contained in:
debian
2026-03-04 16:17:03 -05:00
parent 4c92712d20
commit f8191133c8
204 changed files with 32722 additions and 422 deletions

501
dist/plugins/agents/PlaywrightAgent.js vendored Normal file
View File

@@ -0,0 +1,501 @@
"use strict";
/**
* PlaywrightAgent — implements IInteractionAgent using Playwright.
* All random choices use a deterministic seed and are logged.
* Supports scope enforcement, auth injection, and action delay.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.PlaywrightAgent = void 0;
const crypto = __importStar(require("crypto"));
const playwright_1 = require("playwright");
const Logger_1 = require("../../core/Logger");
const ExplorationConfig_1 = require("../../core/ExplorationConfig");
/** Simple deterministic pseudo-random number generator (LCG) */
class SeededRandom {
constructor(seed) {
this.state = seed;
}
/** Returns a float in [0, 1) */
next() {
this.state = (this.state * 1664525 + 1013904223) & 0xffffffff;
return (this.state >>> 0) / 0x100000000;
}
/** Returns an integer in [0, max) */
nextInt(max) {
return Math.floor(this.next() * max);
}
}
function generateId() {
return crypto.randomUUID();
}
function domHash(url, domSnapshot) {
return crypto
.createHash('sha1')
.update(url + domSnapshot)
.digest('hex')
.substring(0, 16);
}
class PlaywrightAgent {
constructor(config = {}) {
/** Captured HTTP responses for the current action */
this.pendingResponses = [];
this.pendingConsoleErrors = [];
this.pendingJsExceptions = [];
this.seed = config.seed ?? 42;
this.rng = new SeededRandom(this.seed);
this.headless = config.headless ?? true;
this.timeoutMs = config.timeoutMs ?? 30000;
this.logger = config.logger ?? new Logger_1.NullLogger();
this.explorationConfig = config.explorationConfig ?? {};
}
async launch(url) {
// Select browser type
const browserType = this.explorationConfig.browsers?.[0] ?? 'chromium';
const launcher = browserType === 'firefox' ? playwright_1.firefox : browserType === 'webkit' ? playwright_1.webkit : playwright_1.chromium;
this.browser = await launcher.launch({ headless: this.headless });
// Apply auth headers if configured
const auth = this.explorationConfig.auth;
let contextOptions = {};
// Mobile device emulation
const mobileDevice = this.explorationConfig.mobileDevice;
if (mobileDevice && mobileDevice !== 'none') {
const device = playwright_1.devices[mobileDevice];
if (device) {
contextOptions = { ...device, ...contextOptions };
}
}
// Custom viewport
if (this.explorationConfig.viewport) {
contextOptions.viewport = this.explorationConfig.viewport;
}
if (auth?.type === 'headers') {
contextOptions.extraHTTPHeaders = auth.headers;
}
this.context = await this.browser.newContext(contextOptions);
// Apply auth cookies if configured
if (auth?.type === 'cookies') {
await this.context.addCookies(auth.cookies);
}
this.page = await this.context.newPage();
this.setupListeners(this.page);
// Apply network chaos conditions
await this.applyNetworkChaos(this.page);
// Login flow auth
if (auth?.type === 'login_flow') {
await this.performLoginFlow(auth);
}
await this.page.goto(url, { timeout: this.timeoutMs });
}
async close() {
await this.browser?.close();
this.browser = undefined;
this.context = undefined;
this.page = undefined;
}
async captureState() {
const page = this.requirePage();
const url = page.url();
const title = await page.title();
const domSnapshot = await page.evaluate(() => document.body.outerHTML);
const stateId = domHash(url, domSnapshot);
return {
id: stateId,
url,
title,
timestamp: Date.now(),
domSnapshot,
visitCount: 0,
};
}
async discoverActions(state) {
const page = this.requirePage();
const actions = [];
const now = Date.now();
const currentUrl = page.url();
if (this.isExcludedPath(currentUrl)) {
return [];
}
// Discover clickable elements
const clickableSelectors = [
'a[href]',
'button',
'[role="button"]',
'input[type="submit"]',
'input[type="button"]',
];
for (const selector of clickableSelectors) {
if (this.isExcludedSelector(selector))
continue;
const elements = await page.locator(selector).all();
for (const el of elements) {
const isVisible = await el.isVisible().catch(() => false);
if (!isVisible)
continue;
// Check element against excluded selectors
const elSel = await this.buildSelector(el, selector);
if (this.isExcludedSelector(elSel))
continue;
// For links, check domain is allowed
if (selector === 'a[href]') {
const href = await el.getAttribute('href').catch(() => null);
if (href && this.isExternalLink(href, currentUrl))
continue;
}
const actionSeed = this.rng.nextInt(0x7fffffff);
actions.push({
id: generateId(),
type: 'click',
selector: elSel,
timestamp: now,
seed: actionSeed,
stateId: state.id,
});
}
}
// Discover fillable inputs
const inputSelectors = [
'input[type="text"]',
'input[type="email"]',
'input[type="password"]',
'textarea',
];
for (const selector of inputSelectors) {
if (this.isExcludedSelector(selector))
continue;
const elements = await page.locator(selector).all();
for (const el of elements) {
const isVisible = await el.isVisible().catch(() => false);
if (!isVisible)
continue;
const elSel = await this.buildSelector(el, selector);
if (this.isExcludedSelector(elSel))
continue;
const actionSeed = this.rng.nextInt(0x7fffffff);
actions.push({
id: generateId(),
type: 'fill',
selector: elSel,
value: '',
timestamp: now,
seed: actionSeed,
stateId: state.id,
});
}
}
return actions;
}
async executeAction(action) {
const page = this.requirePage();
this.resetPending();
const observationId = generateId();
const actionDelayMs = this.explorationConfig.actionDelayMs ?? 0;
// Skip actions targeting excluded paths
if (action.url && this.isExcludedPath(action.url)) {
const state = await this.captureState();
return this.buildObservation(observationId, action.id, state.id);
}
// Enforce allowed domains for navigate actions
if (action.type === 'navigate' && action.url) {
if (!this.isAllowedUrl(action.url)) {
const state = await this.captureState();
return this.buildObservation(observationId, action.id, state.id);
}
}
try {
switch (action.type) {
case 'click':
await page.locator(action.selector).first().click({ timeout: this.timeoutMs });
break;
case 'fill':
await page.locator(action.selector).first().fill(action.value ?? '', { timeout: this.timeoutMs });
break;
case 'navigate':
await page.goto(action.url, { timeout: this.timeoutMs });
break;
case 'submit':
await page.locator(action.selector).first().dispatchEvent('submit');
break;
case 'select':
await page.locator(action.selector).first().selectOption(action.value ?? '');
break;
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.pendingJsExceptions.push(`Action ${action.type} failed: ${msg}`);
}
// Wait for async effects to settle + configured delay
await page.waitForTimeout(200 + actionDelayMs);
const newState = await this.captureState();
return this.buildObservation(observationId, action.id, newState.id);
}
// ─── Private helpers ──────────────────────────────────────────────────────
getPage() {
return this.requirePage();
}
requirePage() {
if (!this.page)
throw new Error('PlaywrightAgent: not launched. Call launch() first.');
return this.page;
}
resetPending() {
this.pendingResponses = [];
this.pendingConsoleErrors = [];
this.pendingJsExceptions = [];
}
buildObservation(observationId, actionId, newStateId) {
return {
id: observationId,
actionId,
newStateId,
httpResponses: [...this.pendingResponses],
consoleErrors: [...this.pendingConsoleErrors],
jsExceptions: [...this.pendingJsExceptions],
timestamp: Date.now(),
};
}
setupListeners(page) {
const requestTimestamps = new Map();
page.on('request', (req) => {
requestTimestamps.set(req.url(), Date.now());
});
page.on('response', (res) => {
const start = requestTimestamps.get(res.url()) ?? Date.now();
const durationMs = Date.now() - start;
this.pendingResponses.push({
url: res.url(),
status: res.status(),
method: res.request().method(),
durationMs,
});
});
page.on('console', (msg) => {
if (msg.type() === 'error') {
this.pendingConsoleErrors.push(msg.text());
}
});
page.on('pageerror', (err) => {
this.pendingJsExceptions.push(err.message);
});
}
async buildSelector(el, fallback) {
try {
const id = await el.getAttribute('id').catch(() => null);
if (id)
return `#${id}`;
const name = await el.getAttribute('name').catch(() => null);
if (name)
return `[name="${name}"]`;
}
catch {
// ignore
}
return fallback;
}
isExcludedPath(urlOrPath) {
const excludedPaths = this.explorationConfig.excludedPaths ?? [];
if (excludedPaths.length === 0)
return false;
try {
const parsed = new URL(urlOrPath, 'http://placeholder');
return excludedPaths.some((p) => parsed.pathname.startsWith(p));
}
catch {
return false;
}
}
isExcludedSelector(selector) {
const excludedSelectors = this.explorationConfig.excludedSelectors ?? [];
return excludedSelectors.includes(selector);
}
isExternalLink(href, currentUrl) {
const allowedDomains = this.explorationConfig.allowedDomains ?? [];
if (allowedDomains.length === 0)
return false;
try {
const base = new URL(currentUrl);
const target = new URL(href, base.origin);
return !allowedDomains.includes(target.hostname);
}
catch {
return false;
}
}
isAllowedUrl(url) {
const allowedDomains = this.explorationConfig.allowedDomains ?? [];
if (allowedDomains.length === 0)
return true;
try {
const parsed = new URL(url);
return allowedDomains.includes(parsed.hostname);
}
catch {
return false;
}
}
async performLoginFlow(auth) {
const page = this.requirePage();
await page.goto(auth.loginUrl, { timeout: this.timeoutMs });
await page.locator(auth.usernameSelector).first().fill(auth.username);
await page.locator(auth.passwordSelector).first().fill(auth.password);
await page.locator(auth.submitSelector).first().click();
await page.waitForNavigation({ timeout: this.timeoutMs }).catch(() => undefined);
const currentUrl = page.url();
if (currentUrl === auth.loginUrl || currentUrl.includes(new URL(auth.loginUrl).pathname)) {
throw new Error(`Login failed: still on login page ${currentUrl}`);
}
}
async applyNetworkChaos(page) {
const chaos = this.explorationConfig.networkChaos;
if (!chaos?.enabled)
return;
// Apply network condition via CDP (Chromium only)
const profile = chaos.profile ?? 'none';
const condition = ExplorationConfig_1.NETWORK_PROFILES[profile];
if (condition) {
try {
const client = await this.context.newCDPSession(page);
await client.send('Network.emulateNetworkConditions', {
offline: condition.offline,
downloadThroughput: condition.offline ? -1 : (condition.downloadKbps * 1024) / 8,
uploadThroughput: condition.offline ? -1 : (condition.uploadKbps * 1024) / 8,
latency: condition.latencyMs,
});
}
catch {
// CDP not available (e.g. non-Chromium browser) — ignore
}
}
// Block specified endpoints
if (chaos.blockedEndpoints && chaos.blockedEndpoints.length > 0) {
await page.route('**/*', (route) => {
const url = route.request().url();
const isBlocked = chaos.blockedEndpoints.some((pattern) => this.matchGlob(url, pattern));
if (isBlocked) {
route.fulfill({ status: 503, body: 'Service Unavailable (ABE Network Chaos)' });
}
else {
route.continue();
}
});
}
// Slow down specified endpoints
if (chaos.slowEndpoints && chaos.slowEndpoints.length > 0) {
for (const slowEp of chaos.slowEndpoints) {
await page.route(slowEp.pattern, async (route) => {
await new Promise((r) => setTimeout(r, slowEp.delayMs));
route.continue();
});
}
}
}
matchGlob(url, pattern) {
// Convert glob pattern to regex
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
try {
return new RegExp(escaped).test(url);
}
catch {
return false;
}
}
/**
* Detects mobile layout issues on the current page:
* - Touch targets smaller than 44x44px (WCAG 2.5.5 / Apple HIG)
* - Horizontal content overflow beyond viewport width
*/
async detectMobileLayoutIssues(stateId, sessionId, actionTrace) {
const page = this.requirePage();
const anomalies = [];
try {
const issues = await page.evaluate(() => {
const findings = [];
// Check for horizontal overflow
const docWidth = document.documentElement.scrollWidth;
const viewportWidth = window.innerWidth;
if (docWidth > viewportWidth) {
findings.push(`horizontal_overflow: ${docWidth}px > ${viewportWidth}px viewport`);
}
// Check for small touch targets (< 44x44 px)
const interactiveSelectors = ['a', 'button', '[role="button"]', 'input', 'select', 'textarea'];
const seen = new Set();
for (const sel of interactiveSelectors) {
for (const el of document.querySelectorAll(sel)) {
if (seen.has(el))
continue;
seen.add(el);
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0)
continue;
if (rect.width < 44 || rect.height < 44) {
const label = el.getAttribute('aria-label') ||
el.textContent?.trim().slice(0, 30) ||
el.tagName.toLowerCase();
findings.push(`small_touch_target: "${label}" (${Math.round(rect.width)}x${Math.round(rect.height)}px)`);
if (findings.filter((f) => f.startsWith('small_touch_target')).length >= 5)
break;
}
}
if (findings.filter((f) => f.startsWith('small_touch_target')).length >= 5)
break;
}
return findings;
}).catch(() => []);
if (issues.length === 0)
return anomalies;
const hasOverflow = issues.some((i) => i.startsWith('horizontal_overflow'));
const smallTargetCount = issues.filter((i) => i.startsWith('small_touch_target')).length;
const severity = hasOverflow ? 'high' : smallTargetCount >= 3 ? 'medium' : 'low';
anomalies.push({
id: generateId(),
type: 'mobile_layout_issue',
severity,
observationId: stateId,
actionTrace,
description: `Mobile layout issues detected: ${issues.slice(0, 3).join('; ')}`,
evidence: { rawErrors: issues },
timestamp: Date.now(),
});
}
catch {
// Page may be in invalid state — ignore
}
return anomalies;
}
}
exports.PlaywrightAgent = PlaywrightAgent;