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
// Real-browser test for the example app, run with: npm run test:e2e
// Requires Chromium once: npx playwright install chromium
// Named .spec.js (not .test.js) so it's never picked up by a bare `node --test`.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { chromium } from 'playwright';
import { createElement, getElement, html } from '../index.js';

const PORT = 3987;
const BASE_URL = `http://localhost:${PORT}/`;

let serverProcess;
let browser;
let page;
const pageErrors = [];

before(async () => {
  serverProcess = spawn(process.execPath, ['examples/server.js', String(PORT)], { stdio: 'ignore' });

  const deadline = Date.now() + 15000;
  for (;;) {
    try {
      if ((await fetch(BASE_URL)).ok) break;
    } catch {
      // server not up yet
    }
    if (Date.now() > deadline) throw new Error('example server did not start in time');
    await new Promise((resolve) => setTimeout(resolve, 200));
  }

  browser = await chromium.launch();
  page = await browser.newPage();
  page.on('pageerror', (err) => pageErrors.push(err.message));
  await page.goto(BASE_URL, { waitUntil: 'networkidle' });
  await page.waitForSelector('app-counter');
});

after(async () => {
  await browser?.close();
  serverProcess?.kill();
});

test('renders each counter exactly once, with no leftover duplicate SSR markup', async () => {
  const counters = page.locator('app-counter');
  assert.strictEqual(await counters.count(), 2);
  for (let i = 0; i < 2; i++) {
    assert.strictEqual(await counters.nth(i).locator('output').count(), 1, `counter ${i} should render exactly one <output>`);
  }
});

test('seeds each counter from its count attribute, and app-total sums them with no manual sync', async () => {
  const first = page.locator('app-counter').nth(0);
  const second = page.locator('app-counter').nth(1);
  assert.strictEqual((await first.locator('output').textContent()).trim(), '5', 'first app-counter has count="5"');
  assert.strictEqual((await second.locator('output').textContent()).trim(), '7', 'second app-counter has count="7"');
  // computed server-side by cheerio-parsing the already-rendered preceding
  // markup (both app-counter tags), via Total.watch — not a separately-
  // tracked total, and no shared store between the two components.
  assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters: 12');
});

test('clicking +/- updates the counter and the derived total', async () => {
  const first = page.locator('app-counter').nth(0);
  const second = page.locator('app-counter').nth(1);

  // Buttons carry an aria-label ("Increment"/"Decrement") that overrides their
  // visible "+"/"-" glyph as the accessible name, so query by that instead.
  await first.getByRole('button', { name: 'Increment' }).click();
  await first.getByRole('button', { name: 'Increment' }).click();
  await first.getByRole('button', { name: 'Increment' }).click();
  await second.getByRole('button', { name: 'Increment' }).click();
  await first.getByRole('button', { name: 'Decrement' }).click();

  // first: 5 + 3 - 1 = 7, second: 7 + 1 = 8, total: sum of both, live. Reflection
  // and the MutationObserver it feeds are both async, so wait for the total to
  // actually settle rather than asserting immediately after the last click.
  await page.waitForFunction(() => document.querySelector('app-total h1').textContent.trim() === 'Total of 2 Counters: 15');
  assert.strictEqual((await first.locator('output').textContent()).trim(), '7');
  assert.strictEqual((await second.locator('output').textContent()).trim(), '8');
  assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters: 15');
});

test('external attribute writes are adopted directly, with no separate internal state to conflict with', async () => {
  // Continuing from the previous test, the first counter currently shows 7.
  // count now lives only in the attribute (via prop) — there's no reducer
  // value to reconcile it against, so an external write just is the new
  // state, and app-total (via its watch declaration) picks it up automatically.
  const first = page.locator('app-counter').nth(0);
  await first.evaluate((el) => el.setAttribute('count', '30'));
  await page.waitForFunction(() => document.querySelector('app-counter').querySelector('output').textContent.trim() === '30');
  assert.strictEqual((await first.locator('output').textContent()).trim(), '30');
  await page.waitForFunction(() => document.querySelector('app-total h1').textContent.trim() === 'Total of 2 Counters: 38');
  assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters: 38');
});

test('no console/page errors were thrown', () => {
  assert.deepStrictEqual(pageErrors, []);
});

test('hydration reuses the exact SSR DOM node, so focus survives it naturally', async () => {
  // Server-render the fixture the same way examples/server.js does, so the
  // markup handed to the browser has real lit hydration markers rather than
  // hand-typed HTML — hydrate() only works against genuine SSR structure.
  const Comp = () => html`<div class="wrap"><button>-</button><button>+</button></div>`;
  createElement({ url: '/focus-test-element.js' }, Comp);
  const ssrMarkup = new (getElement('focus-test-element'))().render();

  const result = await page.evaluate(async (markup) => {
    const { createElement, html } = await import('/index.js');
    const Comp = () => html`<div class="wrap"><button>-</button><button>+</button></div>`;
    createElement({ url: '/focus-test-element.js' }, Comp);

    const el = document.createElement('focus-test-element');
    el.innerHTML = markup;
    document.body.appendChild(el);

    const originalPlus = el.querySelectorAll('button')[1];
    originalPlus.focus();
    const focusedBefore = document.activeElement === originalPlus;

    // the hydration render is scheduled on a microtask by connectedCallback; wait for it
    await new Promise((resolve) => queueMicrotask(resolve));

    const newPlus = el.querySelectorAll('button')[1];
    const focusedAfter = document.activeElement === newPlus;
    const sameNode = originalPlus === newPlus;
    el.remove();
    return { focusedBefore, focusedAfter, sameNode };
  }, ssrMarkup);

  assert.strictEqual(result.focusedBefore, true, 'button should be focused before the hydration render runs');
  assert.strictEqual(result.sameNode, true, 'hydration should attach to the existing SSR node, not recreate it');
  assert.strictEqual(result.focusedAfter, true, 'focus should remain on the same node through hydration');
});