@zeix/ui-element
Version:
UIElement - a HTML-first library for reactive Web Components
1,843 lines (1,674 loc) • 49.1 kB
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>