Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/pharos/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,15 @@
},
"devDependencies": {
"@custom-elements-manifest/analyzer": "^0.11.0",
"@open-wc/semantic-dom-diff": "^0.20.1",
"@open-wc/testing": "patch:@open-wc/testing@npm%3A4.0.0#~/.yarn/patches/@open-wc-testing-npm-4.0.0-96dbe4d202.patch",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@vitest/browser-playwright": "^4.1.9",
"@vitest/coverage-istanbul": "^4.1.9",
"@webcomponents/scoped-custom-element-registry": "^0.0.10",
"autoprefixer": "^10.5.2",
"axe-core": "^4.11.1",
"chokidar": "^5.0.0",
"globby": "^16.2.0",
"minify-html-literals": "^1.3.5",
Expand Down
61 changes: 30 additions & 31 deletions packages/pharos/src/components/alert/pharos-alert.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fixture, expect } from '@open-wc/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { html } from 'lit/static-html.js';

import { fixture, errorFixture } from '../../test/fixture';
import type { PharosAlert } from './pharos-alert';
import type { PharosButton } from '../button/pharos-button';
import type { PharosLink } from '../link/pharos-link';
Expand All @@ -10,26 +11,26 @@ describe('pharos-alert', () => {

beforeEach(async () => {
component = await fixture(html`
<test-pharos-alert status="success"> It worked! </test-pharos-alert>
<test-pharos-alert status="success">It worked!</test-pharos-alert>
`);
});

it('is accessible', async () => {
await expect(component).to.be.accessible();
await expect(component).toBeAccessible();
});

it('throws an error for missing status attribute', async () => {
component = await fixture(html` <test-pharos-alert> It worked! </test-pharos-alert> `).catch(
(e) => e
);
expect('status is a required attribute.').to.be.thrown;
it('throws an error for a missing status attribute', async () => {
const error = await errorFixture(html` <test-pharos-alert>It worked!</test-pharos-alert> `);

expect(error.message).toContain('status is a required attribute.');
});

it('renders the alert when a status is provided', async () => {
component = await fixture(html`
<test-pharos-alert status="info"> It worked! </test-pharos-alert>
const alert = await fixture<PharosAlert>(html`
<test-pharos-alert status="info">It worked!</test-pharos-alert>
`);
expect(component).shadowDom.to.equal(`

expect(alert).toEqualShadowDom(`
<div
class="alert alert--info"
role="alert"
Expand All @@ -47,57 +48,55 @@ describe('pharos-alert', () => {
</div>
</div>
`);
expect(alert).toHaveTextContent('It worked!');
});

it('throws an error for an invalid status value', async () => {
component = await fixture(html`
<test-pharos-alert status="fake"> It worked! </test-pharos-alert>
`).catch((e) => e);
expect('fake is not a valid status. Valid statuses are: info, success, warning, error').to.be
.thrown;
});
const error = await errorFixture(html`
<test-pharos-alert status="fake">It worked!</test-pharos-alert>
`);

expect(error.message).toContain(
'fake is not a valid status. Valid statuses are: info, success, warning, error'
);
});
it('adds a class to slotted links', async () => {
const link = document.createElement('test-pharos-link') as PharosLink;

component.appendChild(link);
await component.updateComplete;
const anchor = link.renderRoot.querySelector('#link-element');

expect(anchor).to.have.class('link--alert');
expect(anchor?.classList.contains('link--alert')).toBe(true);
});

it('is closable', async () => {
component = await fixture(html`
const alert = await fixture<PharosAlert>(html`
<test-pharos-alert status="success" closable id="closable-alert">
It worked!
</test-pharos-alert>
`);

await component.updateComplete;

const closeButton = component.renderRoot.querySelector('.alert__button') as PharosButton;
const closeButton = alert.renderRoot.querySelector('.alert__button') as HTMLElement;
closeButton.click();

expect(document.getElementById('closable-alert')).to.be.null;
expect(document.getElementById('closable-alert')).toBeNull();
});

it('fires a custom event pharos-alert-closed when closed by user interaction', async () => {
component = await fixture(html`
const alert = await fixture<PharosAlert>(html`
<test-pharos-alert status="success" closable id="closable-alert">
It worked!
</test-pharos-alert>
`);

let wasFired = false;
const handleClose = (): void => {
alert.addEventListener('pharos-alert-closed', () => {
wasFired = true;
};
component.addEventListener('pharos-alert-closed', handleClose);
await component.updateComplete;
});

const closeButton = component.renderRoot.querySelector('.alert__button') as PharosButton;
const closeButton = alert.renderRoot.querySelector('.alert__button') as PharosButton;
closeButton.click();

expect(wasFired).to.be.true;
expect(wasFired).toBe(true);
});
});
62 changes: 62 additions & 0 deletions packages/pharos/src/test/fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { render } from 'lit';
import type { TemplateResult } from 'lit';

interface FixtureOptions {
/**
* Render the template into this element instead of a fresh wrapper, and
* attach the element itself to the document.
*/
parentNode?: HTMLElement;
}

const hasUpdateComplete = (value: unknown): value is { updateComplete: Promise<unknown> } => {
const update = (value as { updateComplete?: { then?: unknown } } | null)?.updateComplete;
return typeof update?.then === 'function';
};

const mount = (template: TemplateResult, parentNode?: HTMLElement): Element => {
// Clear the document body to ensure there are no elements left from earlier tests in the same file.
document.body.replaceChildren();

const container = parentNode ?? document.createElement('div');
document.body.appendChild(container);

render(template, container);

return container.firstElementChild as Element;
};

/**
* Render a Lit template into a container attached to the document and
* resolve once the first element has finished its initial update.
*/
export async function fixture<T extends Element>(
template: TemplateResult,
options: FixtureOptions = {}
): Promise<T> {
const element = mount(template, options.parentNode) as T;
// This ensures the shadow DOM is ready for testing before returning the element.
if (hasUpdateComplete(element)) await element.updateComplete;
return element;
}

/**
* Mounts a Lit template but expects the element's first
* update to *reject* — returns the thrown error instead of letting it surface
* as an unhandled rejection.
*/
export async function errorFixture(
template: TemplateResult,
options: FixtureOptions = {}
): Promise<Error> {
const element = mount(template, options.parentNode);
if (!hasUpdateComplete(element)) {
throw new Error('fixtureError: element has no updateComplete to reject');
}
try {
await element.updateComplete;
} catch (error) {
return error as Error;
}
throw new Error('fixtureError: expected the element update to reject, but it resolved');
}
122 changes: 122 additions & 0 deletions packages/pharos/src/test/matchers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { expect } from 'vitest';
import axe from 'axe-core';
import { getDiffableHTML, type DiffOptions } from '@open-wc/semantic-dom-diff/get-diffable-html.js';

interface AccessibleOptions {
ignoredRules?: string[];
}

const formatViolations = (violations: axe.Result[]): string => {
const lines: string[] = ['Accessibility Violations', '---'];
violations.forEach((violation) => {
lines.push(`Rule: ${violation.id}`);
lines.push(`Impact: ${violation.impact}`);
lines.push(`${violation.help} (${violation.helpUrl})`);
violation.nodes.forEach((node) => {
lines.push('');
if (node.target) {
lines.push(`Issue target: ${node.target}`);
}
lines.push(`Context: ${node.html}`);
if (node.failureSummary) {
lines.push(node.failureSummary);
}
});
lines.push('---');
});
return lines.join('\n');
};

expect.extend({
// check for accessibility violations using axe-core.
async toBeAccessible(received: Element, options: AccessibleOptions = {}) {
const runOptions: axe.RunOptions = { resultTypes: ['violations'] };
if (options.ignoredRules?.length) {
runOptions.rules = Object.fromEntries(
options.ignoredRules.map((rule) => [rule, { enabled: false }])
);
}
const { violations } = await axe.run(received, runOptions);
const pass = violations.length === 0;
return {
pass,
message: () =>
pass
? 'expected element to have accessibility violations, but none were found'
: formatViolations(violations),
};
},

// check that the element's light DOM matches the expected HTML.
toEqualDom(received: Element, expected: string, options?: DiffOptions) {
const actual = getDiffableHTML(received.outerHTML, options);
const expectedHTML = getDiffableHTML(expected, options);
return {
pass: actual === expectedHTML,
message: () => 'expected DOM to equal the given HTML',
actual,
expected: expectedHTML,
};
},
// check that the element's shadow DOM matches the expected HTML.
toEqualShadowDom(received: Element, expected: string, options?: DiffOptions) {
const shadowRoot = received.shadowRoot;
if (!shadowRoot) {
return {
pass: false,
message: () => 'expected element to have a shadow root, but it did not',
};
}
const actual = getDiffableHTML(shadowRoot.innerHTML, options);
const expectedHTML = getDiffableHTML(expected, options);
return {
pass: actual === expectedHTML,
message: () => 'expected shadow DOM to equal the given HTML',
actual,
expected: expectedHTML,
};
},
});

interface PharosMatchers<R = unknown> {
/**
* Asserts the element has no axe-core accessibility violations.
*
* @param options.ignoredRules - axe rule IDs to disable for this run
*
* @example
* await expect(component).toBeAccessible();
* await expect(component).toBeAccessible({ ignoredRules: ['region'] });
*/
toBeAccessible(options?: AccessibleOptions): Promise<R>;
/**
* Asserts the element's light DOM matches the expected HTML, ignoring
* whitespace and Lit's marker comments.
*
* @param expected - the expected HTML string.
* @param options - `getDiffableHTML` options, e.g. `{ ignoreAttributes: [...] }`.
*
* @example
* expect(component).toEqualDom('<div class="x">text</div>');
*/
toEqualDom(expected: string, options?: DiffOptions): R;
/**
* Asserts the element's shadow DOM matches the expected HTML, ignoring
* whitespace and Lit's marker comments.
*
* @param expected - the expected shadow DOM HTML string.
* @param options - `getDiffableHTML` options, e.g. `{ ignoreAttributes: [...] }`.
*
* @example
* expect(component).toEqualShadowDom(`
* <div class="alert alert--info" role="alert">...</div>
* `);
*/
toEqualShadowDom(expected: string, options?: DiffOptions): R;
}

declare module 'vitest' {
interface Matchers<T> extends PharosMatchers<T> {}
interface Assertion<T> extends PharosMatchers<T> {}
interface AsymmetricMatchersContaining extends PharosMatchers {}
}
3 changes: 3 additions & 0 deletions packages/pharos/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ import '@webcomponents/scoped-custom-element-registry';

// Register every Pharos component under the `test-` prefix
import './src/test/initComponents';

// Register custom matchers globally
import './src/test/matchers';
Loading
Loading