UNPKG

@zeix/ui-element

Version:

UIElement - a HTML-first library for reactive Web Components

1,843 lines (1,674 loc) 49.1 kB
<!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>Component Tests</title> </head> <body> <style> .visually-hidden { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; } basic-counter { display: flex; flex-direction: row; gap: 1rem; & p { margin-block: 0.2rem; } } module-tabgroup > [role='tablist'] { display: flex; gap: 0.2rem; padding: 0; & button[aria-selected='true'] { color: purple; } } module-lazy .error { color: red; } post-reply div { margin-left: 2rem; } </style> <void-component id="void"> <h1>Hello from Server</h1> </void-component> <void-component id="void2"> <h1>Hello from Server</h1> </void-component> <causal-component id="causal"> <h1>Hello from Server</h1> </causal-component> <causal-component id="causal-with-ignored-attribute" heading="Hello from Attribute" > <h1>Hello from Server</h1> </causal-component> <updating-component id="updating"> <h1>Hello from Server</h1> <p>Number of unread messages: <span></span></p> </updating-component> <updating-component id="updating-with-string-attribute" heading="Hello from Attribute" > <h1>Hello from Server</h1> <p>Number of unread messages: <span></span></p> </updating-component> <updating-component id="updating-with-number-attribute" count="42" step="0.1" value="3.14" > <h1>Hello from Server</h1> <p>Number of unread messages: <span></span></p> <input type="number" /> </updating-component> <updating-component id="updating-with-boolean-attribute" selected> <h1>Hello from Server</h1> <p>Number of unread messages: <span></span></p> </updating-component> <basic-counter value="42"> <p>Count: <span class="value">42</span></p> <p>Parity: <span class="parity">even</span></p> <p>Double: <span class="double">84</span></p> <div> <button class="decrement"></button> <button class="increment">+</button> </div> </basic-counter> <greeting-configurator> <form-textbox class="first"> <input type="text" name="first" value="Jane" /> </form-textbox> <form-textbox class="last"> <input type="text" name="last" value="Doe" /> </form-textbox> <form-checkbox> <input type="checkbox" name="fullname" /> </form-checkbox> <hello-world> <p> <span class="greeting">Hello</span> <span class="name">World</span> </p> </hello-world> </greeting-configurator> <module-lazy src="/test/mock/module-lazy.html" id="lazy-success"> <card-callout> <p class="loading" role="status">Loading...</p> <p class="error" role="alert" aria-live="polite" hidden></p> </card-callout> </module-lazy> <module-lazy src="/test/mock/404.html" id="lazy-error"> <card-callout> <p class="loading" role="status">Loading...</p> <p class="error" role="alert" aria-live="polite" hidden></p> </card-callout> </module-lazy> <module-lazy src="/test/mock/recursion.html" id="lazy-recursion"> <p class="loading" role="status">Loading...</p> <p class="error" role="alert" aria-live="polite" hidden></p> </module-lazy> <module-tabgroup> <div role="tablist"> <button type="button" role="tab" id="trigger1" aria-controls="panel1" aria-selected="true" tabindex="0" > Tab 1 </button> <button type="button" role="tab" id="trigger2" aria-controls="panel2" aria-selected="false" tabindex="-1" > Tab 2 </button> <button type="button" role="tab" id="trigger3" aria-controls="panel3" aria-selected="false" tabindex="-1" > Tab 3 </button> </div> <div role="tabpanel" id="panel1" aria-labelledby="trigger1"> Tab 1 content </div> <div role="tabpanel" id="panel2" aria-labelledby="trigger2" hidden> Tab 2 content </div> <div role="tabpanel" id="panel3" aria-labelledby="trigger3" hidden> Tab 3 content </div> </module-tabgroup> <template id="post-reply-template"> <div> <p></p> <post-reply> <button type="button" class="reply-button">Reply</button> <form hidden> <label> <span>Message:</span> <textarea required></textarea> </label> <button type="submit">Submit</button> </form> </post-reply> </div> </template> <post-reply message="My two cents"> <button type="button">Reply</button> <form hidden> <label> <span>Message:</span> <textarea required></textarea> </label> <button type="submit">Submit</button> </form> </post-reply> <script type="module"> import { runTests } from '@web/test-runner-mocha' import { assert } from '@esm-bundle/chai' import { RESET, component, on, pass, state, asBoolean, asInteger, asNumber, asString, setText, setProperty, setAttribute, show, toggleAttribute, toggleClass, setStyle, dangerouslySetInnerHTML, insertOrRemoveElement, } from '../index.dev.js' const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) const animationFrame = async () => new Promise(requestAnimationFrame) const normalizeText = text => text.replace(/\s+/g, ' ').trim() const LOADING_DELAY = 800 // Adjust this if tests for LazyLoad fail; needs to be high enough so initial loading message can be observed before it gets removed when async connectedCallback() finishes component('void-component', {}, () => []) component( 'causal-component', { heading: 'Hello from Internal State', }, (_, { first }) => [first('h1', setText('heading'))], ) component( 'updating-component', { heading: asString(RESET), count: asInteger(), step: asNumber(), value: (el, value) => { if (value == null) return RESET const parsed = Number.isInteger(el.step) ? parseInt(value, 10) : parseFloat(value) return Number.isFinite(parsed) ? parsed : RESET }, selected: asBoolean(), }, (_, { first }) => [ first('h1', setText('heading'), toggleClass('selected')), first('span', setText('count')), first('input', setAttribute('step'), setProperty('value')), ], ) component( 'basic-counter', { value: asInteger(), }, (el, { first }) => [ first( '.decrement', on('click', () => { el.value-- }), ), first( '.increment', on('click', () => { el.value++ }), ), first('.value', setText('value')), first( '.parity', setText(() => (el.value % 2 ? 'odd' : 'even')), ), first( '.double', setText(() => el.value * 2), ), ], ) component('greeting-configurator', {}, (el, { first }) => [ first( 'hello-world', pass({ name: () => { const firstName = el.querySelector('.first') const lastName = el.querySelector('.last') return el.querySelector('form-checkbox').checked ? `${firstName.value} ${lastName.value}` : firstName.value }, }), ), ]) component( 'hello-world', { greeting: asString(RESET), name: asString('World'), }, (_, { first }) => [ first('.greeting', setText('greeting')), first('.name', setText('name')), ], ) component( 'form-textbox', { value: asString(RESET), }, (el, { first }) => [ first( 'input', setProperty('value'), on('change', e => { el.value = e.target.value }), ), ], ) component( 'form-checkbox', { checked: asBoolean(), }, (el, { first }) => [ first( 'input', setProperty('checked'), on('change', e => { el.checked = e.target.checked }), ), ], ) component( 'module-lazy', { error: '', src: (el, v) => { // Custom attribute parser if (!v) { el.error = 'No URL provided in src attribute' return '' } else if ( ( el.parentElement || el.getRootNode().host )?.closest(`${el.localName}[src="${v}"]`) ) { el.error = 'Recursive loading detected' return '' } const url = new URL(v, location.href) // Ensure 'src' attribute is a valid URL if (url.origin === location.origin) { // Sanity check for cross-origin URLs el.error = '' // Success: wipe previous error if there was any return String(url) } el.error = 'Invalid URL origin' return '' }, content: el => async abort => { // Async Computed callback const url = el.src if (!url) return '' try { const response = await fetch(url, { signal: abort, }) await wait(LOADING_DELAY) el.querySelector('.loading')?.remove() if (response.ok) return response.text() else el.error = response.statusText } catch (error) { el.error = error.message } return '' }, }, (el, { first }) => [ first( '.error', setText('error'), show(() => !!el.error), ), dangerouslySetInnerHTML('content', { shadowRootMode: 'open', }), ], ) component( 'module-tabgroup', { selected: '', }, (el, { all, first }) => { el.selected = el .querySelector('[role="tab"][aria-selected="true"]') .getAttribute('aria-controls') ?? '' const isSelected = target => el.selected === target.getAttribute('aria-controls') const tabs = Array.from(el.querySelectorAll('[role="tab"]')) let focusIndex = 0 return [ first( '[role="tablist"]', on('keydown', () => {}), ), all( '[role="tab"]', on('click', e => { el.selected = e.currentTarget.getAttribute( 'aria-controls', ) ?? '' focusIndex = tabs.findIndex(tab => isSelected(tab), ) }), setProperty('ariaSelected', target => String(isSelected(target)), ), setProperty('tabIndex', target => isSelected(target) ? 0 : -1, ), ), all( '[role="tabpanel"]', setProperty( 'hidden', target => el.selected !== target.id, ), ), ] }, ) component('post-reply', {}, (el, { first }) => { const message = state('') const formVisible = state(false) return [ first( 'button', on('click', () => { formVisible.set(true) }), ), first( 'form', on('submit', e => { e.preventDefault() message.set(el.querySelector('textarea').value) formVisible.set(false) }), show(formVisible), ), insertOrRemoveElement(() => !!message.get(), { create: () => { const post = document.importNode( document.getElementById('post-template') .content, true, ) post.querySelector('h2').textContent = message.get() return post }, }), ] }) runTests(() => { describe('Void component', function () { it('should be an instance of HTMLElement', async function () { const voidComponent = document.getElementById('void') assert.instanceOf(voidComponent, HTMLElement) assert.equal(voidComponent.localName, 'void-component') }) it('should do nothing at all', async function () { const voidComponent = document.getElementById('void') await animationFrame() const textContent = normalizeText( voidComponent.querySelector('h1').textContent, ) assert.equal( textContent, 'Hello from Server', 'Should not change server-side rendered heading', ) }) it('should return false with in for unset state', async function () { const voidComponent = document.getElementById('void') assert.equal('test' in voidComponent, false) assert.equal( voidComponent.hasOwnProperty('test'), false, ) }) }) describe('Causal component', function () { it('should update according to internal state', async function () { const causalComponent = document.getElementById('causal') await animationFrame() const textContent = normalizeText( causalComponent.querySelector('h1').textContent, ) assert.equal( textContent, 'Hello from Internal State', 'Should have initial heading from internal state', ) }) it('should update when state is set', async function () { const causalComponent = document.getElementById('causal') causalComponent.heading = 'Hello from State' await animationFrame() const textContent = normalizeText( causalComponent.querySelector('h1').textContent, ) assert.equal( textContent, 'Hello from State', 'Should update text content from setting heading state', ) }) it('should update after a delay when state is set', async function () { const causalComponent = document.getElementById('causal') const delay = Math.floor(Math.random() * 200) await wait(delay) causalComponent.heading = 'Hello from Delayed State' await animationFrame() const textContent = normalizeText( causalComponent.querySelector('h1').textContent, ) assert.equal( textContent, 'Hello from Delayed State', 'Should update text content from setting heading state after a delay', ) }) it('should ignore non-observed attributes', async function () { const causalComponent = document.getElementById( 'causal-with-ignored-attribute', ) await animationFrame() const textContent = normalizeText( causalComponent.querySelector('h1').textContent, ) assert.equal( textContent, 'Hello from Internal State', 'Should have initial heading from internal state', ) }) }) describe('Updating component', function () { it('should do nothing if attribute is not set', async function () { const updatingComponent = document.getElementById('updating') await animationFrame() const textContent = normalizeText( updatingComponent.querySelector('h1').textContent, ) assert.equal( textContent, 'Hello from Server', 'Should not change server-side rendered heading', ) }) it('should update from initial string attribute', async function () { const updatingComponent = document.getElementById( 'updating-with-string-attribute', ) await animationFrame() const textContent = normalizeText( updatingComponent.querySelector('h1').textContent, ) assert.equal( textContent, 'Hello from Attribute', 'Should have initial heading from string attribute', ) }) it('should update from initial integer number attribute', async function () { const updatingComponent = document.getElementById( 'updating-with-number-attribute', ) await animationFrame() const textContent = normalizeText( updatingComponent.querySelector('span').textContent, ) assert.equal( textContent, '42', 'Should have initial count from numeric attribute', ) }) it('should update from initial floating point number attribute', async function () { const updatingComponent = document.getElementById( 'updating-with-number-attribute', ) await animationFrame() const stepAttribute = updatingComponent .querySelector('input') .getAttribute('step') assert.equal( stepAttribute, '0.1', 'Should have initial step attribute from floating point number attribute', ) }) it('should update from initial custom parser attribute', async function () { const updatingComponent = document.getElementById( 'updating-with-number-attribute', ) await animationFrame() const valueAttribute = updatingComponent.querySelector('input').value assert.equal( valueAttribute, '3.14', 'Should have initial value attribute from custom parser attribute', ) }) it('should add class from boolean attribute', async function () { const updatingComponent = document.getElementById( 'updating-with-boolean-attribute', ) await animationFrame() const className = updatingComponent.querySelector('h1').className assert.equal( className, 'selected', 'Should have initial class from boolean attribute', ) }) it('should update when string attribute set', async function () { const updatingComponent = document.getElementById( 'updating-with-string-attribute', ) updatingComponent.setAttribute( 'heading', 'Hello from Changed Attribute', ) await animationFrame() const textContent = normalizeText( updatingComponent.querySelector('h1').textContent, ) assert.equal( textContent, 'Hello from Changed Attribute', 'Should update text content from setting heading attribute', ) }) it('should update when numeric attribute is set', async function () { const updatingComponent = document.getElementById( 'updating-with-number-attribute', ) updatingComponent.setAttribute('count', '0') await animationFrame() const textContent = normalizeText( updatingComponent.querySelector('span').textContent, ) assert.equal( textContent, '0', 'Should update text content from setting count attribute', ) }) it('should update when numeric state is set', async function () { const updatingComponent = document.getElementById( 'updating-with-number-attribute', ) updatingComponent.step = 1 await animationFrame() const stepAttribute = updatingComponent .querySelector('input') .getAttribute('step') assert.equal( stepAttribute, '1', 'Should update step attribute of input element from setting step state', ) }) it('should update when numeric attribute is set, parsed as integer', async function () { const updatingComponent = document.getElementById( 'updating-with-number-attribute', ) updatingComponent.setAttribute('value', '1.14') await animationFrame() const valueAttribute = updatingComponent.querySelector('input').value assert.equal( valueAttribute, '1', 'Should update value attribute of input element from setting value attribute and parse it as defined', ) }) it('should remove class when boolean attribute removed', async function () { const updatingComponent = document.getElementById( 'updating-with-boolean-attribute', ) updatingComponent.removeAttribute('selected') await animationFrame() const className = updatingComponent.querySelector('h1').className assert.equal( className, '', 'Should remove class from removing selected attribute', ) }) it('should update when state is set', async function () { const updatingComponent = document.getElementById( 'updating-with-string-attribute', ) updatingComponent.heading = 'Hello from State' await animationFrame() const textContent = normalizeText( updatingComponent.querySelector('h1').textContent, ) assert.equal( textContent, 'Hello from State', 'Should update text content from setting heading state', ) }) }) describe('My counter', function () { it('should increment and decrement', async function () { const counter = document.querySelector('basic-counter') const decrement = counter.querySelector('.decrement') const increment = counter.querySelector('.increment') const value = counter.querySelector('.value') assert.equal( counter.value, 42, 'Should have initial value from attribute', ) assert.equal( normalizeText(value.textContent), '42', 'Should have initial textContent from attribute', ) decrement.click() assert.equal( counter.value, 41, 'Should decrement value', ) await animationFrame() assert.equal( normalizeText(value.textContent), '41', 'Should have updated textContent from decrement', ) increment.click() assert.equal( counter.value, 42, 'Should increment value', ) await animationFrame() assert.equal( normalizeText(value.textContent), '42', 'Should have updated textContent from increment', ) }) it('should update derived values', async function () { const counter = document.querySelector('basic-counter') const decrement = counter.querySelector('.decrement') const parity = counter.querySelector('.parity') const double = counter.querySelector('.double') assert.equal( normalizeText(parity.textContent), 'even', 'Should have derived parity textContent from attribute', ) assert.equal( normalizeText(double.textContent), '84', 'Should have derived double textContent from attribute', ) decrement.click() await animationFrame() assert.equal( normalizeText(parity.textContent), 'odd', 'Should have changed derived parity textContent', ) assert.equal( normalizeText(double.textContent), '82', 'Should have decremented derived double textContent', ) }) }) describe('Greeting Configurator', function () { it('should display greeting', async function () { const configurator = document.querySelector( 'greeting-configurator', ) const helloWorld = configurator.querySelector('hello-world') const greeting = helloWorld.querySelector('p') assert.equal( normalizeText(greeting.textContent), 'Hello Jane', 'Should have initial greeting', ) helloWorld.greeting = 'Hi' await animationFrame() assert.equal( normalizeText(greeting.textContent), 'Hi Jane', 'Should have updated greeting from state', ) }) it('should update name if first name changes', async function () { const configurator = document.querySelector( 'greeting-configurator', ) const first = configurator.querySelector('.first') const input = first.querySelector('input') const helloWorld = configurator.querySelector('hello-world') const greeting = helloWorld.querySelector('p') input.value = 'Esther' input.dispatchEvent(new Event('change')) await animationFrame() assert.equal( normalizeText(greeting.textContent), 'Hi Esther', 'Should update if first name changes', ) }) it('should not update name if last name changes', async function () { const configurator = document.querySelector( 'greeting-configurator', ) const last = configurator.querySelector('.last') const input = last.querySelector('input') const helloWorld = configurator.querySelector('hello-world') const greeting = helloWorld.querySelector('p') input.value = 'Brunner' input.dispatchEvent(new Event('change')) await animationFrame() assert.equal( normalizeText(greeting.textContent), 'Hi Esther', 'Should not update if last name changes', ) }) it('should update greeting if use fullname is checked or unchecked', async function () { const configurator = document.querySelector( 'greeting-configurator', ) const fullname = configurator.querySelector('form-checkbox') const input = fullname.querySelector('input') const helloWorld = configurator.querySelector('hello-world') const greeting = helloWorld.querySelector('p') input.checked = true input.dispatchEvent(new Event('change')) await animationFrame() assert.equal( normalizeText(greeting.textContent), 'Hi Esther Brunner', 'Should update if use fullname is checked', ) input.checked = false input.dispatchEvent(new Event('change')) await animationFrame() assert.equal( normalizeText(greeting.textContent), 'Hi Esther', 'Should update if use fullname is unchecked', ) }) }) describe('Lazy Load', function () { it('should display loading status initially', function () { const lazyComponent = document.getElementById('lazy-success') assert.equal( normalizeText( lazyComponent.querySelector('.loading') .textContent, ), 'Loading...', ) }) it('should display lazy loaded content', async function () { const lazyComponent = document.getElementById('lazy-success') await wait(LOADING_DELAY) const shadow = lazyComponent.shadowRoot assert.instanceOf( shadow, DocumentFragment, 'Should have a shadow root', ) assert.equal( normalizeText( shadow.querySelector('p').textContent, ), 'Lazy loaded content', 'Should display lazy loaded content', ) assert.equal( lazyComponent.querySelector('.error').hidden, true, 'Should hide error container', ) assert.equal( lazyComponent.querySelector('.loading') === null, true, 'Should remove loading status', ) }) it('should display error message', async function () { const lazyComponent = document.getElementById('lazy-error') await wait(LOADING_DELAY) assert.equal( normalizeText( lazyComponent.querySelector('.error') .textContent, ), 'Not Found', 'Should display error message', ) assert.equal( lazyComponent.querySelector('.loading') === null, true, 'Should remove loading status', ) }) it('should prevent recursive loading', async function () { const lazyComponent = document.getElementById('lazy-recursion') await wait(LOADING_DELAY) const shadow = lazyComponent.shadowRoot assert.instanceOf( shadow, DocumentFragment, 'Should have a shadow root', ) const lazySubComponent = shadow.querySelector('module-lazy') const errorElement = lazySubComponent.querySelector('.error') assert.isFalse( errorElement.hidden, 'Error message should be visible', ) assert.equal( errorElement.textContent, 'Recursive loading detected', 'Should display recursion error message', ) }) }) describe('Tab Group', function () { it('should mark the first button as active', async function () { const tabGroup = document.querySelector('module-tabgroup') const buttons = tabGroup.querySelectorAll('button') await animationFrame() assert.equal( buttons[0].ariaSelected, 'true', 'Should have the first button marked as active', ) }) it('should change the active tab when a button is clicked', async function () { const tabGroup = document.querySelector('module-tabgroup') const buttons = tabGroup.querySelectorAll('button') buttons[1].click() await animationFrame() assert.equal( buttons[0].ariaSelected, 'false', 'Should have the first button marked as inactive', ) assert.equal( buttons[1].ariaSelected, 'true', 'Should have the second button marked as active', ) }) it('should display the content of the active tab', async function () { const tabGroup = document.querySelector('module-tabgroup') const buttons = tabGroup.querySelectorAll('button') await animationFrame() assert.equal( tabGroup .querySelectorAll('[role="tabpanel"]')[1] .hasAttribute('hidden'), false, 'Should mark the second tabpanel as visible', ) buttons[0].click() await animationFrame() assert.equal( tabGroup .querySelectorAll('[role="tabpanel"]')[1] .hasAttribute('hidden'), true, 'Should mark the second tabpanel as hidden', ) assert.equal( tabGroup .querySelectorAll('[role="tabpanel"]')[0] .hasAttribute('hidden'), false, 'Should mark the first tabpanel as visible', ) }) }) describe('InsertRecursion', function () { it('should not cause infinite loops or memory leaks', async function () { const parentCount = document.querySelectorAll('recursion-parent').length const childCount = document.querySelectorAll('recursion-child').length assert.isAtMost( parentCount, 3, 'Should not create more than 3 recursion-parent components', ) assert.isAtMost( childCount, 3, 'Should not create more than 3 recursion-child components', ) }) }) describe('Component Constructor Edge Cases', function () { it('should throw error for reserved property names', function () { const name = 'invalid-reserved' component( name, { validProp: asString('valid'), }, () => [], ) const instance = document.createElement(name) assert.throws( () => { instance.setSignal( 'constructor', state('invalid'), ) }, TypeError, 'Property name "constructor" is a reserved word', ) }) it('should throw error for HTMLElement property conflicts', function () { // Test that innerHTML property is properly blocked const name = 'invalid-htmlelement' component( name, { validProp: asString('valid'), }, () => [], ) const instance = document.createElement(name) // innerHTML should be blocked as it's an HTMLElement property assert.throws( () => { instance.setSignal( 'innerHTML', state('invalid'), ) }, TypeError, 'Property name "innerHTML" conflicts with inherited HTMLElement property', ) }) it('should handle setSignal with invalid signal type', function () { const name = 'test-setsignal' component(name, {}, () => []) const instance = document.createElement(name) assert.throws( () => { instance.setSignal( 'invalidProp', 'not a signal', ) }, TypeError, 'Expected signal as value', ) }) it('should handle multiple signal assignments to same property', function () { const name = 'test-multiple-signals' component( name, { value: asString('initial'), }, () => [], ) const instance = document.createElement(name) const newSignal = state('new value') // Should not throw when reassigning signal assert.doesNotThrow(() => { instance.setSignal('value', newSignal) }) assert.equal(instance.value, 'new value') }) it('should throw error for reserved property names during component definition', function () { assert.throws( () => { component( 'invalid-reserved-def', { constructor: asString('invalid'), }, () => [], ) }, TypeError, 'Property name "constructor" is a reserved word in component "invalid-reserved-def"', ) }) it('should throw error for HTMLElement property conflicts during component definition', function () { assert.throws( () => { component( 'invalid-htmlelement-def', { innerHTML: asString('invalid'), }, () => [], ) }, TypeError, 'Property name "innerHTML" conflicts with inherited HTMLElement property in component "invalid-htmlelement-def"', ) }) it('should throw error for multiple invalid properties during component definition', function () { assert.throws( () => { component( 'invalid-multiple-def', { validProp: asString('valid'), className: asString('invalid'), anotherValid: asString('valid'), }, () => [], ) }, TypeError, 'Property name "className" conflicts with inherited HTMLElement property in component "invalid-multiple-def"', ) }) }) describe('Component Lifecycle Edge Cases', function () { it('should handle component disconnection during initialization', function () { let initCalled = false let connectedCalled = false let disconnectedCalled = false const name = 'test-disconnect' component(name, {}, () => { initCalled = true return [] }) const instance = document.createElement(name) document.body.appendChild(instance) // Override callbacks to track calls const originalConnected = instance.connectedCallback const originalDisconnected = instance.disconnectedCallback instance.connectedCallback = function () { connectedCalled = true originalConnected.call(this) } instance.disconnectedCallback = function () { disconnectedCalled = true originalDisconnected.call(this) } // Trigger connection instance.connectedCallback() assert.isTrue( connectedCalled, 'Connected callback should be called', ) assert.isTrue(initCalled, 'Init should be called') // Trigger disconnection instance.disconnectedCallback() assert.isTrue( disconnectedCalled, 'Disconnected callback should be called', ) document.body.removeChild(instance) }) it('should handle attribute changes before connection', function () { let parseCount = 0 const name = 'test-early-attr' component( name, { value: (host, value) => { parseCount++ return value || 'default' }, }, () => [], ) const instance = document.createElement(name) const initialCount = parseCount // Change attribute before connecting instance.attributeChangedCallback( 'value', null, 'early', ) assert.equal( parseCount - initialCount, 1, 'Parser should be called once more', ) assert.equal(instance.value, 'early') }) it('should handle multiple connect/disconnect cycles', function () { let connectCount = 0 let disconnectCount = 0 const name = 'test-cycles' component(name, {}, () => { connectCount++ return [ () => { disconnectCount++ }, ] }) const instance = document.createElement(name) document.body.appendChild(instance) const initialConnectCount = connectCount const initialDisconnectCount = disconnectCount // Multiple cycles for (let i = 0; i < 3; i++) { instance.connectedCallback() instance.disconnectedCallback() } assert.equal( connectCount - initialConnectCount, 3, 'Should handle multiple connections', ) assert.equal( disconnectCount - initialDisconnectCount, 3, 'Should handle multiple disconnections', ) document.body.removeChild(instance) }) }) describe('Attribute Parser Edge Cases', function () { it('should handle parser functions that throw errors', function () { const name = 'test-parser-error' component( name, { errorProp: (host, value) => { if (value === 'error') { throw new Error('Parser error') } return value }, }, () => [], ) const instance = document.createElement(name) // Parser should throw, but component should handle it gracefully try { instance.attributeChangedCallback( 'errorProp', null, 'error', ) } catch (error) { assert.equal(error.message, 'Parser error') } }) it('should handle computed signals in attribute parsing', function () { const name = 'test-computed-attr' component( name, { base: asString('base'), computedProp: host => { // Create computed signal manually since computed might not be imported const signal = state( `computed-${host.base}`, ) return signal }, }, () => [], ) const instance = document.createElement(name) document.body.appendChild(instance) // Signal should be set correctly assert.isString( instance.computedProp, 'Computed property should have string value', ) document.body.removeChild(instance) }) it('should handle null and undefined attribute values', function () { let nullCount = 0 const name = 'test-null-undefined' component( name, { nullProp: (host, value) => { if (value === null) nullCount++ return value || 'null-default' }, }, () => [], ) const instance = document.createElement(name) const initialCount = nullCount instance.attributeChangedCallback( 'nullProp', 'old', null, ) assert.equal( nullCount - initialCount, 1, 'Should handle null values', ) assert.equal(instance.nullProp, 'null-default') }) }) describe('Selector Function Edge Cases', function () { it('should handle complex CSS selectors with first()', function () { const name = 'test-complex-selector' component(name, {}, (_, { first }) => [ first('div[data-test="value"]:not(.hidden)', () => { // Complex selector should work }), ]) const instance = document.createElement(name) instance.innerHTML = ` <div data-test="value" class="visible">Target</div> <div data-test="value" class="hidden">Hidden</div> <div data-test="other">Other</div> ` document.body.appendChild(instance) // Should not throw with complex selector assert.doesNotThrow(() => { instance.connectedCallback() }) document.body.removeChild(instance) }) it('should handle dynamic DOM changes with all()', async function () { let attachCount = 0 let detachCount = 0 const name = 'test-dynamic-all' component(name, {}, (_, { all }) => [ all('.dynamic-item', () => { attachCount++ return () => { detachCount++ } }), ]) const instance = document.createElement(name) document.body.appendChild(instance) instance.connectedCallback() // Add elements dynamically const item1 = document.createElement('div') item1.className = 'dynamic-item' instance.appendChild(item1) await animationFrame() const item2 = document.createElement('div') item2.className = 'dynamic-item' instance.appendChild(item2) await animationFrame() // Remove elements item1.remove() await animationFrame() assert.isAtLeast( attachCount, 2, 'Should attach to dynamically added elements', ) assert.isAtLeast( detachCount, 1, 'Should detach from removed elements', ) instance.disconnectedCallback() document.body.removeChild(instance) }) it('should handle shadow DOM boundaries', function () { const name = 'test-shadow-selector' component(name, {}, (_, { first }) => [ first('.shadow-target', () => { // Should work with shadow DOM }), ]) const instance = document.createElement(name) instance.attachShadow({ mode: 'open' }) instance.shadowRoot.innerHTML = '<div class="shadow-target">Shadow content</div>' document.body.appendChild(instance) assert.doesNotThrow(() => { instance.connectedCallback() }) document.body.removeChild(instance) }) }) describe('Error Handling', function () { it('should handle setup function returning non-array', function () { // Test that setup function with non-array return is handled const name = 'test-non-array-fixed' component(name, {}, () => { return 'not an array' // Invalid return type }) const instance = document.createElement(name) assert.instanceOf( instance, HTMLElement, 'Component should be created despite setup error', ) // Verify the component was created successfully assert.equal(instance.localName, name) // Note: connectedCallback will throw when added to DOM, but this is expected // The error is caught by the test framework as an uncaught error }) it('should handle invalid initializers gracefully', function () { const name = 'test-invalid-init' component( name, { validProp: asString('valid'), invalidProp: undefined, // Invalid initializer }, () => [], ) const instance = document.createElement(name) // Should not crash with invalid initializer assert.doesNotThrow(() => { // Constructor should handle undefined initializers }) assert.equal(instance.validProp, 'valid') assert.isUndefined(instance.invalidProp) }) it('should handle setup function errors', function () { // Test that setup function with errors is handled const name = 'test-setup-error-fixed' component(name, {}, () => { throw new Error('Setup error') }) const instance = document.createElement(name) assert.instanceOf( instance, HTMLElement, 'Component should be created despite setup error', ) // Verify the component was created successfully assert.equal(instance.localName, name) // Note: connectedCallback will throw when added to DOM, but this is expected // The error is caught by the test framework as an uncaught error }) }) describe('Debug Mode', function () { it('should handle debug attribute', function () { const name = 'test-debug' component( name, { value: asString('test'), }, () => [], ) const instance = document.createElement(name) instance.setAttribute('debug', '') document.body.appendChild(instance) // Debug mode should be enabled instance.connectedCallback() assert.isTrue( instance.debug, 'Debug should be enabled with debug attribute', ) document.body.removeChild(instance) }) it('should handle debug logging without crashing', function () { const name = 'test-debug-logging' component( name, { value: asString('initial'), }, () => [], ) const instance = document.createElement(name) instance.setAttribute('debug', '') document.body.appendChild(instance) instance.connectedCallback() // Should not crash when logging debug info assert.doesNotThrow(() => { instance.getSignal('value') instance.setSignal('value', state('new')) instance.attributeChangedCallback( 'value', 'old', 'new', ) instance.disconnectedCallback() }) document.body.removeChild(instance) }) }) describe('Performance and Memory', function () { it('should handle large numbers of dynamic elements efficiently', async function () { let attachCount = 0 const ELEMENT_COUNT = 50 const name = 'test-performance' component(name, {}, (_, { all }) => [ all('.perf-item', () => { attachCount++ }), ]) const instance = document.createElement(name) document.body.appendChild(instance) instance.connectedCallback() const initialCount = attachCount // Add many elements at once for (let i = 0; i < ELEMENT_COUNT; i++) { const item = document.createElement('div') item.className = 'perf-item' item.textContent = `Item ${i}` instance.appendChild(item) } await animationFrame() assert.isAtLeast( attachCount - initialCount, ELEMENT_COUNT, 'Should handle many elements efficiently', ) instance.disconnectedCallback() document.body.removeChild(instance) }) it('should handle component cleanup without memory leaks', function () { const name = 'test-memory-cleanup' component( name, { value: asString('test'), }, () => [], ) // Create and destroy many components for (let i = 0; i < 20; i++) { const instance = document.createElement(name) document.body.appendChild(instance) instance.connectedCallback() instance.disconnectedCallback() document.body.removeChild(instance) } // Should not throw or cause memory issues assert.isTrue(true, 'Memory cleanup should work') }) }) describe('Real-world Integration', function () { it('should handle nested component communication', function () { const p = 'integration-parent' component( p, { data: asString('parent-data'), }, () => [], ) const c = 'integration-child' component( c, { inherited: asString(''), }, () => [], ) const parent = document.createElement(p) const child = document.createElement(c) parent.appendChild(child) document.body.appendChild(parent) parent.connectedCallback() child.connectedCallback() // Test signal sharing child.setSignal('inherited', parent.getSignal('data')) assert.equal(child.inherited, 'parent-data') // Test reactive updates parent.data = 'updated-data' assert.equal(child.inherited, 'updated-data') child.disconnectedCallback() parent.disconnectedCallback() document.body.removeChild(parent) }) it('should handle complex attribute parsing scenarios', function () { let parseCallCount = 0 const name = 'complex-parsing' component( name, { jsonData: (host, value) => { parseCallCount++ try { return value ? JSON.parse(value) : {} } catch { return { error: 'Invalid JSON' } } }, numericList: (host, value) => { parseCallCount++ return value ? value .split(',') .map(Number) .filter(n => !isNaN(n)) : [] }, }, () => [], ) const instance = document.createElement(name) const initialCount = parseCallCount // Test JSON parsing instance.attributeChangedCallback( 'jsonData', null, '{"test": true}', ) assert.deepEqual(instance.jsonData, { test: true }) // Test invalid JSON handling instance.attributeChangedCallback( 'jsonData', null, 'invalid json', ) assert.deepEqual(instance.jsonData, { error: 'Invalid JSON', }) // Test numeric list parsing instance.attributeChangedCallback( 'numericList', null, '1,2,3,invalid,4', ) assert.deepEqual(instance.numericList, [1, 2, 3, 4]) assert.equal( parseCallCount - initialCount, 3, 'All parsers should be called', ) }) }) }) </script> </body> </html>