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

View File

@@ -0,0 +1,197 @@
/**
* Tests for AccessibilityCollector with mocked AxeBuilder and violations.
*/
import { AccessibilityCollector } from '../../src/plugins/collectors/AccessibilityCollector';
import type { IAction } from '../../src/core/interfaces';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeAction(): IAction {
return { id: 'a1', type: 'click', timestamp: Date.now(), seed: 42, stateId: 'state1' };
}
interface MockViolation {
id: string;
impact?: string;
description: string;
helpUrl: string;
nodes: unknown[];
}
// ─── Mock @axe-core/playwright ────────────────────────────────────────────────
const analyzeMock = jest.fn();
jest.mock('@axe-core/playwright', () => {
return {
AxeBuilder: jest.fn().mockImplementation(() => ({
withTags: jest.fn().mockReturnThis(),
analyze: analyzeMock,
})),
};
});
function makeViolations(violations: MockViolation[]) {
analyzeMock.mockResolvedValue({ violations });
}
// A minimal mock page (AxeBuilder receives the page, evaluate is only used as fallback)
const mockPage = {
evaluate: jest.fn().mockResolvedValue([]),
};
// ─── Tests ────────────────────────────────────────────────────────────────────
beforeEach(() => {
analyzeMock.mockReset();
mockPage.evaluate.mockReset();
mockPage.evaluate.mockResolvedValue([]);
});
describe('AccessibilityCollector', () => {
it('returns empty array when disabled', async () => {
const collector = new AccessibilityCollector({ enabled: false });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', []);
expect(result).toHaveLength(0);
expect(analyzeMock).not.toHaveBeenCalled();
});
it('returns empty array when no violations found', async () => {
makeViolations([]);
const collector = new AccessibilityCollector({ enabled: true });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', [makeAction()]);
expect(result).toHaveLength(0);
});
it('converts serious violations to high severity anomalies', async () => {
makeViolations([
{
id: 'image-alt',
impact: 'serious',
description: 'Images must have alternate text',
helpUrl: 'https://dequeuniversity.com/rules/axe/image-alt',
nodes: [{ html: '<img src="logo.png">' }],
},
]);
const collector = new AccessibilityCollector({ enabled: true, minImpact: 'minor' });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', [makeAction()]);
expect(result).toHaveLength(1);
expect(result[0]!.type).toBe('accessibility_violation');
expect(result[0]!.severity).toBe('high');
expect(result[0]!.description).toContain('Images must have alternate text');
});
it('converts critical violations to critical severity', async () => {
makeViolations([
{
id: 'color-contrast',
impact: 'critical',
description: 'Elements must have sufficient color contrast',
helpUrl: 'https://dequeuniversity.com/rules/axe/color-contrast',
nodes: [{ html: '<p>text</p>' }],
},
]);
const collector = new AccessibilityCollector({ enabled: true, minImpact: 'minor' });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', []);
expect(result[0]!.severity).toBe('critical');
});
it('converts moderate violations to medium severity', async () => {
makeViolations([
{
id: 'label',
impact: 'moderate',
description: 'Form elements must have labels',
helpUrl: 'https://dequeuniversity.com/rules/axe/label',
nodes: [{}],
},
]);
const collector = new AccessibilityCollector({ enabled: true, minImpact: 'minor' });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', []);
expect(result[0]!.severity).toBe('medium');
});
it('filters out violations below minImpact', async () => {
makeViolations([
{
id: 'link-name',
impact: 'minor',
description: 'Links must have discernible text',
helpUrl: 'https://dequeuniversity.com/rules/axe/link-name',
nodes: [{}],
},
]);
// minImpact = 'serious' → minor is ignored
const collector = new AccessibilityCollector({ enabled: true, minImpact: 'serious' });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', []);
expect(result).toHaveLength(0);
});
it('returns multiple anomalies for multiple violations above threshold', async () => {
makeViolations([
{ id: 'v1', impact: 'serious', description: 'Violation 1', helpUrl: 'h1', nodes: [{}] },
{ id: 'v2', impact: 'critical', description: 'Violation 2', helpUrl: 'h2', nodes: [{}, {}] },
{ id: 'v3', impact: 'moderate', description: 'Violation 3', helpUrl: 'h3', nodes: [{}] },
]);
const collector = new AccessibilityCollector({ enabled: true, minImpact: 'moderate' });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', []);
expect(result).toHaveLength(3);
const severities = result.map((a) => a.severity);
expect(severities).toContain('high');
expect(severities).toContain('critical');
expect(severities).toContain('medium');
});
it('handles AxeBuilder failure gracefully (returns empty)', async () => {
analyzeMock.mockRejectedValue(new Error('axe internal error'));
// Also make the fallback page.evaluate fail
mockPage.evaluate.mockRejectedValue(new Error('page not available'));
const collector = new AccessibilityCollector({ enabled: true });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', []);
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(0);
});
it('includes rule id, node count, and helpUrl in evidence rawErrors', async () => {
makeViolations([
{
id: 'button-name',
impact: 'critical',
description: 'Buttons must have discernible text',
helpUrl: 'https://dequeuniversity.com/rules/axe/button-name',
nodes: [{}, {}, {}],
},
]);
const collector = new AccessibilityCollector({ enabled: true, minImpact: 'minor' });
const result = await collector.collect(mockPage as never, 'state1', 'sess1', []);
const evidence = result[0]!.evidence.rawErrors ?? [];
expect(evidence.some((e) => e.includes('button-name'))).toBe(true);
expect(evidence.some((e) => e.includes('3'))).toBe(true);
expect(evidence.some((e) => e.includes('dequeuniversity'))).toBe(true);
});
it('sets correct observationId and actionTrace on anomaly', async () => {
makeViolations([
{ id: 'v', impact: 'serious', description: 'Desc', helpUrl: 'h', nodes: [{}] },
]);
const actions = [makeAction()];
const collector = new AccessibilityCollector({ enabled: true, minImpact: 'minor' });
const result = await collector.collect(mockPage as never, 'my-state-id', 'sess1', actions);
expect(result[0]!.observationId).toBe('my-state-id');
expect(result[0]!.actionTrace).toBe(actions);
});
});

View File

