198 lines
7.3 KiB
TypeScript
198 lines
7.3 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|