docs: enterprise refactor plan with ralph specs
This commit is contained in:
501
dist/plugins/agents/PlaywrightAgent.js
vendored
Normal file
501
dist/plugins/agents/PlaywrightAgent.js
vendored
Normal 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;
|
||||
Reference in New Issue
Block a user