@@ -0,0 +1,143 @@
/**
* PlaywrightAgent integration test.
* Uses a base64 data: URL so no external server is needed.
*/
import { PlaywrightAgent } from '../../../src/plugins/agents/PlaywrightAgent';
import { NullLogger } from '../../../src/core/Logger';
const HTML_CONTENT = `<!DOCTYPE html>
<html>
<head><title>Test Page</title></head>
<body>
<a href="#section" id="nav-link">Go to section</a>
<button id="submit-btn">Submit</button>
<input type="text" id="name-input" name="name" />
<input type="email" id="email-input" name="email" />
</body>
</html>`;
const TEST_URL = `data:text/html;base64,${Buffer.from(HTML_CONTENT).toString('base64')}`;
describe('PlaywrightAgent', () => {
jest.setTimeout(30000);
let agent: PlaywrightAgent;
beforeEach(() => {
agent = new PlaywrightAgent({ seed: 42, headless: true, logger: new NullLogger() });
});
afterEach(async () => {
await agent.close();
});
it('launches and captures initial state', async () => {
await agent.launch(TEST_URL);
const state = await agent.captureState();
expect(state.id).toBeTruthy();
expect(state.title).toBe('Test Page');
expect(state.domSnapshot).toContain('submit-btn');
expect(state.visitCount).toBe(0);
expect(typeof state.timestamp).toBe('number');
});
it('discovers clickable and fillable actions', async () => {
await agent.launch(TEST_URL);
const state = await agent.captureState();
const actions = await agent.discoverActions(state);
expect(actions.length).toBeGreaterThan(0);
const clicks = actions.filter((a) => a.type === 'click');
const fills = actions.filter((a) => a.type === 'fill');
expect(clicks.length).toBeGreaterThan(0);
expect(fills.length).toBeGreaterThan(0);
// All actions must have required fields
for (const action of actions) {
expect(action.id).toBeTruthy();
expect(action.seed).toBeDefined();
expect(action.stateId).toBe(state.id);
expect(action.timestamp).toBeGreaterThan(0);
}
});
it('executes a click action and returns an observation', async () => {
await agent.launch(TEST_URL);
const state = await agent.captureState();
const actions = await agent.discoverActions(state);
const clickAction = actions.find((a) => a.type === 'click');
expect(clickAction).toBeDefined();
const observation = await agent.executeAction(clickAction!);
expect(observation.id).toBeTruthy();
expect(observation.actionId).toBe(clickAction!.id);
expect(observation.newStateId).toBeTruthy();
expect(Array.isArray(observation.httpResponses)).toBe(true);
expect(Array.isArray(observation.consoleErrors)).toBe(true);
expect(Array.isArray(observation.jsExceptions)).toBe(true);
});
it('executes a fill action and returns an observation', async () => {
await agent.launch(TEST_URL);
const state = await agent.captureState();
const actions = await agent.discoverActions(state);
const fillAction = actions.find((a) => a.type === 'fill');
expect(fillAction).toBeDefined();
const observation = await agent.executeAction(fillAction!);
expect(observation.actionId).toBe(fillAction!.id);
expect(observation.jsExceptions).toHaveLength(0);
});
it('uses deterministic seed for action discovery (same seed = same order)', async () => {
await agent.launch(TEST_URL);
const state = await agent.captureState();
const actions1 = await agent.discoverActions(state);
// Skip if no actions found (page didn't load elements)
if (actions1.length === 0) return;
await agent.close();
const agent2 = new PlaywrightAgent({ seed: 42, headless: true, logger: new NullLogger() });
await agent2.launch(TEST_URL);
const state2 = await agent2.captureState();
const actions2 = await agent2.discoverActions(state2);
await agent2.close();
// Same seed → same seeds on actions in same order
const seeds1 = actions1.map((a) => a.seed);
const seeds2 = actions2.map((a) => a.seed);
expect(seeds1).toEqual(seeds2);
});
it('two instances with different seeds produce different action seeds', async () => {
const agent2 = new PlaywrightAgent({ seed: 99, headless: true, logger: new NullLogger() });
await agent.launch(TEST_URL);
await agent2.launch(TEST_URL);
const state1 = await agent.captureState();
const state2 = await agent2.captureState();
const actions1 = await agent.discoverActions(state1);
const actions2 = await agent2.discoverActions(state2);
await agent2.close();
// Only test if actions were found
if (actions1.length === 0) {
// If no actions, pass — the test validates seed differences when actions exist
return;
}
const seeds1 = actions1.map((a) => a.seed);
const seeds2 = actions2.map((a) => a.seed);
expect(seeds1).not.toEqual(seeds2);
});
});

View File

@@ -0,0 +1,102 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { NetworkCollector } from '../../src/plugins/collectors/NetworkCollector';
import { DOMSnapshotCollector } from '../../src/plugins/collectors/DOMSnapshotCollector';
import { IAnomaly, IAction } from '../../src/core/interfaces';
import { IInteractionAgent } from '../../src/plugins/interfaces';
function makeAnomaly(id = 'anom-001'): IAnomaly {
return {
id,
type: 'http_error',
severity: 'high',
observationId: 'obs-1',
actionTrace: [] as IAction[],
description: 'Test anomaly',
evidence: {
httpLog: [{ url: '/api', status: 500, method: 'POST', durationMs: 100 }],
},
timestamp: Date.now(),
};
}
function makeMockAgent(domSnapshot = '<body><p>hello</p></body>'): IInteractionAgent {
return {
launch: jest.fn(),
close: jest.fn(),
discoverActions: jest.fn(),
executeAction: jest.fn(),
captureState: jest.fn().mockResolvedValue({
id: 'state-1',
url: 'http://localhost/',
title: 'Test',
timestamp: Date.now(),
domSnapshot,
visitCount: 0,
}),
};
}
describe('NetworkCollector', () => {
it('returns httpLog from anomaly evidence', async () => {
const collector = new NetworkCollector();
const anomaly = makeAnomaly();
const agent = makeMockAgent();
const evidence = await collector.collect(anomaly, agent);
expect(evidence.httpLog).toHaveLength(1);
expect(evidence.httpLog![0].status).toBe(500);
});
it('returns empty httpLog when none present', async () => {
const collector = new NetworkCollector();
const anomaly: IAnomaly = { ...makeAnomaly(), evidence: {} };
const agent = makeMockAgent();
const evidence = await collector.collect(anomaly, agent);
expect(evidence.httpLog).toEqual([]);
});
it('has correct name', () => {
expect(new NetworkCollector().name).toBe('NetworkCollector');
});
});
describe('DOMSnapshotCollector', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abe-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('writes DOM snapshot to disk', async () => {
const collector = new DOMSnapshotCollector(tmpDir);
const anomaly = makeAnomaly('anom-dom');
const agent = makeMockAgent('<body><h1>Snapshot</h1></body>');
const evidence = await collector.collect(anomaly, agent);
expect(evidence.domSnapshotPath).toBeTruthy();
const fullPath = path.join(tmpDir, evidence.domSnapshotPath!);
expect(fs.existsSync(fullPath)).toBe(true);
const content = fs.readFileSync(fullPath, 'utf8');
expect(content).toContain('<h1>Snapshot</h1>');
});
it('creates output directory if it does not exist', async () => {
const nestedDir = path.join(tmpDir, 'nested', 'output');
const collector = new DOMSnapshotCollector(nestedDir);
const anomaly = makeAnomaly('anom-nested');
const agent = makeMockAgent();
await collector.collect(anomaly, agent);
expect(fs.existsSync(path.join(nestedDir, 'anom-nested'))).toBe(true);
});
it('has correct name', () => {
expect(new DOMSnapshotCollector().name).toBe('DOMSnapshotCollector');
});
});

