import { afterEach, describe, expect, test, vi } from 'vitest'; import { WebComponent, choice, disable, enable, json, number, string, type PropChange, } from '../src/index.ts'; let count = 0; /** A fresh, unused tag name. */ function nextTag(): string { count += 1; return `x-test-${count}`; } /** Defines `ctor` under a fresh tag name and returns it, so `new ctor()` works. */ function define(ctor: T): T { customElements.define(nextTag(), ctor); return ctor; } /** Narrows `node` to an instance of `ctor`, failing the test otherwise. */ function instance(ctor: abstract new (...args: never) => T, node: unknown): T { if (!(node instanceof ctor)) throw new TypeError(`expected a ${ctor.name}`); return node; } afterEach(() => { document.body.replaceChildren(); for (const style of document.head.querySelectorAll('style[data-wc-mixin]')) style.remove(); }); describe('props', () => { test('onPropChange fires once per change, from either side', () => { const SPEC = { props: { count: number({ default: 0 }) } } as const; const changes: PropChange[] = []; class El extends WebComponent(SPEC) { override onPropChange(change: PropChange): void { changes.push(change); } } const el = new (define(El))(); el.count = 1; el.count = 1; el.setAttribute('count', '2'); el.removeAttribute('count'); expect(changes).toEqual([ { name: 'count', value: 1, oldValue: 0 }, { name: 'count', value: 2, oldValue: 1 }, { name: 'count', value: 0, oldValue: 2 }, ]); }); test('parsed attributes populate props on upgrade', () => { class El extends WebComponent({ props: { userId: number(), ready: enable() } }) {} const tag = nextTag(); document.body.innerHTML = `<${tag} user-id="7" ready>`; customElements.define(tag, El); const el = instance(El, document.body.firstElementChild); expect(el.userId).toBe(7); expect(el.ready).toBe(true); }); test('properties set before upgrade go through the accessor', () => { class El extends WebComponent({ props: { label: string() } }) {} const tag = nextTag(); const el = document.createElement(tag); Object.assign(el, { label: 'early' }); customElements.define(tag, El); customElements.upgrade(el); document.body.append(el); expect(Object.hasOwn(el, 'label')).toBe(false); expect(instance(El, el).label).toBe('early'); expect(el.getAttribute('label')).toBe('early'); }); test('boolean codecs accept ="false"', () => { class El extends WebComponent({ props: { ready: enable(), fetch: disable() } }) {} const el = new (define(El))(); el.setAttribute('ready', 'false'); el.setAttribute('disable-fetch', 'false'); expect(el.ready).toBe(false); expect(el.fetch).toBe(true); }); test('choice and json', () => { class El extends WebComponent({ props: { size: choice(['s', 'm', 'l'], { default: 'm' }), data: json<{ a: number }>() }, }) {} const el = new (define(El))(); el.setAttribute('size', 'l'); expect(el.size).toBe('l'); el.setAttribute('size', 'xl'); expect(el.size).toBe('m'); el.data = { a: 1 }; expect(el.getAttribute('data')).toBe('{"a":1}'); el.setAttribute('data', '{"a":2}'); expect(el.data).toEqual({ a: 2 }); }); test('dataPrefix prefixes derived names only', () => { class El extends WebComponent({ dataPrefix: true, props: { userId: number(), fetch: disable(), ready: enable({ attribute: 'is-ready' }) }, }) {} expect(El.observedAttributes).toEqual(['data-user-id', 'data-disable-fetch', 'is-ready']); expect(El.attributeFor).toEqual({ userId: 'data-user-id', fetch: 'data-disable-fetch', ready: 'is-ready', }); const el = new (define(El))(); el.fetch = false; expect(el.dataset['disableFetch']).toBe(''); }); }); describe('shadow DOM', () => { test('open shadow root gets the template and adopted css', () => { class El extends WebComponent({ shadow: 'open', template: '

hi

', css: 'p { color: red; }', }) {} const el = new (define(El))(); expect(el.shadowRoot).toBe(el.root); expect(el.root.innerHTML).toBe('

hi

'); expect(el.root.adoptedStyleSheets).toHaveLength(1); document.body.append(el); expect(getComputedStyle(instance(HTMLParagraphElement, el.root.querySelector('p'))).color).toBe( 'rgb(255, 0, 0)', ); }); test('closed shadow root stays closed but is reachable via root', () => { class El extends WebComponent({ shadow: 'closed', template: '

hi

' }) {} const el = new (define(El))(); expect(el.shadowRoot).toBeNull(); expect(el.root).toBeInstanceOf(ShadowRoot); expect(el.root.mode).toBe('closed'); }); test('instances share one stylesheet', () => { class El extends WebComponent({ shadow: 'open', css: 'p {}' }) {} define(El); const a = new El(); const b = new El(); expect(a.root.adoptedStyleSheets[0]).toBe(b.root.adoptedStyleSheets[0]); }); test('declarative shadow DOM wins over shadow/template', () => { class El extends WebComponent({ shadow: 'open', template: '

template

', css: 'p {}' }) {} const tag = nextTag(); document.body.setHTMLUnsafe( `<${tag}>`, ); customElements.define(tag, El); const el = instance(El, document.body.firstElementChild); expect(el.root.mode).toBe('closed'); expect(el.root.innerHTML).toBe('

declarative

'); expect(el.root.adoptedStyleSheets).toHaveLength(1); }); test('template element is accepted', () => { const template = document.createElement('template'); template.innerHTML = 'x'; class El extends WebComponent({ shadow: 'open', template }) {} const el = new (define(El))(); expect(el.root.innerHTML).toBe('x'); }); }); describe('light DOM', () => { test('template is appended once, on first connect', () => { class El extends WebComponent({ template: '

hi

' }) {} const el = new (define(El))(); expect(el.root).toBe(el); expect(el.childNodes).toHaveLength(0); document.body.append(el); el.remove(); document.body.append(el); expect(el.innerHTML).toBe('

hi

'); }); test('css becomes a single