docs: enterprise refactor plan with ralph specs
This commit is contained in:
197
tests/plugins/accessibility.test.ts
Normal file
197
tests/plugins/accessibility.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
143
tests/plugins/agents/PlaywrightAgent.test.ts
Normal file
143
tests/plugins/agents/PlaywrightAgent.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
102
tests/plugins/collectors.test.ts
Normal file
102
tests/plugins/collectors.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
108
tests/plugins/explorationConfig.test.ts
Normal file
108
tests/plugins/explorationConfig.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
111
tests/plugins/exporters/JSONExporter.test.ts
Normal file
111
tests/plugins/exporters/JSONExporter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
133
tests/plugins/exporters/MarkdownExporter.test.ts
Normal file
133
tests/plugins/exporters/MarkdownExporter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
246
tests/plugins/fuzzers.test.ts
Normal file
246
tests/plugins/fuzzers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
289
tests/plugins/networkChaos.test.ts
Normal file
289
tests/plugins/networkChaos.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
185
tests/plugins/performanceCollector.test.ts
Normal file
185
tests/plugins/performanceCollector.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
109
tests/plugins/reproducers.test.ts
Normal file
109
tests/plugins/reproducers.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
128
tests/plugins/visualRegression.test.ts
Normal file
128
tests/plugins/visualRegression.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user