View File

@@ -0,0 +1,108 @@
/**
* Unit tests for scope enforcement and auth in PlaywrightAgent.
* These tests use the private helper methods indirectly via the agent's behavior
* by testing the isExcludedPath, isExternalLink, and isAllowedUrl logic
* through subclassing or direct method exposure.
*
* Since these methods are private, we test observable behavior via
* discoverActions and executeAction with mock pages or just verify config logic.
*/
import { ExplorationConfig, DEFAULT_EXPLORATION_CONFIG } from '../../src/core/ExplorationConfig';
describe('ExplorationConfig', () => {
it('has sensible defaults', () => {
const cfg = { ...DEFAULT_EXPLORATION_CONFIG };
expect(cfg.maxStates).toBe(50);
expect(cfg.maxDepth).toBe(5);
expect(cfg.actionDelayMs).toBe(500);
expect(cfg.sessionTimeoutMs).toBe(300000);
expect(cfg.fuzzingEnabled).toBe(true);
expect(cfg.fuzzingIntensity).toBe('medium');
expect(cfg.auth).toBeNull();
expect(cfg.excludedPaths).toEqual([]);
expect(cfg.excludedSelectors).toEqual([]);
});
it('accepts cookies auth config', () => {
const config: ExplorationConfig = {
...DEFAULT_EXPLORATION_CONFIG,
auth: {
type: 'cookies',
cookies: [{ name: 'session', value: 'abc', domain: 'localhost' }],
},
};
expect(config.auth?.type).toBe('cookies');
});
it('accepts headers auth config', () => {
const config: ExplorationConfig = {
...DEFAULT_EXPLORATION_CONFIG,
auth: {
type: 'headers',
headers: { Authorization: 'Bearer token123' },
},
};
expect(config.auth?.type).toBe('headers');
});
it('accepts login_flow auth config', () => {
const config: ExplorationConfig = {
...DEFAULT_EXPLORATION_CONFIG,
auth: {
type: 'login_flow',
loginUrl: 'http://app.com/login',
usernameSelector: 'input[name="email"]',
passwordSelector: 'input[name="password"]',
submitSelector: 'button[type="submit"]',
username: 'user@test.com',
password: 'secret',
},
};
expect(config.auth?.type).toBe('login_flow');
});
});
// Helper to test URL-based scope rules (extracted for testability)
describe('Scope URL rules', () => {
function isExcludedPath(urlOrPath: string, excludedPaths: string[]): boolean {
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;
}
}
function isExternalLink(href: string, currentUrl: string, allowedDomains: string[]): boolean {
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;
}
}
it('excludes paths correctly', () => {
expect(isExcludedPath('http://app.com/logout', ['/logout'])).toBe(true);
expect(isExcludedPath('http://app.com/home', ['/logout'])).toBe(false);
expect(isExcludedPath('http://app.com/admin/users', ['/admin'])).toBe(true);
});
it('allows paths when no exclusions', () => {
expect(isExcludedPath('http://app.com/logout', [])).toBe(false);
});
it('detects external links', () => {
expect(isExternalLink('http://external.com/page', 'http://myapp.com', ['myapp.com'])).toBe(true);
expect(isExternalLink('/page', 'http://myapp.com', ['myapp.com'])).toBe(false);
expect(isExternalLink('http://myapp.com/page', 'http://myapp.com', ['myapp.com'])).toBe(false);
});
it('allows all links when no allowedDomains', () => {
expect(isExternalLink('http://external.com/page', 'http://myapp.com', [])).toBe(false);
});
});

View File

