@narottamdev/component-test-utils
Version:
Universal testing utilities for component libraries across different frameworks
637 lines (628 loc) • 22.2 kB
JavaScript
class ReactAdapter {
constructor() {
try {
this.testingLibrary = require('@testing-library/react');
}
catch (error) {
throw new Error('React Testing Library is required for React components. Please install @testing-library/react');
}
}
render(component, props) {
const { render } = this.testingLibrary;
const result = render(component, props);
return {
element: result.container.firstChild,
container: result.container,
rerender: result.rerender,
unmount: result.unmount,
debug: result.debug
};
}
cleanup() {
const { cleanup } = this.testingLibrary;
cleanup();
}
fireEvent(element, eventType, options) {
const { fireEvent } = this.testingLibrary;
const eventMethod = fireEvent[eventType];
if (typeof eventMethod === 'function') {
eventMethod(element, options);
}
else {
throw new Error('Event type ' + eventType + ' is not supported');
}
}
async waitFor(callback, options) {
const { waitFor } = this.testingLibrary;
return waitFor(callback, options);
}
}
class VueAdapter {
constructor() {
try {
this.testUtils = require('@testing-library/vue');
}
catch (error) {
throw new Error('Vue Testing Library is required for Vue components. Please install @testing-library/vue');
}
}
render(component, props) {
const { render } = this.testUtils;
const result = render(component, { props });
return {
element: result.container.firstChild,
container: result.container,
rerender: (newProps) => result.rerender({ props: newProps }),
unmount: result.unmount,
debug: result.debug
};
}
cleanup() {
const { cleanup } = this.testUtils;
if (cleanup) {
cleanup();
}
}
fireEvent(element, eventType, options) {
const { fireEvent } = this.testUtils;
const eventMethod = fireEvent[eventType];
if (typeof eventMethod === 'function') {
eventMethod(element, options);
}
else {
throw new Error('Event type ' + eventType + ' is not supported');
}
}
async waitFor(callback, options) {
const { waitFor } = this.testUtils;
return waitFor(callback, options);
}
}
class AngularAdapter {
constructor() {
try {
const { TestBed } = require('@angular/testing');
this.testBed = TestBed;
}
catch (error) {
throw new Error('Angular Testing utilities are required for Angular components. Please install @angular/testing');
}
}
render(component, props) {
const fixture = this.testBed.createComponent(component);
if (props) {
Object.assign(fixture.componentInstance, props);
}
fixture.detectChanges();
return {
element: fixture.nativeElement,
container: fixture.nativeElement.parentElement || fixture.nativeElement,
rerender: (newProps) => {
if (newProps) {
Object.assign(fixture.componentInstance, newProps);
}
fixture.detectChanges();
},
unmount: () => {
fixture.destroy();
},
debug: () => {
console.log(fixture.nativeElement.outerHTML);
}
};
}
cleanup() {
this.testBed.resetTestingModule();
}
fireEvent(element, eventType, options) {
const event = new Event(eventType, {
bubbles: options?.bubbles || true,
cancelable: options?.cancelable || true,
...options
});
element.dispatchEvent(event);
}
async waitFor(callback, options) {
const timeout = options?.timeout || 5000;
const startTime = Date.now();
return new Promise((resolve, reject) => {
const check = async () => {
try {
await callback();
resolve();
}
catch (error) {
if (Date.now() - startTime > timeout) {
reject(new Error('waitFor timed out after ' + timeout + 'ms'));
}
else {
setTimeout(check, 100);
}
}
};
check();
});
}
}
class VanillaAdapter {
constructor() {
this.container = null;
}
ensureContainer() {
if (!this.container) {
if (typeof document === 'undefined') {
throw new Error('VanillaAdapter requires a DOM environment. Make sure you are running in a browser or have jsdom configured.');
}
this.container = document.createElement('div');
this.container.setAttribute('data-testid', 'test-container');
document.body.appendChild(this.container);
}
return this.container;
}
render(component, props) {
const container = this.ensureContainer();
let element;
if (typeof component === 'function') {
element = component(props);
}
else if (component instanceof HTMLElement) {
element = component;
}
else if (typeof component === 'string') {
const wrapper = document.createElement('div');
wrapper.innerHTML = component;
element = wrapper.firstElementChild;
}
else {
throw new Error('Invalid component type for vanilla adapter');
}
container.innerHTML = '';
container.appendChild(element);
return {
element,
container: container,
rerender: (newProps) => {
if (typeof component === 'function') {
const newElement = component(newProps);
container.innerHTML = '';
container.appendChild(newElement);
}
},
unmount: () => {
container.innerHTML = '';
},
debug: () => {
console.log(container.innerHTML);
}
};
}
cleanup() {
if (this.container) {
this.container.innerHTML = '';
if (this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
this.container = null;
}
}
fireEvent(element, eventType, options) {
const event = new Event(eventType, {
bubbles: options?.bubbles || true,
cancelable: options?.cancelable || true,
...options
});
element.dispatchEvent(event);
}
async waitFor(callback, options) {
const timeout = options?.timeout || 5000;
const startTime = Date.now();
return new Promise((resolve, reject) => {
const check = async () => {
try {
await callback();
resolve();
}
catch (error) {
if (Date.now() - startTime > timeout) {
reject(new Error('waitFor timed out after ' + timeout + 'ms'));
}
else {
setTimeout(check, 100);
}
}
};
check();
});
}
}
class TestRenderer {
constructor(config) {
this.config = config;
this.adapter = this.createAdapter(config.framework);
}
createAdapter(framework) {
switch (framework) {
case 'react':
return new ReactAdapter();
case 'vue':
return new VueAdapter();
case 'angular':
return new AngularAdapter();
case 'vanilla':
return new VanillaAdapter();
default:
throw new Error('Unsupported framework: ' + framework);
}
}
render(component, props) {
return this.adapter.render(component, props);
}
fireEvent(element, eventType, options) {
return this.adapter.fireEvent(element, eventType, options);
}
async waitFor(callback, options) {
return this.adapter.waitFor(callback, options);
}
cleanup() {
return this.adapter.cleanup();
}
}
function createTestUtils(config) {
return new TestRenderer(config);
}
class AccessibilityTester {
static async testAccessibility(element, options) {
const violations = [];
const checks = [
() => this.checkForAltText(element),
() => this.checkForAriaLabels(element),
() => this.checkColorContrast(element),
() => this.checkKeyboardNavigation(element),
() => this.checkHeadingStructure(element)
];
for (const check of checks) {
try {
const result = await check();
if (result)
violations.push(result);
}
catch (error) {
violations.push({
rule: 'accessibility-check-error',
impact: 'serious',
message: error?.message || 'Unknown error'
});
}
}
return violations;
}
static checkForAltText(element) {
const images = element.querySelectorAll('img');
for (const img of Array.from(images)) {
if (!img.getAttribute('alt') && !img.getAttribute('aria-label')) {
return {
rule: 'image-alt',
impact: 'serious',
message: 'Images must have alternative text',
element: img
};
}
}
return null;
}
static checkForAriaLabels(element) {
const interactive = element.querySelectorAll('button, input, select, textarea, [role="button"]');
for (const el of Array.from(interactive)) {
const hasLabel = el.getAttribute('aria-label') ||
el.getAttribute('aria-labelledby') ||
el.textContent?.trim();
if (!hasLabel) {
return {
rule: 'aria-label',
impact: 'serious',
message: 'Interactive elements must have accessible names',
element: el
};
}
}
return null;
}
static checkColorContrast(element) {
const computedStyle = window.getComputedStyle(element);
const color = computedStyle.color;
const backgroundColor = computedStyle.backgroundColor;
if (color === backgroundColor) {
return {
rule: 'color-contrast',
impact: 'serious',
message: 'Insufficient color contrast',
element
};
}
return null;
}
static checkKeyboardNavigation(element) {
const focusable = element.querySelectorAll('a, button, input, select, textarea, [tabindex]');
for (const el of Array.from(focusable)) {
const tabIndex = el.getAttribute('tabindex');
if (tabIndex && parseInt(tabIndex) > 0) {
return {
rule: 'tabindex',
impact: 'moderate',
message: 'Avoid positive tabindex values',
element: el
};
}
}
return null;
}
static checkHeadingStructure(element) {
const headings = element.querySelectorAll('h1, h2, h3, h4, h5, h6');
let lastLevel = 0;
for (const heading of Array.from(headings)) {
const level = parseInt(heading.tagName.charAt(1));
if (level > lastLevel + 1) {
return {
rule: 'heading-order',
impact: 'moderate',
message: 'Heading levels should not skip levels',
element: heading
};
}
lastLevel = level;
}
return null;
}
}
function testAccessibility(element, options) {
return AccessibilityTester.testAccessibility(element, options);
}
class PerformanceTester {
static async measureRenderPerformance(renderFn, options) {
const iterations = options?.iterations || 1;
const renderTimes = [];
for (let i = 0; i < iterations; i++) {
const startTime = performance.now();
await renderFn();
const endTime = performance.now();
renderTimes.push(endTime - startTime);
}
const averageRenderTime = renderTimes.reduce((sum, time) => sum + time, 0) / renderTimes.length;
const metrics = {
renderTime: averageRenderTime,
iterations
};
if ('memory' in performance) {
metrics.memoryUsage = performance.memory.usedJSHeapSize;
}
return metrics;
}
static async measureRerenderPerformance(rerenderFn, options) {
const iterations = options?.iterations || 10;
const rerenderTimes = [];
for (let i = 0; i < iterations; i++) {
const startTime = performance.now();
await rerenderFn();
const endTime = performance.now();
rerenderTimes.push(endTime - startTime);
}
return rerenderTimes.reduce((sum, time) => sum + time, 0) / rerenderTimes.length;
}
static measureMemoryUsage() {
if ('memory' in performance) {
return performance.memory.usedJSHeapSize;
}
return undefined;
}
static startPerformanceProfile(label) {
if (performance.mark) {
performance.mark(label + '-start');
}
}
static endPerformanceProfile(label) {
if (performance.mark && performance.measure) {
performance.mark(label + '-end');
performance.measure(label, label + '-start', label + '-end');
const entries = performance.getEntriesByName(label);
if (entries.length > 0) {
return entries[entries.length - 1].duration;
}
}
return undefined;
}
static async waitForStablePerformance(testFn, options) {
const maxAttempts = options?.maxAttempts || 10;
const threshold = options?.threshold || 0.1;
const measurements = [];
for (let i = 0; i < maxAttempts; i++) {
const startTime = performance.now();
await testFn();
const endTime = performance.now();
measurements.push(endTime - startTime);
if (measurements.length >= 3) {
const recent = measurements.slice(-3);
const avg = recent.reduce((sum, val) => sum + val, 0) / recent.length;
const variance = recent.every(val => Math.abs(val - avg) / avg < threshold);
if (variance) {
return;
}
}
}
console.warn('Performance did not stabilize within maximum attempts');
}
}
function measureRenderPerformance(renderFn, options) {
return PerformanceTester.measureRenderPerformance(renderFn, options);
}
function measureRerenderPerformance(rerenderFn, options) {
return PerformanceTester.measureRerenderPerformance(rerenderFn, options);
}
class ComponentAssertions {
static toBeInDocument(element) {
if (!element || !document.body.contains(element)) {
throw new Error('Expected element to be in the document');
}
}
static toBeVisible(element) {
if (!element) {
throw new Error('Expected element to exist');
}
const style = window.getComputedStyle(element);
const isVisible = style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0';
if (!isVisible) {
throw new Error('Expected element to be visible');
}
}
static toHaveTextContent(element, text) {
if (!element) {
throw new Error('Expected element to exist');
}
const textContent = element.textContent || '';
if (!textContent.includes(text)) {
throw new Error('Expected element to have text content ' + text + ', but got ' + textContent);
}
}
static toHaveAttribute(element, attr, value) {
if (!element) {
throw new Error('Expected element to exist');
}
if (!element.hasAttribute(attr)) {
throw new Error('Expected element to have attribute ' + attr);
}
if (value !== undefined) {
const actualValue = element.getAttribute(attr);
if (actualValue !== value) {
throw new Error('Expected attribute ' + attr + ' to have value ' + value + ', but got ' + actualValue);
}
}
}
static toHaveClass(element, className) {
if (!element) {
throw new Error('Expected element to exist');
}
if (!element.classList.contains(className)) {
throw new Error('Expected element to have class ' + className);
}
}
static toHaveStyle(element, styles) {
if (!element) {
throw new Error('Expected element to exist');
}
const computedStyle = window.getComputedStyle(element);
for (const [property, expectedValue] of Object.entries(styles)) {
const actualValue = computedStyle.getPropertyValue(property);
if (actualValue !== expectedValue.toString()) {
throw new Error('Expected element to have style ' + property + ': ' + expectedValue + ', but got ' + actualValue);
}
}
}
static toBeDisabled(element) {
if (!element) {
throw new Error('Expected element to exist');
}
const isDisabled = element.hasAttribute('disabled') ||
element.getAttribute('aria-disabled') === 'true';
if (!isDisabled) {
throw new Error('Expected element to be disabled');
}
}
static toBeEnabled(element) {
if (!element) {
throw new Error('Expected element to exist');
}
const isDisabled = element.hasAttribute('disabled') ||
element.getAttribute('aria-disabled') === 'true';
if (isDisabled) {
throw new Error('Expected element to be enabled');
}
}
static toHaveFocus(element) {
if (!element) {
throw new Error('Expected element to exist');
}
if (document.activeElement !== element) {
throw new Error('Expected element to have focus');
}
}
static toBeChecked(element) {
if (!element) {
throw new Error('Expected element to exist');
}
const input = element;
if (input.type !== 'checkbox' && input.type !== 'radio') {
throw new Error('Expected element to be a checkbox or radio input');
}
if (!input.checked) {
throw new Error('Expected element to be checked');
}
}
static toHaveValue(element, value) {
if (!element) {
throw new Error('Expected element to exist');
}
const input = element;
if (input.value !== value.toString()) {
throw new Error('Expected element to have value ' + value + ', but got ' + input.value);
}
}
}
const customMatchers = {
toBeInDocument(received) {
try {
ComponentAssertions.toBeInDocument(received);
return { pass: true, message: () => 'Expected element not to be in document' };
}
catch (error) {
return { pass: false, message: () => error.message };
}
},
toBeVisible(received) {
try {
ComponentAssertions.toBeVisible(received);
return { pass: true, message: () => 'Expected element not to be visible' };
}
catch (error) {
return { pass: false, message: () => error.message };
}
},
toHaveTextContent(received, text) {
try {
ComponentAssertions.toHaveTextContent(received, text);
return { pass: true, message: () => 'Expected element not to have text content ' + text };
}
catch (error) {
return { pass: false, message: () => error.message };
}
},
toHaveAttribute(received, attr, value) {
try {
ComponentAssertions.toHaveAttribute(received, attr, value);
return { pass: true, message: () => 'Expected element not to have attribute ' + attr };
}
catch (error) {
return { pass: false, message: () => error.message };
}
},
toHaveClass(received, className) {
try {
ComponentAssertions.toHaveClass(received, className);
return { pass: true, message: () => 'Expected element not to have class ' + className };
}
catch (error) {
return { pass: false, message: () => error.message };
}
},
toHaveStyle(received, styles) {
try {
ComponentAssertions.toHaveStyle(received, styles);
return { pass: true, message: () => 'Expected element not to have specified styles' };
}
catch (error) {
return { pass: false, message: () => error.message };
}
}
};
export { AccessibilityTester, AngularAdapter, ComponentAssertions, PerformanceTester, ReactAdapter, TestRenderer, VanillaAdapter, VueAdapter, createTestUtils, customMatchers, measureRenderPerformance, measureRerenderPerformance, testAccessibility };
//# sourceMappingURL=index.esm.js.map