atoms-element v5.0.0

#js

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
});