@@ -0,0 +1,111 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { JSONExporter } from '../../../src/plugins/exporters/JSONExporter';
import { IAnomaly, IAction } from '../../../src/core/interfaces';
function makeAnomaly(id = 'anom-001'): IAnomaly {
const action: IAction = {
id: 'act-1',
type: 'click',
selector: '#submit',
timestamp: 1700000000000,
seed: 42,
stateId: 's1',
};
return {
id,
type: 'http_error',
severity: 'high',
observationId: 'obs-1',
actionTrace: [action],
description: 'HTTP 500 on form submit',
evidence: {
httpLog: [{ url: '/api/register', status: 500, method: 'POST', durationMs: 234 }],
rawErrors: ['POST /api/register → 500'],
},
timestamp: 1700000000000,
};
}
describe('JSONExporter', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abe-json-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('creates report.json in the output directory', async () => {
const exporter = new JSONExporter('http://localhost:3000');
const outputDir = path.join(tmpDir, 'anom-001');
const filePath = await exporter.export(makeAnomaly(), outputDir);
expect(fs.existsSync(filePath)).toBe(true);
expect(filePath.endsWith('report.json')).toBe(true);
});
it('produces valid JSON', async () => {
const exporter = new JSONExporter('http://localhost:3000');
const outputDir = path.join(tmpDir, 'anom-001');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(() => JSON.parse(content)).not.toThrow();
});
it('includes all required top-level fields', async () => {
const exporter = new JSONExporter('http://localhost:3000');
const outputDir = path.join(tmpDir, 'anom-001');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const report = JSON.parse(fs.readFileSync(filePath, 'utf8'));
expect(report.version).toBe('1.0');
expect(report.generated_at).toBeTruthy();
expect(report.environment).toBeDefined();
expect(report.anomaly).toBeDefined();
expect(report.reproduction).toBeDefined();
expect(report.evidence).toBeDefined();
});
it('includes anomaly details', async () => {
const exporter = new JSONExporter();
const anomaly = makeAnomaly('test-id');
const outputDir = path.join(tmpDir, 'test-id');
const filePath = await exporter.export(anomaly, outputDir);
const report = JSON.parse(fs.readFileSync(filePath, 'utf8'));
expect(report.anomaly.id).toBe('test-id');
expect(report.anomaly.type).toBe('http_error');
expect(report.anomaly.severity).toBe('high');
});
it('includes reproduction steps', async () => {
const exporter = new JSONExporter();
const outputDir = path.join(tmpDir, 'steps-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const report = JSON.parse(fs.readFileSync(filePath, 'utf8'));
expect(report.reproduction.steps).toHaveLength(1);
expect(report.reproduction.steps[0].step).toBe(1);
expect(report.reproduction.steps[0].action_type).toBe('click');
});
it('includes HTTP log in evidence', async () => {
const exporter = new JSONExporter();
const outputDir = path.join(tmpDir, 'http-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const report = JSON.parse(fs.readFileSync(filePath, 'utf8'));
expect(report.evidence.http_log).toHaveLength(1);
expect(report.evidence.http_log[0].status).toBe(500);
});
it('has format = json', () => {
expect(new JSONExporter().format).toBe('json');
});
it('creates output directory if it does not exist', async () => {
const exporter = new JSONExporter();
const nestedDir = path.join(tmpDir, 'nested', 'deep', 'anom-001');
await exporter.export(makeAnomaly(), nestedDir);
expect(fs.existsSync(nestedDir)).toBe(true);
});
});

View File

@@ -0,0 +1,133 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { MarkdownExporter } from '../../../src/plugins/exporters/MarkdownExporter';
import { IAnomaly, IAction } from '../../../src/core/interfaces';
function makeAnomaly(overrides: Partial<IAnomaly> = {}): IAnomaly {
const action: IAction = {
id: 'act-1',
type: 'navigate',
url: 'http://localhost:3000/register',
timestamp: 1700000000000,
seed: 42,
stateId: 's1',
};
return {
id: 'anom-md-001',
type: 'http_error',
severity: 'high',
observationId: 'obs-1',
actionTrace: [action],
description: 'Form submission returns HTTP 500 on empty email field',
evidence: {
screenshotPath: 'anom-md-001/screenshot.png',
domSnapshotPath: 'anom-md-001/dom.html',
httpLog: [{ url: '/api/register', status: 500, method: 'POST', durationMs: 234 }],
rawErrors: ['POST /api/register → 500 (234ms)'],
},
timestamp: 1700000000000,
...overrides,
};
}
describe('MarkdownExporter', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abe-md-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('creates report.md in the output directory', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'anom-md-001');
const filePath = await exporter.export(makeAnomaly(), outputDir);
expect(fs.existsSync(filePath)).toBe(true);
expect(filePath.endsWith('report.md')).toBe(true);
});
it('includes anomaly type and date in title', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'title-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(content).toContain('# Bug Report');
expect(content).toContain('http_error');
});
it('includes severity section', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'severity-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(content).toContain('## Severity');
expect(content).toContain('high');
});
it('includes reproduction steps with navigate action', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'steps-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(content).toContain('## Reproduction Steps');
expect(content).toContain('Navigate to');
expect(content).toContain('http://localhost:3000/register');
});
it('includes seed and replay command', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'seed-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(content).toContain('Seed used');
expect(content).toContain('42');
expect(content).toContain('Replay command');
expect(content).toContain('npm run replay');
});
it('includes evidence section with screenshot and dom paths', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'evidence-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(content).toContain('## Evidence');
expect(content).toContain('screenshot.png');
expect(content).toContain('dom.html');
});
it('includes HTTP table when responses present', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'http-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(content).toContain('500');
expect(content).toContain('/api/register');
expect(content).toContain('POST');
});
it('includes raw errors section', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'errors-test');
const filePath = await exporter.export(makeAnomaly(), outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(content).toContain('## Raw Errors');
expect(content).toContain('POST /api/register');
});
it('has format = markdown', () => {
expect(new MarkdownExporter().format).toBe('markdown');
});
it('handles anomaly with no action trace', async () => {
const exporter = new MarkdownExporter();
const outputDir = path.join(tmpDir, 'no-trace-test');
const anomaly = makeAnomaly({ actionTrace: [] });
const filePath = await exporter.export(anomaly, outputDir);
const content = fs.readFileSync(filePath, 'utf8');
expect(content).toContain('No steps recorded');
});
});

View File

