atoms-element v5.0.0
git clone https://git.pyrossh.dev/atoms-element
A simple web component library for defining your custom elements. It works on both client and server.
examples/e2e.spec.js
| 4c11234 | 1 | // Real-browser test for the example app, run with: npm run test:e2e |
| 4c11234 | 2 | // Requires Chromium once: npx playwright install chromium |
| 4c11234 | 3 | // Named .spec.js (not .test.js) so it's never picked up by a bare `node --test`. |
| 4c11234 | 4 | import { test, before, after } from 'node:test'; |
| 4c11234 | 5 | import assert from 'node:assert/strict'; |
| 4c11234 | 6 | import { spawn } from 'node:child_process'; |
| 4c11234 | 7 | import { chromium } from 'playwright'; |
| 25308a9 | 8 | import { createElement, getElement, html } from '../index.js'; |
| 4c11234 | 9 | |
| 4c11234 | 10 | const PORT = 3987; |
| 4c11234 | 11 | const BASE_URL = `http://localhost:${PORT}/`; |
| 4c11234 | 12 | |
| 4c11234 | 13 | let serverProcess; |
| 4c11234 | 14 | let browser; |
| 4c11234 | 15 | let page; |
| 4c11234 | 16 | const pageErrors = []; |
| 4c11234 | 17 | |
| 4c11234 | 18 | before(async () => { |
| 4c11234 | 19 | serverProcess = spawn(process.execPath, ['examples/server.js', String(PORT)], { stdio: 'ignore' }); |
| 4c11234 | 20 | |
| 4c11234 | 21 | const deadline = Date.now() + 15000; |
| 4c11234 | 22 | for (;;) { |
| 4c11234 | 23 | try { |
| 4c11234 | 24 | if ((await fetch(BASE_URL)).ok) break; |
| 4c11234 | 25 | } catch { |
| 4c11234 | 26 | // server not up yet |
| 4c11234 | 27 | } |
| 4c11234 | 28 | if (Date.now() > deadline) throw new Error('example server did not start in time'); |
| 4c11234 | 29 | await new Promise((resolve) => setTimeout(resolve, 200)); |
| 4c11234 | 30 | } |
| 4c11234 | 31 | |
| 4c11234 | 32 | browser = await chromium.launch(); |
| 4c11234 | 33 | page = await browser.newPage(); |
| 4c11234 | 34 | page.on('pageerror', (err) => pageErrors.push(err.message)); |
| 4c11234 | 35 | await page.goto(BASE_URL, { waitUntil: 'networkidle' }); |
| 4c11234 | 36 | await page.waitForSelector('app-counter'); |
| 4c11234 | 37 | }); |
| 4c11234 | 38 | |
| 4c11234 | 39 | after(async () => { |
| 4c11234 | 40 | await browser?.close(); |
| 4c11234 | 41 | serverProcess?.kill(); |
| 4c11234 | 42 | }); |
| 4c11234 | 43 | |
| 4c11234 | 44 | test('renders each counter exactly once, with no leftover duplicate SSR markup', async () => { |
| 4c11234 | 45 | const counters = page.locator('app-counter'); |
| 4c11234 | 46 | assert.strictEqual(await counters.count(), 2); |
| 4c11234 | 47 | for (let i = 0; i < 2; i++) { |
| e955350 | 48 | assert.strictEqual(await counters.nth(i).locator('output').count(), 1, `counter ${i} should render exactly one <output>`); |
| 4c11234 | 49 | } |
| 4c11234 | 50 | }); |
| 4c11234 | 51 | |
| ba74771 | 52 | test('seeds each counter from its count attribute, and app-total sums them with no manual sync', async () => { |
| ba74771 | 53 | const first = page.locator('app-counter').nth(0); |
| ba74771 | 54 | const second = page.locator('app-counter').nth(1); |
| e955350 | 55 | assert.strictEqual((await first.locator('output').textContent()).trim(), '5', 'first app-counter has count="5"'); |
| e955350 | 56 | assert.strictEqual((await second.locator('output').textContent()).trim(), '7', 'second app-counter has count="7"'); |
| ba74771 | 57 | // computed server-side by cheerio-parsing the already-rendered preceding |
| e955350 | 58 | // markup (both app-counter tags), via Total.watch — not a separately- |
| e955350 | 59 | // tracked total, and no shared store between the two components. |
| ba74771 | 60 | assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters: 12'); |
| ba74771 | 61 | }); |
| ba74771 | 62 | |
| ba74771 | 63 | test('clicking +/- updates the counter and the derived total', async () => { |
| 4c11234 | 64 | const first = page.locator('app-counter').nth(0); |
| 4c11234 | 65 | const second = page.locator('app-counter').nth(1); |
| 4c11234 | 66 | |
| e955350 | 67 | // Buttons carry an aria-label ("Increment"/"Decrement") that overrides their |
| e955350 | 68 | // visible "+"/"-" glyph as the accessible name, so query by that instead. |
| e955350 | 69 | await first.getByRole('button', { name: 'Increment' }).click(); |
| e955350 | 70 | await first.getByRole('button', { name: 'Increment' }).click(); |
| e955350 | 71 | await first.getByRole('button', { name: 'Increment' }).click(); |
| e955350 | 72 | await second.getByRole('button', { name: 'Increment' }).click(); |
| e955350 | 73 | await first.getByRole('button', { name: 'Decrement' }).click(); |
| 4c11234 | 74 | |
| ba74771 | 75 | // first: 5 + 3 - 1 = 7, second: 7 + 1 = 8, total: sum of both, live. Reflection |
| ba74771 | 76 | // and the MutationObserver it feeds are both async, so wait for the total to |
| ba74771 | 77 | // actually settle rather than asserting immediately after the last click. |
| ba74771 | 78 | await page.waitForFunction(() => document.querySelector('app-total h1').textContent.trim() === 'Total of 2 Counters: 15'); |
| e955350 | 79 | assert.strictEqual((await first.locator('output').textContent()).trim(), '7'); |
| e955350 | 80 | assert.strictEqual((await second.locator('output').textContent()).trim(), '8'); |
| ba74771 | 81 | assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters: 15'); |
| ba74771 | 82 | }); |
| ba74771 | 83 | |
| ba74771 | 84 | test('external attribute writes are adopted directly, with no separate internal state to conflict with', async () => { |
| ba74771 | 85 | // Continuing from the previous test, the first counter currently shows 7. |
| 582dce0 | 86 | // count now lives only in the attribute (via prop) — there's no reducer |
| ba74771 | 87 | // value to reconcile it against, so an external write just is the new |
| e955350 | 88 | // state, and app-total (via its watch declaration) picks it up automatically. |
| ba74771 | 89 | const first = page.locator('app-counter').nth(0); |
| ba74771 | 90 | await first.evaluate((el) => el.setAttribute('count', '30')); |
| e955350 | 91 | await page.waitForFunction(() => document.querySelector('app-counter').querySelector('output').textContent.trim() === '30'); |
| e955350 | 92 | assert.strictEqual((await first.locator('output').textContent()).trim(), '30'); |
| ba74771 | 93 | await page.waitForFunction(() => document.querySelector('app-total h1').textContent.trim() === 'Total of 2 Counters: 38'); |
| ba74771 | 94 | assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters: 38'); |
| 4c11234 | 95 | }); |
| 4c11234 | 96 | |
| 4c11234 | 97 | test('no console/page errors were thrown', () => { |
| 4c11234 | 98 | assert.deepStrictEqual(pageErrors, []); |
| 4c11234 | 99 | }); |
| c13e41f | 100 | |
| 25308a9 | 101 | test('hydration reuses the exact SSR DOM node, so focus survives it naturally', async () => { |
| 25308a9 | 102 | // Server-render the fixture the same way examples/server.js does, so the |
| 25308a9 | 103 | // markup handed to the browser has real lit hydration markers rather than |
| 25308a9 | 104 | // hand-typed HTML — hydrate() only works against genuine SSR structure. |
| 25308a9 | 105 | const Comp = () => html`<div class="wrap"><button>-</button><button>+</button></div>`; |
| 25308a9 | 106 | createElement({ url: '/focus-test-element.js' }, Comp); |
| 25308a9 | 107 | const ssrMarkup = new (getElement('focus-test-element'))().render(); |
| 25308a9 | 108 | |
| 25308a9 | 109 | const result = await page.evaluate(async (markup) => { |
| c13e41f | 110 | const { createElement, html } = await import('/index.js'); |
| c13e41f | 111 | const Comp = () => html`<div class="wrap"><button>-</button><button>+</button></div>`; |
| c13e41f | 112 | createElement({ url: '/focus-test-element.js' }, Comp); |
| c13e41f | 113 | |
| c13e41f | 114 | const el = document.createElement('focus-test-element'); |
| 25308a9 | 115 | el.innerHTML = markup; |
| c13e41f | 116 | document.body.appendChild(el); |
| c13e41f | 117 | |
| c13e41f | 118 | const originalPlus = el.querySelectorAll('button')[1]; |
| c13e41f | 119 | originalPlus.focus(); |
| c13e41f | 120 | const focusedBefore = document.activeElement === originalPlus; |
| c13e41f | 121 | |
| c13e41f | 122 | // the hydration render is scheduled on a microtask by connectedCallback; wait for it |
| c13e41f | 123 | await new Promise((resolve) => queueMicrotask(resolve)); |
| c13e41f | 124 | |
| c13e41f | 125 | const newPlus = el.querySelectorAll('button')[1]; |
| c13e41f | 126 | const focusedAfter = document.activeElement === newPlus; |
| c13e41f | 127 | const sameNode = originalPlus === newPlus; |
| c13e41f | 128 | el.remove(); |
| c13e41f | 129 | return { focusedBefore, focusedAfter, sameNode }; |
| 25308a9 | 130 | }, ssrMarkup); |
| c13e41f | 131 | |
| c13e41f | 132 | assert.strictEqual(result.focusedBefore, true, 'button should be focused before the hydration render runs'); |
| 25308a9 | 133 | assert.strictEqual(result.sameNode, true, 'hydration should attach to the existing SSR node, not recreate it'); |
| 25308a9 | 134 | assert.strictEqual(result.focusedAfter, true, 'focus should remain on the same node through hydration'); |
| c13e41f | 135 | }); |