@zeix/ui-element
Version:
UIElement - a HTML-first library for reactive Web Components
1,944 lines (1,720 loc) • 66.5 kB
HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Effects Tests</title>
</head>
<body>
<update-text text="Text from Attribute">
<p>Text from Server</p>
</update-text>
<update-property value="Value from Attribute">
<input type="text" value="Value from Server" />
</update-property>
<update-attribute required>
<input type="text" required />
<p id="update-attribute-error">Please fill this required field</p>
</update-attribute>
<update-class active="0">
<ul>
<li data-index="0">Item 1</li>
<li data-index="1">Item 2</li>
<li class="selected" data-index="2">Item 3</li>
</ul>
</update-class>
<update-style color="red">
<p style="color: blue">Text from Server</p>
</update-style>
<dangerous-inner-html id="dangerous"></dangerous-inner-html>
<shadow-dangerous-inner-html
id="shadow-dangerous"
></shadow-dangerous-inner-html>
<dangerous-with-scripts></dangerous-with-scripts>
<create-element>
<ul></ul>
</create-element>
<remove-element>
<ul>
<li data-key="1">Item 1</li>
<li data-key="2">Item 2</li>
<li data-key="3">Item 3</li>
</ul>
</remove-element>
<insert-template id="insert-light">
<template class="li">
<li></li>
</template>
<template class="p">
<p></p>
</template>
<ul></ul>
</insert-template>
<insert-template id="insert-shadow">
<template class="li">
<li></li>
</template>
<template class="p">
<p></p>
</template>
<template shadowrootmode="open">
<ul></ul>
</template>
</insert-template>
<security-test>
<div></div>
</security-test>
<edge-case-test>
<div>
<span></span>
</div>
</edge-case-test>
<signal-types-test>
<p>
<span></span>
</p>
</signal-types-test>
<unset-test>
<div>
<span
title="original"
class="original"
style="color: blue"
></span>
</div>
</unset-test>
<div id="host">
<div id="test-on-function"></div>
<div id="test-on-provider"></div>
<div id="test-on-invalid"></div>
</div>
<emit-test value="test-detail">
<div>Content</div>
</emit-test>
<child-component id="orphan">
<h1>Hello from server</h1>
<p>Text from server</p>
</child-component>
<parent-component id="parent" heading="Hello from attribute">
<child-component id="child">
<h1>Hello from server</h1>
<p>Text from server</p>
</child-component>
<invalid-component id="invalid"></invalid-component>
</parent-component>
<!-- <call-method-test>
<input type="text" value="test" />
<button>Test Button</button>
</call-method-test>
<focus-test>
<input type="text" id="input1" value="first" />
<input type="text" id="input2" value="second" />
<button id="btn1">Button 1</button>
</focus-test> -->
<script type="module">
import { runTests } from '@web/test-runner-mocha'
import { assert } from '@esm-bundle/chai'
import {
RESET,
UNSET,
component,
on,
state,
computed,
effect,
asBoolean,
asInteger,
asNumber,
asString,
setText,
setProperty,
show,
setAttribute,
toggleAttribute,
toggleClass,
setStyle,
dangerouslySetInnerHTML,
insertOrRemoveElement,
updateElement,
emitEvent,
pass,
// callMethod,
// focus,
} from '../index.dev.js'
const animationFrame = async () =>
new Promise(requestAnimationFrame)
const normalizeText = text => text.replace(/\s+/g, ' ').trim()
component(
'update-text',
{
text: asString(),
},
(_, { first }) => [first('p', setText('text'))],
)
component(
'update-property',
{
value: asString(),
},
(_, { first }) => [first('input', setProperty('value'))],
)
component(
'update-attribute',
{
required: asBoolean(),
ariaInvalid: 'false',
},
(el, { first }) => [
first(
'input',
on('change', e => {
el.ariaInvalid = String(!e.target.checkValidity())
}),
toggleAttribute('required'),
setAttribute('aria-errormessage', () =>
el.required && el.ariaInvalid !== 'false'
? el.querySelector('p').id
: RESET,
),
),
],
)
component(
'update-class',
{
active: asInteger(),
},
(el, { all }) => [
all(
'li',
toggleClass(
'selected',
target =>
el.active === parseInt(target.dataset.index),
),
),
],
)
component(
'update-style',
{
color: asString(),
},
(_, { first }) => [first('p', setStyle('color'))],
)
component(
'dangerous-inner-html',
{
content: '<p>Initial content</p>',
},
() => [dangerouslySetInnerHTML('content')],
)
component(
'shadow-dangerous-inner-html',
{
content: '<p>Initial shadow content</p>',
},
() => [
dangerouslySetInnerHTML('content', {
shadowRootMode: 'open',
}),
],
)
component(
'dangerous-with-scripts',
{
content: '<p id="test-p-shadow">Original</p>',
},
() => [
dangerouslySetInnerHTML('content', {
shadowRootMode: 'open',
allowScripts: true,
}),
],
)
component(
'create-element',
{
before: 0,
prepend: 0,
append: 0,
after: 0,
},
(el, { first }) => [
first(
'ul',
insertOrRemoveElement('before', {
create: () => {
const p = document.createElement('p')
p.textContent = 'Before'
return p
},
position: 'beforebegin',
}),
insertOrRemoveElement('prepend', {
create: () => {
const li = document.createElement('li')
li.textContent = 'Prepend'
li.setAttribute('value', 'foo')
return li
},
position: 'afterbegin',
}),
insertOrRemoveElement('append', {
create: () => document.createElement('li'),
}),
insertOrRemoveElement('after', {
create: () => {
const p = document.createElement('p')
p.setAttribute('value', 'bar')
return p
},
position: 'afterend',
}),
),
],
)
component(
'remove-element',
{
items: [1, 2, 3],
},
(el, { all }) => [
all(
'li',
insertOrRemoveElement(li =>
el.items.includes(parseInt(li.dataset.key))
? 0
: -1,
),
),
],
)
component(
'insert-template',
{
before: 0,
prepend: 0,
append: 0,
after: 0,
},
(el, { first }) => {
const pTemplate = el.querySelector('.p')
const liTemplate = el.querySelector('.li')
return [
first(
'ul',
insertOrRemoveElement('before', {
create: () =>
document.importNode(pTemplate.content, true)
.firstElementChild,
position: 'beforebegin',
}),
insertOrRemoveElement('prepend', {
create: () =>
document.importNode(
liTemplate.content,
true,
).firstElementChild,
position: 'afterbegin',
}),
insertOrRemoveElement('append', {
create: () =>
document.importNode(
liTemplate.content,
true,
).firstElementChild,
}),
insertOrRemoveElement('after', {
create: () =>
document.importNode(pTemplate.content, true)
.firstElementChild,
position: 'afterend',
}),
),
]
},
)
component(
'security-test',
{
hrefValue: 'https://example.com',
onclickValue: "alert('xss')",
},
() => [],
)
component(
'edge-case-test',
{
value: 'test',
errorProp: 'error',
},
() => [],
)
component(
'signal-types-test',
{
textValue: 'signal text',
},
() => [],
)
component(
'unset-test',
{
titleValue: asString(RESET),
classValue: asString(RESET),
colorValue: asString(RESET),
},
() => [],
)
component(
'emit-test',
{
value: asString(),
other: '',
},
(el, { first }) => [
emitEvent('custom-event', 'value'),
on('other-event', e => {
el.other = e.detail
}),
first(
'div',
emitEvent('other-event', 'value'),
emitEvent(
'test-event',
target => el.value === target.textContent,
),
),
],
)
component(
'child-component',
{
heading: asString(RESET),
text: asString(RESET),
},
(_, { first }) => [
first('h1', setText('heading')),
first('p', setText('text')),
],
)
component(
'parent-component',
{
heading: asString(RESET),
},
(el, { first }) => [
first(
'child-component',
pass({
heading: 'heading',
text: () => el.heading.toUpperCase(),
}),
),
],
)
// component(
// 'call-method-test',
// {
// shouldClick: asBoolean(false),
// shouldSelect: asBoolean(false),
// },
// (el, { first }) => [
// first(
// 'button',
// callMethod('click', el => el.shouldClick),
// ),
// first(
// 'input',
// callMethod(
// 'setSelectionRange',
// el => el.shouldSelect,
// [0, 2],
// ),
// ),
// ],
// )
// component(
// 'focus-test',
// {
// focusFirst: asBoolean(false),
// focusSecond: asBoolean(false),
// focusButton: asBoolean(false),
// },
// (el, { first }) => [
// first(
// '#input1',
// focus(el => el.focusFirst),
// ),
// first(
// '#input2',
// focus(el => el.focusSecond),
// ),
// first(
// '#btn1',
// focus(el => el.focusButton),
// ),
// ],
// )
runTests(() => {
describe('setText', function () {
it('should prove setText() working correctly', async function () {
const component = document.querySelector('update-text')
const paragraph = component.querySelector('p')
await animationFrame()
let textContent = normalizeText(paragraph.textContent)
assert.equal(
textContent,
'Text from Attribute',
'Should display text content from attribute',
)
component.text = 'New Text'
await animationFrame()
textContent = normalizeText(paragraph.textContent)
assert.equal(
textContent,
'New Text',
'Should update text content from text signal',
)
component.text = RESET
await animationFrame()
textContent = normalizeText(paragraph.textContent)
assert.equal(
textContent,
'Text from Server',
'Should revert text content to server-rendered version',
)
})
})
describe('setProperty', function () {
it('should prove setProperty() working correctly', async function () {
const component =
document.querySelector('update-property')
const input = component.querySelector('input')
await animationFrame()
assert.equal(
input.value,
'Value from Attribute',
'Should display value from attribute',
)
component.value = 'New Value'
await animationFrame()
assert.equal(
input.value,
'New Value',
'Should update value from text signal',
)
component.value = RESET
await animationFrame()
assert.equal(
input.value,
'Value from Server',
'Should revert value to server-rendered version',
)
})
})
describe('setAttribute and toggleAttribute', function () {
it('should prove setAttribute() and toggleAttribute() working correctly', async function () {
const component =
document.querySelector('update-attribute')
const input = component.querySelector('input')
await animationFrame()
assert.equal(
input.required,
true,
'Should set required property from attribute',
)
assert.equal(
input.hasAttribute('aria-errormessage'),
false,
'Should not have aria-errormessage before interaction',
)
input.value = 'New Value'
input.dispatchEvent(new Event('change'))
await animationFrame()
assert.equal(
input.hasAttribute('aria-errormessage'),
false,
'Should not have aria-errormessage if field is not empty',
)
input.value = ''
input.dispatchEvent(new Event('change'))
await animationFrame()
assert.equal(
input.hasAttribute('aria-errormessage'),
true,
'Should have aria-errormessage if field is empty and required',
)
component.toggleAttribute('required')
await animationFrame()
assert.equal(
input.hasAttribute('aria-errormessage'),
false,
'Should not have aria-errormessage if field is not required',
)
component.required = RESET
await animationFrame()
assert.equal(
input.required,
true,
'Should revert required attribute to server-rendered version',
)
})
})
describe('toggleClass', function () {
it('should prove toggleClass() working correctly', async function () {
const component = document.querySelector('update-class')
const items = Array.from(
component.querySelectorAll('li'),
)
await animationFrame()
assert.equal(
items[0].classList.contains('selected'),
true,
'First item should have selected class from active attribute',
)
assert.equal(
items[2].classList.contains('selected'),
false,
'Third item should not have selected class removed',
)
component.active = 1
await animationFrame()
assert.equal(
items[1].classList.contains('selected'),
true,
'Second item should have selected class from active signal',
)
assert.equal(
items[0].classList.contains('selected'),
false,
'First item should not have selected class removed',
)
component.active = RESET
await animationFrame()
assert.equal(
items[1].classList.contains('selected'),
false,
'Second item should have selected class removed',
)
// restore can't work because the selected class for each item is derived on the fly and not stored in a signal
// assert.equal(items[2].classList.contains('selected'), true, 'Third item should not have selected class restored to server-rendered version')
})
})
describe('setStyle', function () {
it('should prove setStyle() working correctly', async function () {
const component = document.querySelector('update-style')
const paragraph = component.querySelector('p')
await animationFrame()
assert.equal(
paragraph.style.color,
'red',
'Should set color from attribute',
)
component.color = 'green'
await animationFrame()
assert.equal(
paragraph.style.color,
'green',
'Should update color from color signal',
)
component.color = RESET
await animationFrame()
assert.equal(
paragraph.style.color,
'blue',
'Should revert color to server-rendered version',
)
})
})
describe('show', function () {
it('should hide element when predicate is false', async function () {
const element = document.createElement('div')
element.textContent = 'Test content'
document.body.appendChild(element)
// Create a mock component as host
const host = { getSignal: () => ({ get: () => false }) }
try {
// Initially visible
assert.equal(element.hidden, false)
// Apply show with false predicate
const cleanup = show(() => false)(host, element)
await animationFrame()
assert.equal(
element.hidden,
true,
'Element should be hidden when predicate is false',
)
cleanup()
} finally {
element.remove()
}
})
it('should show element when predicate is true', async function () {
const element = document.createElement('div')
element.textContent = 'Test content'
element.hidden = true // Start hidden
document.body.appendChild(element)
// Create a mock component as host
const host = { getSignal: () => ({ get: () => true }) }
try {
// Initially hidden
assert.equal(element.hidden, true)
// Apply show with true predicate
const cleanup = show(() => true)(host, element)
await animationFrame()
assert.equal(
element.hidden,
false,
'Element should be visible when predicate is true',
)
cleanup()
} finally {
element.remove()
}
})
it('should work with reactive signals like module-todo pattern', async function () {
// Create reactive signals for active tasks
const activeSignal = state([])
const singularElement = document.createElement('span')
singularElement.className = 'singular'
singularElement.textContent = 'task'
const pluralElement = document.createElement('span')
pluralElement.className = 'plural'
pluralElement.textContent = 'tasks'
const remainingElement = document.createElement('div')
remainingElement.className = 'remaining'
const allDoneElement = document.createElement('div')
allDoneElement.className = 'all-done'
allDoneElement.textContent = 'All done!'
document.body.appendChild(singularElement)
document.body.appendChild(pluralElement)
document.body.appendChild(remainingElement)
document.body.appendChild(allDoneElement)
try {
// Create a mock host component
const host = {
getSignal: () => ({ get: () => true }),
}
// Apply show effects like in module-todo using reactive signals
const cleanup1 = show(
() => activeSignal.get().length === 1,
)(host, singularElement)
const cleanup2 = show(
() => activeSignal.get().length > 1,
)(host, pluralElement)
const cleanup3 = show(
() => !!activeSignal.get().length,
)(host, remainingElement)
const cleanup4 = show(
() => !activeSignal.get().length,
)(host, allDoneElement)
await animationFrame()
// Initially no active tasks
assert.equal(
singularElement.hidden,
true,
'Singular should be hidden with 0 tasks',
)
assert.equal(
pluralElement.hidden,
true,
'Plural should be hidden with 0 tasks',
)
assert.equal(
remainingElement.hidden,
true,
'Remaining should be hidden with 0 tasks',
)
assert.equal(
allDoneElement.hidden,
false,
'All done should be visible with 0 tasks',
)
// Add one task
activeSignal.set(['task1'])
await animationFrame()
assert.equal(
singularElement.hidden,
false,
'Singular should be visible with 1 task',
)
assert.equal(
pluralElement.hidden,
true,
'Plural should be hidden with 1 task',
)
assert.equal(
remainingElement.hidden,
false,
'Remaining should be visible with 1 task',
)
assert.equal(
allDoneElement.hidden,
true,
'All done should be hidden with 1 task',
)
// Add more tasks
activeSignal.set(['task1', 'task2', 'task3'])
await animationFrame()
assert.equal(
singularElement.hidden,
true,
'Singular should be hidden with 3 tasks',
)
assert.equal(
pluralElement.hidden,
false,
'Plural should be visible with 3 tasks',
)
assert.equal(
remainingElement.hidden,
false,
'Remaining should be visible with 3 tasks',
)
assert.equal(
allDoneElement.hidden,
true,
'All done should be hidden with 3 tasks',
)
cleanup1()
cleanup2()
cleanup3()
cleanup4()
} finally {
singularElement.remove()
pluralElement.remove()
remainingElement.remove()
allDoneElement.remove()
}
})
it('should work with property name strings', async function () {
// Create reactive component with signal
const visibilitySignal = state(true)
const component = {
isVisible: true,
getSignal: function (prop) {
if (prop === 'isVisible')
return visibilitySignal
return { get: () => this[prop] }
},
}
const element = document.createElement('div')
document.body.appendChild(element)
try {
// Use property name string
const cleanup = show('isVisible')(
component,
element,
)
await animationFrame()
assert.equal(
element.hidden,
false,
'Element should be visible when isVisible is true',
)
// Change signal value (not direct property)
visibilitySignal.set(false)
await animationFrame()
assert.equal(
element.hidden,
true,
'Element should be hidden when isVisible is false',
)
cleanup()
} finally {
element.remove()
}
})
it('should work with signal objects directly', async function () {
const signal = state(true)
const element = document.createElement('div')
document.body.appendChild(element)
// Create a mock host component
const host = { getSignal: () => ({ get: () => true }) }
try {
const cleanup = show(signal)(host, element)
await animationFrame()
assert.equal(
element.hidden,
false,
'Element should be visible when signal is true',
)
// Change signal value
signal.set(false)
await animationFrame()
assert.equal(
element.hidden,
true,
'Element should be hidden when signal is false',
)
// Change back
signal.set(true)
await animationFrame()
assert.equal(
element.hidden,
false,
'Element should be visible again when signal is true',
)
cleanup()
} finally {
element.remove()
}
})
it('should handle clear button pattern from form-textbox', async function () {
// Create reactive signal for input length
const lengthSignal = state(0)
const clearButton = document.createElement('button')
clearButton.className = 'clear'
clearButton.textContent = '✕'
clearButton.hidden = true // Initially hidden
document.body.appendChild(clearButton)
try {
// Create a mock host component
const host = {
getSignal: () => ({ get: () => true }),
}
// Apply show effect like in form-textbox using reactive signal
const cleanup = show(() => !!lengthSignal.get())(
host,
clearButton,
)
await animationFrame()
// Initially no input, button should be hidden
assert.equal(
clearButton.hidden,
true,
'Clear button should be hidden when input is empty',
)
// Type some text
lengthSignal.set(5)
await animationFrame()
assert.equal(
clearButton.hidden,
false,
'Clear button should be visible when input has text',
)
// Clear input
lengthSignal.set(0)
await animationFrame()
assert.equal(
clearButton.hidden,
true,
'Clear button should be hidden when input is cleared',
)
cleanup()
} finally {
clearButton.remove()
}
})
it('should handle RESET and UNSET values properly', async function () {
const element = document.createElement('div')
element.hidden = true // Start with hidden
document.body.appendChild(element)
try {
// Create a mock host component
const host = {
getSignal: () => ({ get: () => true }),
}
// Test RESET - should revert to original DOM value
const cleanup1 = show(() => RESET)(host, element)
await animationFrame()
assert.equal(
element.hidden,
true,
'RESET should maintain original hidden state',
)
cleanup1()
// Test UNSET - should delete/fallback
const cleanup2 = show(() => UNSET)(host, element)
await animationFrame()
assert.equal(
element.hidden,
true,
'UNSET should fallback to original hidden state',
)
cleanup2()
} finally {
element.remove()
}
})
})
describe('dangerouslySetInnerHTML', () => {
let dangerous, shadowDangerous
before(() => {
dangerous = document.getElementById('dangerous')
shadowDangerous =
document.getElementById('shadow-dangerous')
})
it('should set inner HTML for non-shadow component', async () => {
assert.equal(
dangerous.innerHTML,
'<p>Initial content</p>',
)
dangerous.content = '<div>Updated content</div>'
await animationFrame()
assert.equal(
dangerous.innerHTML,
'<div>Updated content</div>',
)
})
it('should set inner HTML for shadow component', async () => {
assert.equal(
shadowDangerous.shadowRoot.innerHTML,
'<p>Initial shadow content</p>',
)
shadowDangerous.content =
'<div>Updated shadow content</div>'
await animationFrame()
assert.equal(
shadowDangerous.shadowRoot.innerHTML,
'<div>Updated shadow content</div>',
)
})
it('should ignore empty content', async () => {
dangerous.content = ''
await animationFrame()
assert.equal(
dangerous.innerHTML,
'<div>Updated content</div>',
)
shadowDangerous.content = ''
await animationFrame()
assert.equal(
shadowDangerous.shadowRoot.innerHTML,
'<div>Updated shadow content</div>',
)
})
it('should not execute scripts by default, but allow execution when specified', async () => {
const scriptContent =
'ipt>document.getElementById("test-p").textContent = "Modified";</scr'
const shadowScriptContent =
'ipt>document.querySelector("dangerous-with-scripts").shadowRoot.getElementById("test-p-shadow").textContent = "Modified";</scr'
// Test default behavior (scripts not executed)
dangerous.content = `<p id="test-p">Original</p><scr${scriptContent}ipt>`
await animationFrame()
assert.equal(
dangerous.querySelector('#test-p').textContent,
'Original',
'Script should not modify content by default',
)
const dangerousWithScripts = document.querySelector(
'dangerous-with-scripts',
)
dangerousWithScripts.content = `<p id="test-p-shadow">Original</p><scr${shadowScriptContent}ipt>`
await animationFrame()
assert.equal(
dangerousWithScripts.shadowRoot.querySelector(
'#test-p-shadow',
).textContent,
'Modified',
'Script should modify content when allowScripts is true',
)
})
})
describe('createElement', function () {
let createElementComponent
before(() => {
createElementComponent =
document.querySelector('create-element')
})
it('should insert a paragraph before the UL', async function () {
createElementComponent.before = 1
await animationFrame()
const insertedParagraph =
createElementComponent.querySelector(
'p:first-child',
)
assert.isNotNull(
insertedParagraph,
'Paragraph should be inserted before the UL',
)
assert.equal(
insertedParagraph.textContent,
'Before',
'Paragraph should have correct text content',
)
assert.equal(
createElementComponent.before,
0,
'Before signal should be reset to 0',
)
})
it('should insert a LI at the beginning of the UL', async function () {
createElementComponent.prepend = 1
await animationFrame()
const insertedLi =
createElementComponent.querySelector(
'ul li:first-child',
)
assert.isNotNull(
insertedLi,
'LI should be inserted at the beginning of the UL',
)
assert.equal(
insertedLi.textContent,
'Prepend',
'LI should have correct text content',
)
assert.equal(
insertedLi.getAttribute('value'),
'foo',
'LI should have correct attribute',
)
assert.equal(
createElementComponent.prepend,
0,
'Prepend signal should be reset to 0',
)
})
it('should insert a LI at the end of the UL', async function () {
createElementComponent.append = 1
await animationFrame()
const insertedLi =
createElementComponent.querySelector(
'ul li:last-child',
)
assert.isNotNull(
insertedLi,
'LI should be inserted at the end of the UL',
)
assert.equal(
insertedLi.textContent,
'',
'LI should have empty text content',
)
assert.equal(
createElementComponent.append,
0,
'Append signal should be reset to 0',
)
})
it('should insert a paragraph after the UL', async function () {
createElementComponent.after = 1
await animationFrame()
const insertedParagraph =
createElementComponent.querySelector('ul + p')
assert.isNotNull(
insertedParagraph,
'Paragraph should be inserted after the UL',
)
assert.equal(
insertedParagraph.textContent,
'',
'Paragraph should have empty text content',
)
assert.equal(
insertedParagraph.getAttribute('value'),
'bar',
'Paragraph should have correct attribute',
)
assert.equal(
createElementComponent.after,
0,
'After signal should be reset to 0',
)
})
it('should allow re-triggering effects', async function () {
// Re-trigger the 'before' effect
createElementComponent.before = true
await animationFrame()
const beforeParagraph2 =
createElementComponent.querySelector(
'p:first-child + p',
)
assert.isNotNull(
beforeParagraph2,
'LI should be inserted at the beginning of the UL',
)
// Re-trigger the 'prepend' effect
createElementComponent.prepend = true
await animationFrame()
const prependLis =
createElementComponent.querySelectorAll(
'li[value="foo"]',
)
assert.equal(
prependLis.length,
2,
'Should insert another LI at the beginning of the UL',
)
// Verify that all signals are reset to false
assert.equal(
createElementComponent.before,
false,
'Before signal should be reset to false',
)
assert.equal(
createElementComponent.prepend,
false,
'Prepend signal should be reset to false',
)
assert.equal(
createElementComponent.append,
false,
'Append signal should be reset to false',
)
assert.equal(
createElementComponent.after,
false,
'After signal should be reset to false',
)
})
})
describe('removeElement', function () {
let removeElementComponent
before(() => {
removeElementComponent =
document.querySelector('remove-element')
})
it('should remove an item using immutable update (toSpliced)', async function () {
removeElementComponent.items =
removeElementComponent.items.toSpliced(1, 1)
await animationFrame()
const items =
removeElementComponent.querySelectorAll('li')
assert.equal(
items.length,
2,
'Should have 2 items after removal',
)
assert.equal(
items[0].textContent,
'Item 1',
'First item should remain',
)
assert.equal(
items[1].textContent,
'Item 3',
'Third item should now be second',
)
})
/** Mutable updates don't work
* @TODO log a warning
it('should remove an item using mutable update (splice)', async function () {
removeElementComponent.set('items', v => v.splice(0, 1));
await animationFrame();
const items = removeElementComponent.querySelectorAll('li');
assert.equal(items.length, 1, 'Should have 1 item after removal');
assert.equal(items[0].textContent, 'Item 3', 'Third item should remain last');
}); */
it('should handle removing all items', async function () {
removeElementComponent.items = []
await animationFrame()
const items =
removeElementComponent.querySelectorAll('li')
assert.equal(items.length, 0, 'Should remove all items')
})
})
describe('insertTemplate', function () {
const testInsertTemplate = async id => {
const component = document.getElementById(id)
const root =
id === 'insert-shadow'
? component.shadowRoot
: component
const ul = root.querySelector('ul')
await animationFrame()
assert.equal(
ul.children.length,
0,
'Initially, ul should be empty',
)
component.before = 1
await animationFrame()
assert.equal(
ul.previousElementSibling.tagName,
'P',
'Should insert p element before ul',
)
component.prepend = 1
await animationFrame()
assert.equal(
ul.firstElementChild.tagName,
'LI',
'Should prepend li element to ul',
)
component.append = 1
await animationFrame()
assert.equal(
ul.lastElementChild.tagName,
'LI',
'Should append li element to ul',
)
component.after = 1
await animationFrame()
assert.equal(
ul.nextElementSibling.tagName,
'P',
'Should insert p element after ul',
)
}
it('should prove insertTemplate() working correctly in light DOM', async function () {
await testInsertTemplate('insert-light')
})
it('should prove insertTemplate() working correctly in Shadow DOM', async function () {
await testInsertTemplate('insert-shadow')
})
})
describe('Security Tests', function () {
let securityComponent
before(() => {
securityComponent =
document.querySelector('security-test')
})
it('should reject unsafe attributes', async function () {
const div = securityComponent.querySelector('div')
// Test that onclick attribute is rejected
let threwError = false
let errorMessage = ''
const cleanup = updateElement(() => "alert('xss')", {
op: 'a',
name: 'onclick',
read: el => el.getAttribute('onclick'),
update: (el, value) => {
// This should throw due to safeSetAttribute
if (/^on/i.test('onclick'))
throw new Error(`Unsafe attribute: onclick`)
el.setAttribute('onclick', value)
},
reject: error => {
threwError = true
errorMessage = error.message
},
})(securityComponent, div)
await animationFrame()
assert.isTrue(
threwError,
'Should throw error for onclick attribute',
)
assert.match(errorMessage, /Unsafe attribute: onclick/)
cleanup()
// Test that onmouseover attribute is rejected
threwError = false
errorMessage = ''
const cleanup2 = updateElement(() => "alert('xss')", {
op: 'a',
name: 'onmouseover',
read: el => el.getAttribute('onmouseover'),
update: (el, value) => {
// This should throw due to safeSetAttribute
if (/^on/i.test('onmouseover'))
throw new Error(
`Unsafe attribute: onmouseover`,
)
el.setAttribute('onmouseover', value)
},
reject: error => {
threwError = true
errorMessage = error.message
},
})(securityComponent, div)
await animationFrame()
assert.isTrue(
threwError,
'Should throw error for onmouseover attribute',
)
assert.match(
errorMessage,
/Unsafe attribute: onmouseover/,
)
cleanup2()
})
it('should reject unsafe URLs', async function () {
const div = securityComponent.querySelector('div')
// Test that javascript: URLs are rejected
let threwError = false
let errorMessage = ''
const cleanup = updateElement(
() => "javascript:alert('xss')",
{
op: 'a',
name: 'href',
read: el => el.getAttribute('href'),
update: (el, value) => {
// This should throw due to safeSetAttribute
if (value.includes('javascript:'))
throw new Error(
`Unsafe URL for href: ${value}`,
)
el.setAttribute('href', value)
},
reject: error => {
threwError = true
errorMessage = error.message
},
},
)(securityComponent, div)
await animationFrame()
assert.isTrue(
threwError,
'Should throw error for javascript: URL',
)
assert.match(errorMessage, /Unsafe URL/)
cleanup()
// Test that data: URLs with javascript are rejected
threwError = false
errorMessage = ''
const cleanup2 = updateElement(
() =>
"data:text/html,<script>alert('xss')<\/script>",
{
op: 'a',
name: 'src',
read: el => el.getAttribute('src'),
update: (el, value) => {
// This should throw due to safeSetAttribute
if (
value.includes('data:')
&& value.includes('script')
)
throw new Error(
`Unsafe URL for src: ${value}`,
)
el.setAttribute('src', value)
},
reject: error => {
threwError = true
errorMessage = error.message
},
},
)(securityComponent, div)
await animationFrame()
assert.isTrue(
threwError,
'Should throw error for data: URL with script',
)
assert.match(errorMessage, /Unsafe URL/)
cleanup2()
})
it('should allow safe URLs', async function () {
const div = securityComponent.querySelector('div')
// Test that https URLs are allowed
const httpsEffect = setAttribute(
'href',
() => 'https://example.com',
)(securityComponent, div)
await animationFrame()
assert.equal(
div.getAttribute('href'),
'https://example.com',
)
httpsEffect()
// Test that mailto URLs are allowed
const mailtoEffect = setAttribute(
'href',
() => 'mailto:test@example.com',
)(securityComponent, div)
await animationFrame()
assert.equal(
div.getAttribute('href'),
'mailto:test@example.com',
)
mailtoEffect()
// Test that tel URLs are allowed
const telEffect = setAttribute(
'href',
() => 'tel:+1234567890',
)(securityComponent, div)
await animationFrame()
assert.equal(
div.getAttribute('href'),
'tel:+1234567890',
)
telEffect()
// Test that relative URLs are allowed
const relativeEffect = setAttribute(
'href',
() => '/relative/path',
)(securityComponent, div)
await animationFrame()
assert.equal(div.getAttribute('href'), '/relative/path')
relativeEffect()
})
})
describe('Reactive Type Variations', function () {
let signalComponent
before(() => {
signalComponent =
document.querySelector('signal-types-test')
})
it('should work with Signal objects directly', async function () {
const span = signalComponent.querySelector('span')
const textSignal = state('direct signal')
const cleanup = setText(textSignal)(
signalComponent,
span,
)
await animationFrame()
assert.equal(span.textContent, 'direct signal')
textSignal.set('updated signal')
await animationFrame()
assert.equal(span.textContent, 'updated signal')
cleanup()
})
it('should work with function-based Reactive', async function () {
const span = signalComponent.querySelector('span')
const cleanup = setText(
element => `Function result for ${element.tagName}`,
)(signalComponent, span)
await animationFrame()
assert.equal(
span.textContent,
'Function result for SPAN',
)
cleanup()
})
it('should work with property name strings', async function () {
const span = signalComponent.querySelector('span')
const cleanup = setText('textValue')(
signalComponent,
span,
)
await animationFrame()
assert.equal(span.textContent, 'signal text')
signalComponent.textValue = 'updated text'
await animationFrame()
assert.equal(span.textContent, 'updated text')
cleanup()
})
})
describe('UNSET vs RESET vs null Handling', function () {
let unsetComponent
before(() => {
unsetComponent = document.querySelector('unset-test')
})
it('should handle UNSET by deleting attributes', async function () {
const span = unsetComponent.querySelector('span')
// Set a value first
unsetComponent.titleValue = 'test title'
const cleanup = setAttribute('title', 'titleValue')(
unsetComponent,
span,
)
await animationFrame()
assert.equal(span.getAttribute('title'), 'test title')
unsetComponent.titleValue = UNSET
await animationFrame()
assert.equal(span.hasAttribute('title'), false)
cleanup()
})
it('should handle UNSET by removing style properties', async function () {
const span = unsetComponent.querySelector('span')
// Set a value first
unsetComponent.colorValue = 'red'
const cleanup = setStyle('color', 'colorValue')(
unsetComponent,
span,
)
await animationFrame()
assert.equal(span.style.color, 'red')
unsetComponent.colorValue = UNSET
await animationFrame()
assert.equal(span.style.color, '')
cleanup()
})
it('should handle RESET by reverting to original DOM value', async function () {
const span = unsetComponent.querySelector('span')
// Debug: Check initial DOM state
console.log(
'Initial title attribute:',
span.getAttribute('title'),
)
console.log(
'Component titleValue:',
unsetComponent.titleValue,
)
// Manually set the attribute first to test the RESET behavior
span.setAttribute('title', 'original')
// First set up the effect which will read the current DOM value
const cleanup = setAttribute('title', 'titleValue')(
unsetComponent,
span,
)
// Wait for effect to initialize
await animationFrame()
// Now change the value
unsetComponent.titleValue = 'changed title'
await animationFrame()
assert.equal(
span.getAttribute('title'),
'changed title',
)
// RESET should revert to the DOM value that was read when effect was created
unsetComponent.titleValue = RESET
await animationFrame()
assert.equal(span.getAttribute('title'), 'original')
cleanup()
})
it('should handle null as deletion for deletable operations', async function () {
const span = unsetComponent.querySelector('span')
const cleanup = setAttribute('title', () => null)(
unsetComponent,
span,
)
await animationFrame()
assert.equal(span.hasAttribute('title'), false)
cleanup()
})
})
describe('Error Handling and Edge Cases', function () {
let edgeComponent
before(() => {
edgeComponent = document.querySelector('edge-case-test')
})
it('should handle SignalLike resolution errors gracefully', async function () {
const span = edgeComponent.querySelector('span')
const cleanup = setText(() => {
throw new Error('Signal resolution error')
})(edgeComponent, span)
await animationFrame()
// Should not crash, original content should remain
assert.equal(span.textContent, '')
cleanup()
})
it('should handle multiple rapid updates with deduplication', async function () {
const span = edgeComponent.querySelector('span')
let updateCount = 0
const cleanup = updateElement('value', {
op: 't',
read: () => span.textContent,
update: (el, value) => {
updateCount++
el.textContent = value
},
})(edgeComponent, span)
// Initial update happens first
await animationFrame()
const initialCount = updateCount
// Trigger multiple rapid updates
edgeComponent.value = 'update1'
edgeComponent.value = 'update2'
edgeComponent.value = 'update3'
await animationFrame()
// Should have had minimal updates due to deduplication
assert.isBelow(
updateCount - initialCount,
4,
'Should not update for every single change',
)
assert.equal(span.textContent, 'update3')
cleanup()
})
it('should handle insertOrRemoveElement with null create result', async function () {
const div = edgeComponent.querySelector('div')
const initialChildCount = div.children.length
// Use a state to trigger the insertion
const insertionState = state(1)
const cleanup = insertOrRemoveElement(insertionState, {
create: () => null, // Returns null
position: 'beforeend',
})(edgeComponent, div)
await animationFrame()
// Should not crash when create returns null and should not add children
assert.equal(div.children.length, initialChildCount)
cleanup()
})
it('should handle negative removal counts', async function () {
const div = edgeComponent.querySelector('div')
// Add some elements first
div.innerHTML =
'<span>1</span><span>2</span><span>3</span>'
const cleanup = insertOrRemoveElement(() => -2, {
position: 'beforeend',
})(edgeComponent, div)
await animationFrame()
// Should remove 2 elements from the end
assert.equal(div.children.length, 1)
assert.equal(div.children[0].textContent, '1')
cleanup()
})
})
describe('Core updateElement Function', function () {
let edgeComponent
before(() => {
edgeComponent = document.querySelector('edge-case-test')
})
it('should handle different operation types', async function () {
const span = edgeComponent.querySelector('span')
// Test attribute operation
const attrCleanup = updateElement('value', {
op: 'a',
name: 'data-test',
read: el => el.getAttribute('data-test'),
update: (el, value) =>
el.setAttribute('data-test', value),
delete: el => el.removeAttribute('data-test'),
})(edgeComponent, span)
edgeComponent.value = 'attr-value'
await animationFrame()
assert.equal(
span.getAttribute('data-test'),
'attr-value',
)
// Test class operation
const classCleanup = updateElement(() => true, {
op: 'c',
name: 'test-class',
read: el => el.classList.contains('test-class'),
update: (el, value) =>
el.classList.toggle('test-class', value),
})(edgeComponent, span)
await animationFrame()
assert.equal(
span.classList.contains('test-class'),
true,
)
attrCleanup()
classCleanup()
})
it('should call resolve/reject callbacks', async function () {
const span = edgeComponent.querySelector('span')
let resolved = false
let rejected = false
const cleanup = updateElement('value', {
op: 't',
read: el => el.textContent,
update: (el, value) => {
if (value === 'error')
throw new Error('Update error')
el.textContent = value
},
resolve: () => {
resolved = true
},
reject: () => {
rejected = true
},
})(edgeComponent, span)
// Test successful update
edgeComponent.value = 'success'
await animationFrame()
assert.equal(resolved, true)
assert.equal(span.textContent, 'success')
// Test error handling
edgeComponent.value = 'error'
await animationFrame()
assert.equal(rejected, true)
cleanup()
})
})
describe('on()', () => {
it('should attach and remove an event listener', async () => {
const div = document.getElementById('test-on-function')
let called = false
const off = on('click', () => {
called = true
})({}, div)
div.click()
const wasCalled = called
off() // Remove the event listener
called = false
div.click()
assert.equal(wasCalled, true)
assert.equal(called, false)
})
it('should throw TypeError for invalid handler', async () => {
const div = document.getElementById('test-on-invalid')
assert.throws(() => on('click', {})({}, div), TypeError)
})
it('shou