@@ -0,0 +1,246 @@
/**
* Unit tests for fuzzing strategies and FuzzingEngine.
*/
import { detectInputType } from '../../src/plugins/fuzzers/InputTypeDetector';
import { EmptyValueStrategy } from '../../src/plugins/fuzzers/strategies/EmptyValueStrategy';
import { OversizedStringStrategy } from '../../src/plugins/fuzzers/strategies/OversizedStringStrategy';
import { SpecialCharsStrategy } from '../../src/plugins/fuzzers/strategies/SpecialCharsStrategy';
import { TypeMismatchStrategy } from '../../src/plugins/fuzzers/strategies/TypeMismatchStrategy';
import { BoundaryValueStrategy } from '../../src/plugins/fuzzers/strategies/BoundaryValueStrategy';
import { FuzzingEngine } from '../../src/plugins/fuzzers/FuzzingEngine';
import { IState } from '../../src/core/interfaces';
function makeState(domSnapshot = ''): IState {
return {
id: 'state1',
url: 'http://test.com',
title: 'Test',
timestamp: Date.now(),
domSnapshot,
visitCount: 1,
};
}
// ─── InputTypeDetector ────────────────────────────────────────────────────────
describe('detectInputType', () => {
it('detects email from inputType', () => {
expect(detectInputType({ inputType: 'email' })).toBe('email');
});
it('detects password from inputType', () => {
expect(detectInputType({ inputType: 'password' })).toBe('password');
});
it('detects number from inputType', () => {
expect(detectInputType({ inputType: 'number' })).toBe('number');
});
it('detects email from name attribute', () => {
expect(detectInputType({ name: 'email_address' })).toBe('email');
});
it('detects phone from placeholder', () => {
expect(detectInputType({ placeholder: 'Enter phone number' })).toBe('phone');
});
it('detects textarea from tagName', () => {
expect(detectInputType({ tagName: 'textarea' })).toBe('textarea');
});
it('falls back to text for unknown', () => {
expect(detectInputType({})).toBe('text');
});
});
// ─── EmptyValueStrategy ───────────────────────────────────────────────────────
describe('EmptyValueStrategy', () => {
const strategy = new EmptyValueStrategy();
it('applies to all types', () => {
expect(strategy.appliesTo('email')).toBe(true);
expect(strategy.appliesTo('number')).toBe(true);
expect(strategy.appliesTo('text')).toBe(true);
});
it('returns empty/whitespace values', () => {
expect(strategy.values()).toContain('');
expect(strategy.values()).toContain(' ');
expect(strategy.values()).toContain('\t');
});
});
// ─── OversizedStringStrategy ──────────────────────────────────────────────────
describe('OversizedStringStrategy', () => {
it('applies to text types', () => {
const s = new OversizedStringStrategy('medium');
expect(s.appliesTo('text')).toBe(true);
expect(s.appliesTo('email')).toBe(true);
expect(s.appliesTo('number')).toBe(false);
});
it('returns low-intensity 256 chars', () => {
const s = new OversizedStringStrategy('low');
expect(s.values()[0]?.length).toBe(256);
});
it('returns medium-intensity 1024 chars', () => {
const s = new OversizedStringStrategy('medium');
expect(s.values()[0]?.length).toBe(1024);
});
it('returns high-intensity 10000+ chars', () => {
const s = new OversizedStringStrategy('high');
expect(s.values()[0]!.length).toBeGreaterThan(10000);
});
});
// ─── SpecialCharsStrategy ─────────────────────────────────────────────────────
describe('SpecialCharsStrategy', () => {
const s = new SpecialCharsStrategy();
it('applies to text, email, search, textarea', () => {
expect(s.appliesTo('text')).toBe(true);
expect(s.appliesTo('email')).toBe(true);
expect(s.appliesTo('number')).toBe(false);
});
it('includes SQL injection payload', () => {
expect(s.values()).toContain("' OR 1=1 --");
});
it('includes XSS payload', () => {
expect(s.values()).toContain('<script>alert(1)</script>');
});
});
// ─── TypeMismatchStrategy ─────────────────────────────────────────────────────
describe('TypeMismatchStrategy', () => {
const s = new TypeMismatchStrategy();
it('applies to typed fields', () => {
expect(s.appliesTo('email')).toBe(true);
expect(s.appliesTo('number')).toBe(true);
expect(s.appliesTo('text')).toBe(false);
});
it('returns mismatched values for email', () => {
expect(s.values('email')).toContain('not-an-email');
});
it('returns mismatched values for number', () => {
expect(s.values('number')).toContain('abc');
});
it('returns empty for unhandled type', () => {
expect(s.values('text')).toEqual([]);
});
});
// ─── BoundaryValueStrategy ────────────────────────────────────────────────────
describe('BoundaryValueStrategy', () => {
const s = new BoundaryValueStrategy();
it('applies to number and date', () => {
expect(s.appliesTo('number')).toBe(true);
expect(s.appliesTo('date')).toBe(true);
expect(s.appliesTo('text')).toBe(false);
});
it('returns boundary numbers', () => {
expect(s.values('number')).toContain('0');
expect(s.values('number')).toContain('2147483647');
});
it('returns boundary dates', () => {
expect(s.values('date')).toContain('1900-01-01');
});
});
// ─── FuzzingEngine ────────────────────────────────────────────────────────────
describe('FuzzingEngine', () => {
it('generates actions from DOM snapshot with input fields', () => {
const engine = new FuzzingEngine({ intensity: 'low', seed: 42 });
const dom = `<form><input type="email" name="email" /><input type="password" name="pass" /></form>`;
const state = makeState(dom);
const actions = engine.generateFuzzActions(dom, state);
expect(actions.length).toBeGreaterThan(0);
expect(actions.every((a) => a.type === 'fill')).toBe(true);
expect(actions.every((a) => a.stateId === 'state1')).toBe(true);
});
it('generates more actions at high intensity', () => {
const low = new FuzzingEngine({ intensity: 'low', seed: 1 });
const high = new FuzzingEngine({ intensity: 'high', seed: 1 });
const dom = `<input type="text" name="q" />`;
const state = makeState(dom);
expect(high.generateFuzzActions(dom, state).length).toBeGreaterThan(
low.generateFuzzActions(dom, state).length
);
});
it('returns empty array for DOM with no inputs', () => {
const engine = new FuzzingEngine({ intensity: 'medium', seed: 1 });
const dom = `<div><p>No forms here</p></div>`;
const state = makeState(dom);
expect(engine.generateFuzzActions(dom, state)).toHaveLength(0);
});
});
// ─── AnomalyDetector fuzzing rules ────────────────────────────────────────────
describe('AnomalyDetector fuzzing anomaly types', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { AnomalyDetector } = require('../../src/core/AnomalyDetector');
const detector = new AnomalyDetector();
const baseObs = {
id: 'obs1',
actionId: 'act1',
newStateId: 's1',
httpResponses: [],
consoleErrors: [],
jsExceptions: [],
timestamp: Date.now(),
};
it('detects validation_bypass on 200 response to empty input', () => {
const obs = { ...baseObs, httpResponses: [{ url: '/', status: 200, method: 'POST', durationMs: 10 }] };
const result = detector.checkValidationBypass(obs, [], '');
expect(result).not.toBeNull();
expect(result!.type).toBe('validation_bypass');
});
it('does not detect validation_bypass without 2xx', () => {
const obs = { ...baseObs, httpResponses: [{ url: '/', status: 400, method: 'POST', durationMs: 10 }] };
const result = detector.checkValidationBypass(obs, [], '');
expect(result).toBeNull();
});
it('detects server_error_on_fuzz on 500', () => {
const obs = { ...baseObs, httpResponses: [{ url: '/', status: 500, method: 'POST', durationMs: 10 }] };
const result = detector.checkServerErrorOnFuzz(obs, []);
expect(result).not.toBeNull();
expect(result!.type).toBe('server_error_on_fuzz');
expect(result!.severity).toBe('high');
});
it('detects xss_reflection when script tag in DOM', () => {
const result = detector.checkXssReflection(baseObs, [], '<script>alert(1)</script>');
expect(result).not.toBeNull();
expect(result!.type).toBe('xss_reflection');
expect(result!.severity).toBe('critical');
});
it('does not detect xss_reflection without payload in DOM', () => {
const result = detector.checkXssReflection(baseObs, [], '<div>clean</div>');
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,289 @@
/**
* Tests for network chaos: applyNetworkCondition (via PlaywrightAgent private method)
* and route interception for blocked/slow endpoints.
*
* Since applyNetworkChaos is private, we test it indirectly via PlaywrightAgent
* by mocking the CDP session and route handler.
*/
import { NETWORK_PROFILES } from '../../src/core/ExplorationConfig';
// ─── NETWORK_PROFILES constants ───────────────────────────────────────────────
describe('NETWORK_PROFILES', () => {
it('defines fast-3g profile', () => {
const p = NETWORK_PROFILES['fast-3g'];
expect(p).toBeDefined();
expect(p!.offline).toBe(false);
expect(p!.downloadKbps).toBe(1500);
expect(p!.latencyMs).toBe(40);
});
it('defines slow-3g profile', () => {
const p = NETWORK_PROFILES['slow-3g'];
expect(p).toBeDefined();
expect(p!.downloadKbps).toBe(400);
expect(p!.latencyMs).toBe(400);
});
it('defines 2g profile', () => {
const p = NETWORK_PROFILES['2g'];
expect(p).toBeDefined();
expect(p!.downloadKbps).toBe(50);
expect(p!.latencyMs).toBe(800);
});
it('defines offline profile', () => {
const p = NETWORK_PROFILES['offline'];
expect(p).toBeDefined();
expect(p!.offline).toBe(true);
expect(p!.downloadKbps).toBe(0);
});
it('returns null for "none" profile', () => {
expect(NETWORK_PROFILES['none']).toBeNull();
});
it('covers all expected profile keys', () => {
const expected = ['fast-3g', 'slow-3g', '2g', 'offline', 'none'] as const;
for (const key of expected) {
expect(NETWORK_PROFILES).toHaveProperty(key);
}
});
});
// ─── applyNetworkCondition via mocked CDP ─────────────────────────────────────
describe('PlaywrightAgent network chaos via CDP mock', () => {
const cdpSendMock = jest.fn().mockResolvedValue(undefined);
const routeMock = jest.fn().mockResolvedValue(undefined);
const newCDPSessionMock = jest.fn().mockResolvedValue({ send: cdpSendMock });
function buildMockContext() {
return { newCDPSession: newCDPSessionMock };
}
function buildMockPage(url = 'http://localhost') {
return {
url: () => url,
route: routeMock,
goto: jest.fn().mockResolvedValue(undefined),
waitForTimeout: jest.fn().mockResolvedValue(undefined),
evaluate: jest.fn().mockResolvedValue('<body></body>'),
title: jest.fn().mockResolvedValue('Test'),
on: jest.fn(),
};
}
beforeEach(() => {
cdpSendMock.mockClear();
routeMock.mockClear();
newCDPSessionMock.mockClear();
});
it('calls CDP Network.emulateNetworkConditions for fast-3g', async () => {
const { PlaywrightAgent } = await import('../../src/plugins/agents/PlaywrightAgent');
const agent = new PlaywrightAgent({
explorationConfig: {
networkChaos: {
enabled: true,
profile: 'fast-3g',
blockedEndpoints: [],
slowEndpoints: [],
},
},
});
// Inject mocked internals
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(agent as any).context = buildMockContext();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const page = buildMockPage();
// Call applyNetworkChaos via a private method trick
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (agent as any).applyNetworkChaos(page);
expect(newCDPSessionMock).toHaveBeenCalledWith(page);
expect(cdpSendMock).toHaveBeenCalledWith('Network.emulateNetworkConditions', expect.objectContaining({
offline: false,
latency: 40,
}));
});
it('calls CDP with offline:true for offline profile', async () => {
const { PlaywrightAgent } = await import('../../src/plugins/agents/PlaywrightAgent');
const agent = new PlaywrightAgent({
explorationConfig: {
networkChaos: {
enabled: true,
profile: 'offline',
blockedEndpoints: [],
slowEndpoints: [],
},
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(agent as any).context = buildMockContext();
const page = buildMockPage();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (agent as any).applyNetworkChaos(page);
expect(cdpSendMock).toHaveBeenCalledWith('Network.emulateNetworkConditions', expect.objectContaining({
offline: true,
downloadThroughput: -1,
uploadThroughput: -1,
}));
});
it('sets up route interception for blocked endpoints', async () => {
const { PlaywrightAgent } = await import('../../src/plugins/agents/PlaywrightAgent');
const agent = new PlaywrightAgent({
explorationConfig: {
networkChaos: {
enabled: true,
profile: 'none',
blockedEndpoints: ['*/api/analytics'],
slowEndpoints: [],
},
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(agent as any).context = buildMockContext();
const page = buildMockPage();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (agent as any).applyNetworkChaos(page);
expect(routeMock).toHaveBeenCalledWith('**/*', expect.any(Function));
});
it('sets up route interception for slow endpoints', async () => {
const { PlaywrightAgent } = await import('../../src/plugins/agents/PlaywrightAgent');
const agent = new PlaywrightAgent({
explorationConfig: {
networkChaos: {
enabled: true,
profile: 'none',
blockedEndpoints: [],
slowEndpoints: [{ pattern: '*/api/slow', delayMs: 2000 }],
},
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(agent as any).context = buildMockContext();
const page = buildMockPage();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (agent as any).applyNetworkChaos(page);
expect(routeMock).toHaveBeenCalledWith('*/api/slow', expect.any(Function));
});
it('does nothing when networkChaos is disabled', async () => {
const { PlaywrightAgent } = await import('../../src/plugins/agents/PlaywrightAgent');
const agent = new PlaywrightAgent({
explorationConfig: {
networkChaos: { enabled: false, profile: 'fast-3g', blockedEndpoints: [], slowEndpoints: [] },
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(agent as any).context = buildMockContext();
const page = buildMockPage();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (agent as any).applyNetworkChaos(page);
expect(cdpSendMock).not.toHaveBeenCalled();
expect(routeMock).not.toHaveBeenCalled();
});
it('fulfills with 503 for requests matching blocked endpoint patterns', async () => {
const { PlaywrightAgent } = await import('../../src/plugins/agents/PlaywrightAgent');
const agent = new PlaywrightAgent({
explorationConfig: {
networkChaos: {
enabled: true,
profile: 'none',
blockedEndpoints: ['*/api/blocked'],
slowEndpoints: [],
},
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(agent as any).context = buildMockContext();
// Capture the route handler
let capturedHandler: ((route: unknown) => void) | null = null;
const page = {
...buildMockPage(),
route: jest.fn().mockImplementation((_pattern: unknown, handler: (r: unknown) => void) => {
capturedHandler = handler;
}),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (agent as any).applyNetworkChaos(page);
expect(capturedHandler).not.toBeNull();
// Simulate a request to a blocked URL
const fulfillMock = jest.fn();
const continueMock = jest.fn();
const blockedRoute = {
request: () => ({ url: () => 'http://example.com/api/blocked' }),
fulfill: fulfillMock,
continue: continueMock,
};
capturedHandler!(blockedRoute);
expect(fulfillMock).toHaveBeenCalledWith(expect.objectContaining({ status: 503 }));
expect(continueMock).not.toHaveBeenCalled();
});
it('continues requests that do not match blocked patterns', async () => {
const { PlaywrightAgent } = await import('../../src/plugins/agents/PlaywrightAgent');
const agent = new PlaywrightAgent({
explorationConfig: {
networkChaos: {
enabled: true,
profile: 'none',
blockedEndpoints: ['*/api/blocked'],
slowEndpoints: [],
},
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(agent as any).context = buildMockContext();
let capturedHandler: ((route: unknown) => void) | null = null;
const page = {
...buildMockPage(),
route: jest.fn().mockImplementation((_pattern: unknown, handler: (r: unknown) => void) => {
capturedHandler = handler;
}),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (agent as any).applyNetworkChaos(page);
const fulfillMock = jest.fn();
const continueMock = jest.fn();
const allowedRoute = {
request: () => ({ url: () => 'http://example.com/api/users' }),
fulfill: fulfillMock,
continue: continueMock,
};
capturedHandler!(allowedRoute);
expect(continueMock).toHaveBeenCalled();
expect(fulfillMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,185 @@
/**
* Tests for PerformanceCollector with mocked page.evaluate.
*/
import { PerformanceCollector, DEFAULT_PERF_CONFIG } from '../../src/plugins/collectors/PerformanceCollector';
import type { IAction } from '../../src/core/interfaces';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeAction(): IAction {
return { id: 'a1', type: 'click', timestamp: Date.now(), seed: 42, stateId: 'state1' };
}
function makePage(timing = { ttfb: 100, domContentLoaded: 500, loadComplete: 1000 }, vitals = { lcp: null as number | null, cls: null as number | null, inp: null as number | null }) {
return {
url: () => 'http://localhost:3000/page',
evaluate: jest.fn()
.mockResolvedValueOnce(timing)
.mockResolvedValueOnce(vitals),
};
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('PerformanceCollector — disabled', () => {
it('returns zeroed metrics and no anomalies when disabled', async () => {
const collector = new PerformanceCollector({ enabled: false });
const page = makePage();
const { metrics, anomalies } = await collector.collect(page as never, 'state1', 'sess1', []);
expect(anomalies).toHaveLength(0);
expect(metrics.ttfb).toBe(0);
expect(metrics.lcp).toBeNull();
expect(page.evaluate).not.toHaveBeenCalled();
});
});
describe('PerformanceCollector — enabled', () => {
it('captures timing metrics correctly', async () => {
const collector = new PerformanceCollector({ enabled: true });
const page = makePage(
{ ttfb: 200, domContentLoaded: 800, loadComplete: 1500 },
{ lcp: null, cls: null, inp: null }
);
const { metrics } = await collector.collect(page as never, 'state1', 'sess1', [makeAction()]);
expect(metrics.ttfb).toBe(200);
expect(metrics.domContentLoaded).toBe(800);
expect(metrics.loadComplete).toBe(1500);
expect(metrics.sessionId).toBe('sess1');
expect(metrics.stateId).toBe('state1');
expect(metrics.url).toBe('http://localhost:3000/page');
});
it('captures Core Web Vitals when available', async () => {
const collector = new PerformanceCollector({ enabled: true });
const page = makePage(
{ ttfb: 100, domContentLoaded: 400, loadComplete: 900 },
{ lcp: 1800, cls: 0.05, inp: 120 }
);
const { metrics } = await collector.collect(page as never, 'state1', 'sess1', []);
expect(metrics.lcp).toBe(1800);
expect(metrics.cls).toBe(0.05);
expect(metrics.inp).toBe(120);
});
it('returns no anomalies when all metrics are within thresholds', async () => {
const collector = new PerformanceCollector({
enabled: true,
lcpThresholdMs: 4000,
clsThreshold: 0.25,
inpThresholdMs: 500,
ttfbThresholdMs: 1800,
});
const page = makePage(
{ ttfb: 200, domContentLoaded: 500, loadComplete: 1000 },
{ lcp: 1500, cls: 0.05, inp: 100 }
);
const { anomalies } = await collector.collect(page as never, 'state1', 'sess1', []);
expect(anomalies).toHaveLength(0);
});
it('detects LCP violation above threshold', async () => {
const collector = new PerformanceCollector({
enabled: true,
lcpThresholdMs: 2500,
clsThreshold: 0.25,
inpThresholdMs: 500,
ttfbThresholdMs: 1800,
});
const page = makePage(
{ ttfb: 200, domContentLoaded: 600, loadComplete: 1200 },
{ lcp: 5000, cls: 0.01, inp: 50 }
);
const { anomalies } = await collector.collect(page as never, 'state1', 'sess1', [makeAction()]);
expect(anomalies).toHaveLength(1);
expect(anomalies[0]!.type).toBe('performance_degradation');
expect(anomalies[0]!.severity).toBe('high');
expect(anomalies[0]!.description).toContain('LCP');
expect(anomalies[0]!.evidence.rawErrors).toEqual(
expect.arrayContaining([expect.stringContaining('LCP')])
);
});
it('detects TTFB violation above threshold', async () => {
const collector = new PerformanceCollector({
enabled: true,
lcpThresholdMs: 4000,
clsThreshold: 0.25,
inpThresholdMs: 500,
ttfbThresholdMs: 800,
});
const page = makePage(
{ ttfb: 2000, domContentLoaded: 2500, loadComplete: 4000 },
{ lcp: null, cls: null, inp: null }
);
const { anomalies } = await collector.collect(page as never, 'state1', 'sess1', []);
expect(anomalies).toHaveLength(1);
expect(anomalies[0]!.type).toBe('performance_degradation');
expect(anomalies[0]!.evidence.rawErrors).toEqual(
expect.arrayContaining([expect.stringContaining('TTFB')])
);
});
it('detects CLS violation', async () => {
const collector = new PerformanceCollector({
enabled: true,
lcpThresholdMs: 4000,
clsThreshold: 0.1,
inpThresholdMs: 500,
ttfbThresholdMs: 1800,
});
const page = makePage(
{ ttfb: 200, domContentLoaded: 500, loadComplete: 1000 },
{ lcp: null, cls: 0.35, inp: null }
);
const { anomalies } = await collector.collect(page as never, 'state1', 'sess1', []);
expect(anomalies).toHaveLength(1);
const rawErrors = anomalies[0]!.evidence.rawErrors ?? [];
expect(rawErrors.some((e) => e.includes('CLS'))).toBe(true);
});
it('accumulates metrics in getMetrics()', async () => {
const collector = new PerformanceCollector({ enabled: true });
const page = makePage(
{ ttfb: 100, domContentLoaded: 400, loadComplete: 800 },
{ lcp: null, cls: null, inp: null }
);
await collector.collect(page as never, 'state1', 'sess1', []);
const page2 = makePage(
{ ttfb: 150, domContentLoaded: 450, loadComplete: 900 },
{ lcp: 2000, cls: null, inp: null }
);
await collector.collect(page2 as never, 'state2', 'sess1', []);
expect(collector.getMetrics()).toHaveLength(2);
});
it('handles page.evaluate failure gracefully', async () => {
const collector = new PerformanceCollector({ enabled: true });
const page = {
url: () => 'http://localhost',
evaluate: jest.fn().mockRejectedValue(new Error('page disconnected')),
};
const { metrics, anomalies } = await collector.collect(page as never, 's1', 'sess1', []);
expect(metrics.ttfb).toBe(0);
expect(anomalies).toHaveLength(0);
});
it('uses default thresholds matching DEFAULT_PERF_CONFIG', () => {
expect(DEFAULT_PERF_CONFIG.lcpThresholdMs).toBe(4000);
expect(DEFAULT_PERF_CONFIG.clsThreshold).toBe(0.25);
expect(DEFAULT_PERF_CONFIG.inpThresholdMs).toBe(500);
expect(DEFAULT_PERF_CONFIG.ttfbThresholdMs).toBe(1800);
});
});

View File

@@ -0,0 +1,109 @@
import { PlaywrightReproducer } from '../../src/plugins/reproducers/PlaywrightReproducer';
import { IAction } from '../../src/core/interfaces';
function makeAction(id: string, type: IAction['type'], overrides: Partial<IAction> = {}): IAction {
return {
id,
type,
selector: '#btn',
timestamp: 1000,
seed: 42,
stateId: 's1',
...overrides,
};
}
describe('PlaywrightReproducer', () => {
let reproducer: PlaywrightReproducer;
beforeEach(() => {
reproducer = new PlaywrightReproducer();
});
describe('serialize', () => {
it('serializes an action trace to valid JSON', () => {
const trace = [makeAction('a1', 'click'), makeAction('a2', 'fill', { value: 'test' })];
const json = reproducer.serialize(trace);
expect(() => JSON.parse(json)).not.toThrow();
});
it('round-trips through serialize/deserialize', () => {
const trace = [
makeAction('a1', 'navigate', { url: 'http://localhost/', selector: undefined }),
makeAction('a2', 'click'),
makeAction('a3', 'fill', { value: '' }),
];
const json = reproducer.serialize(trace);
const restored = reproducer.deserialize(json);
expect(restored).toHaveLength(3);
expect(restored[0].id).toBe('a1');
expect(restored[1].type).toBe('click');
expect(restored[2].value).toBe('');
});
it('serializes empty trace', () => {
expect(reproducer.serialize([])).toBe('[]');
});
});
describe('deserialize', () => {
it('throws on invalid JSON', () => {
expect(() => reproducer.deserialize('not-json')).toThrow();
});
it('throws when JSON is not an array', () => {
expect(() => reproducer.deserialize('{"id":"a1"}')).toThrow(
'PlaywrightReproducer.deserialize: expected a JSON array'
);
});
it('preserves all action fields', () => {
const action = makeAction('a1', 'fill', { value: 'hello', selector: 'input#email' });
const restored = reproducer.deserialize(reproducer.serialize([action]));
expect(restored[0]).toEqual(action);
});
});
describe('generateScript', () => {
it('generates a non-empty string', () => {
const trace = [makeAction('a1', 'click')];
const script = reproducer.generateScript(trace);
expect(typeof script).toBe('string');
expect(script.length).toBeGreaterThan(0);
});
it('includes playwright require', () => {
const script = reproducer.generateScript([makeAction('a1', 'click')]);
expect(script).toContain("require('playwright')");
});
it('generates navigate step', () => {
const action = makeAction('a1', 'navigate', { url: 'http://localhost:3000', selector: undefined });
const script = reproducer.generateScript([action]);
expect(script).toContain('page.goto');
expect(script).toContain('http://localhost:3000');
});
it('generates click step', () => {
const script = reproducer.generateScript([makeAction('a1', 'click', { selector: '#submit' })]);
expect(script).toContain('click()');
expect(script).toContain('#submit');
});
it('generates fill step with value', () => {
const script = reproducer.generateScript([makeAction('a1', 'fill', { selector: '#email', value: '' })]);
expect(script).toContain('fill');
expect(script).toContain('#email');
});
it('includes seed comment for reproducibility', () => {
const script = reproducer.generateScript([makeAction('a1', 'click')]);
expect(script).toContain('seed=42');
});
it('generates empty script for empty trace', () => {
const script = reproducer.generateScript([]);
expect(script).toContain('browser.close');
});
});
});

View File

@@ -0,0 +1,128 @@
/**
* Tests for VisualBaselineRepository and VisualRegressionCollector.
*/
import Database from 'better-sqlite3';
import { runMigrations } from '../../src/db/migrations';
import { VisualBaselineRepository } from '../../src/db/VisualBaselineRepository';
import { VisualRegressionCollector } from '../../src/plugins/collectors/VisualRegressionCollector';
import { IState } from '../../src/core/interfaces';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
function makeDb(): Database.Database {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
return db;
}
function makeState(id = 'state1'): IState {
return {
id,
url: 'http://test.com/page',
title: 'Test Page',
timestamp: Date.now(),
domSnapshot: '<body></body>',
visitCount: 1,
};
}
describe('VisualBaselineRepository', () => {
let db: Database.Database;
let repo: VisualBaselineRepository;
beforeEach(() => {
db = makeDb();
repo = new VisualBaselineRepository(db);
});
afterEach(() => db.close());
it('creates and finds a baseline by state id', () => {
repo.createBaseline({
id: 'b1',
stateId: 'state1',
url: 'http://test.com',
screenshotPath: '/tmp/screenshot.png',
width: 1280,
height: 720,
});
const found = repo.findBaselineByStateId('state1');
expect(found).toBeDefined();
expect(found!.id).toBe('b1');
expect(found!.approved_by).toBe('user');
});
it('returns undefined for unknown state', () => {
expect(repo.findBaselineByStateId('unknown')).toBeUndefined();
});
it('creates and finds a comparison', () => {
repo.createComparison({
id: 'cmp1',
sessionId: 'sess1',
stateId: 'state1',
currentScreenshotPath: '/tmp/current.png',
status: 'new_state',
});
const found = repo.findComparisonById('cmp1');
expect(found).toBeDefined();
expect(found!.status).toBe('new_state');
expect(found!.baseline_id).toBeNull();
});
it('findComparisons with status filter', () => {
repo.createComparison({ id: 'c1', sessionId: 's1', stateId: 'st1', currentScreenshotPath: '/a.png', status: 'new_state' });
repo.createComparison({ id: 'c2', sessionId: 's1', stateId: 'st2', currentScreenshotPath: '/b.png', status: 'passed' });
const pending = repo.findComparisons({ status: 'new_state' });
expect(pending).toHaveLength(1);
expect(pending[0]!.id).toBe('c1');
});
it('updateComparisonStatus changes status', () => {
repo.createComparison({ id: 'c1', sessionId: 's1', stateId: 'st1', currentScreenshotPath: '/a.png', status: 'pending' });
repo.updateComparisonStatus('c1', 'passed');
expect(repo.findComparisonById('c1')!.status).toBe('passed');
});
it('promoteToBaseline creates baseline and updates comparison status', () => {
repo.createComparison({ id: 'cmp1', sessionId: 's1', stateId: 'state1', currentScreenshotPath: '/current.png', status: 'new_state' });
const baselineId = repo.promoteToBaseline('cmp1');
expect(baselineId).toBeTruthy();
expect(repo.findComparisonById('cmp1')!.status).toBe('passed');
});
});
describe('VisualRegressionCollector', () => {
let db: Database.Database;
let repo: VisualBaselineRepository;
let tmpDir: string;
beforeEach(() => {
db = makeDb();
repo = new VisualBaselineRepository(db);
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abe-visual-test-'));
});
afterEach(() => {
db.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns null when disabled', async () => {
const collector = new VisualRegressionCollector(tmpDir, repo, { enabled: false });
const result = await collector.processScreenshot('/fake.png', makeState(), 'sess1', []);
expect(result).toBeNull();
});
it('creates new_state comparison when no baseline exists', async () => {
const collector = new VisualRegressionCollector(tmpDir, repo);
const result = await collector.processScreenshot('/fake.png', makeState(), 'sess1', []);
expect(result).toBeNull();
const comparisons = repo.findComparisons({ sessionId: 'sess1' });
expect(comparisons).toHaveLength(1);
expect(comparisons[0]!.status).toBe('new_state');
});
});