From ce30b114a2456e755a234f5e38e8bc1d4f315b07 Mon Sep 17 00:00:00 2001 From: Ayo Date: Sun, 5 Jul 2026 15:40:53 +0200 Subject: [PATCH] chore: new demo workspace & e2e tests - this adds a new demo web app with vite HMR for development - all examples are covered with e2e tests --- .claude/launch.json | 11 + .npmignore | 2 +- AGENTS.md | 6 +- demo/examples/attribute-lifecycle/index.html | 26 ++ demo/examples/attribute-lifecycle/index.js | 36 ++ demo/examples/constructed-styles/index.html | 15 + .../examples}/constructed-styles/index.js | 2 +- .../examples}/demo/BooleanPropTest.mjs | 3 +- {examples => demo/examples}/demo/Counter.mjs | 2 +- .../examples}/demo/HelloWorld.mjs | 2 +- .../examples}/demo/SimpleText.mjs | 2 +- {examples => demo/examples}/demo/Toggle.js | 2 +- {examples => demo/examples}/demo/index.html | 5 +- demo/examples/just-parts/index.html | 21 + demo/examples/just-parts/index.js | 26 ++ demo/examples/lifecycle-order/index.html | 27 ++ demo/examples/lifecycle-order/index.js | 57 +++ demo/examples/on-changes/index.html | 20 + demo/examples/on-changes/index.js | 55 +++ .../examples}/pens/counter-toggle.html | 10 +- .../examples}/props-blueprint/hello-world.js | 2 +- .../examples}/props-blueprint/index.html | 5 +- .../examples}/props-blueprint/index.js | 2 +- demo/examples/strict-props/index.html | 22 + demo/examples/strict-props/index.js | 70 ++++ demo/examples/style-objects/index.html | 15 + .../examples}/style-objects/index.js | 2 +- .../examples}/templating/index.html | 5 +- .../examples}/templating/index.js | 2 +- .../examples}/templating/with-lit.js | 2 +- .../examples}/type-restore/Counter.mjs | 2 +- .../examples}/type-restore/HelloWorld.mjs | 2 +- .../examples}/type-restore/Object.mjs | 2 +- .../examples}/type-restore/Toggle.mjs | 2 +- .../examples}/type-restore/index.html | 12 +- demo/examples/use-shadow/index.html | 16 + .../examples}/use-shadow/index.js | 2 +- demo/index.html | 117 ++++++ demo/package.json | 19 + demo/shell.css | 383 ++++++++++++++++++ demo/shell.js | 257 ++++++++++++ demo/vite.config.js | 52 +++ examples/constructed-styles/index.html | 12 - examples/style-objects/index.html | 12 - examples/use-shadow/index.html | 13 - package.json | 12 +- pnpm-lock.yaml | 251 +++++++++++- pnpm-workspace.yaml | 1 + test/e2e/README.md | 49 +++ test/e2e/attribute-lifecycle.test.mjs | 37 ++ test/e2e/constructed-styles.test.mjs | 15 + test/e2e/demo.test.mjs | 48 +++ test/e2e/just-parts.test.mjs | 19 + test/e2e/lifecycle-order.test.mjs | 36 ++ test/e2e/on-changes.test.mjs | 23 ++ test/e2e/props-blueprint.test.mjs | 27 ++ test/e2e/strict-props.test.mjs | 33 ++ test/e2e/style-objects.test.mjs | 22 + test/e2e/templating.test.mjs | 25 ++ test/e2e/type-restore.test.mjs | 40 ++ test/e2e/use-shadow.test.mjs | 23 ++ vitest.config.mjs | 2 + vitest.e2e.config.mjs | 35 ++ 63 files changed, 1970 insertions(+), 88 deletions(-) create mode 100644 .claude/launch.json create mode 100644 demo/examples/attribute-lifecycle/index.html create mode 100644 demo/examples/attribute-lifecycle/index.js create mode 100644 demo/examples/constructed-styles/index.html rename {examples => demo/examples}/constructed-styles/index.js (88%) rename {examples => demo/examples}/demo/BooleanPropTest.mjs (82%) rename {examples => demo/examples}/demo/Counter.mjs (83%) rename {examples => demo/examples}/demo/HelloWorld.mjs (90%) rename {examples => demo/examples}/demo/SimpleText.mjs (88%) rename {examples => demo/examples}/demo/Toggle.js (83%) rename {examples => demo/examples}/demo/index.html (74%) create mode 100644 demo/examples/just-parts/index.html create mode 100644 demo/examples/just-parts/index.js create mode 100644 demo/examples/lifecycle-order/index.html create mode 100644 demo/examples/lifecycle-order/index.js create mode 100644 demo/examples/on-changes/index.html create mode 100644 demo/examples/on-changes/index.js rename {examples => demo/examples}/pens/counter-toggle.html (77%) rename {examples => demo/examples}/props-blueprint/hello-world.js (79%) rename {examples => demo/examples}/props-blueprint/index.html (52%) rename {examples => demo/examples}/props-blueprint/index.js (82%) create mode 100644 demo/examples/strict-props/index.html create mode 100644 demo/examples/strict-props/index.js create mode 100644 demo/examples/style-objects/index.html rename {examples => demo/examples}/style-objects/index.js (92%) rename {examples => demo/examples}/templating/index.html (56%) rename {examples => demo/examples}/templating/index.js (95%) rename {examples => demo/examples}/templating/with-lit.js (96%) rename {examples => demo/examples}/type-restore/Counter.mjs (82%) rename {examples => demo/examples}/type-restore/HelloWorld.mjs (83%) rename {examples => demo/examples}/type-restore/Object.mjs (94%) rename {examples => demo/examples}/type-restore/Toggle.mjs (87%) rename {examples => demo/examples}/type-restore/index.html (74%) create mode 100644 demo/examples/use-shadow/index.html rename {examples => demo/examples}/use-shadow/index.js (95%) create mode 100644 demo/index.html create mode 100644 demo/package.json create mode 100644 demo/shell.css create mode 100644 demo/shell.js create mode 100644 demo/vite.config.js delete mode 100644 examples/constructed-styles/index.html delete mode 100644 examples/style-objects/index.html delete mode 100644 examples/use-shadow/index.html create mode 100644 test/e2e/README.md create mode 100644 test/e2e/attribute-lifecycle.test.mjs create mode 100644 test/e2e/constructed-styles.test.mjs create mode 100644 test/e2e/demo.test.mjs create mode 100644 test/e2e/just-parts.test.mjs create mode 100644 test/e2e/lifecycle-order.test.mjs create mode 100644 test/e2e/on-changes.test.mjs create mode 100644 test/e2e/props-blueprint.test.mjs create mode 100644 test/e2e/strict-props.test.mjs create mode 100644 test/e2e/style-objects.test.mjs create mode 100644 test/e2e/templating.test.mjs create mode 100644 test/e2e/type-restore.test.mjs create mode 100644 test/e2e/use-shadow.test.mjs create mode 100644 vitest.e2e.config.mjs diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..867d8b6 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "demo", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["-F", "demo", "dev", "--port", "5199", "--strictPort"], + "port": 5199 + } + ] +} diff --git a/.npmignore b/.npmignore index 8122869..9762ec6 100644 --- a/.npmignore +++ b/.npmignore @@ -1,5 +1,5 @@ node_modules/ -examples/ +demo/ assets/ src/ .vscode/ diff --git a/AGENTS.md b/AGENTS.md index 59cc703..d6998cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,8 +16,12 @@ This file provides guidance to AI coding agents when working with code in this r - `pnpm build` — `tsc` emits `.d.ts` types, then `copy:source` uses esbuild to minify/bundle `src/*.js` + `src/utils/*` into `dist/` as ESM. **`dist/` is what gets published; `src/` is shipped as the readable source.** - `pnpm size-limit` — enforces the byte budgets declared in `package.json` `size-limit` (e.g. `WebComponent.js` ≤ 1.2 KB). Keep additions tiny; this gate is a core project value. - `pnpm docs` — run the Astro docs site (`docs/` workspace) locally. +- `pnpm demo` — run the Vite examples showcase (`demo/` workspace) locally; `pnpm demo:build` builds it. +- `pnpm test:e2e` — run the browser e2e specs (`test/e2e/`) in Chromium via Vitest browser mode; `pnpm test:all` runs unit + e2e. -pnpm is mandatory (a `preinstall` `only-allow pnpm` guard enforces it). This is a pnpm workspace; the only sub-package is `docs/`. +pnpm is mandatory (a `preinstall` `only-allow pnpm` guard enforces it). This is a pnpm workspace with two sub-packages: `docs/` (Astro docs site) and `demo/` (Vite examples showcase, which consumes the root `web-component-base` package as a `workspace:*` dependency). The runnable example sources live in `demo/examples/` and import the package by name (`web-component-base`), not via relative `src/` paths. + +The demo shares one app shell: `demo/shell.css` (design tokens + component styles, linked by the landing page and every example) and `demo/shell.js` (defines `` — itself a `WebComponent` — and `prepend`s it to each example page; `prepend` never reparents the example's own elements, so their lifecycle is untouched). Every example page links both. Keep new examples consistent by adding `` and ``. ## Architecture diff --git a/demo/examples/attribute-lifecycle/index.html b/demo/examples/attribute-lifecycle/index.html new file mode 100644 index 0000000..8c9fc87 --- /dev/null +++ b/demo/examples/attribute-lifecycle/index.html @@ -0,0 +1,26 @@ + + + + + + Attribute lifecycle + + + + + + +

Attribute lifecycle: empty string, removal, markup wins

+ +

Default (no authored attribute)

+

Shows the declared default; try the buttons.

+ + +

Authored attribute wins over the default

+

+ <attr-demo label="from markup"> — the default is not + applied because the attribute is already present. +

+ + + diff --git a/demo/examples/attribute-lifecycle/index.js b/demo/examples/attribute-lifecycle/index.js new file mode 100644 index 0000000..661de21 --- /dev/null +++ b/demo/examples/attribute-lifecycle/index.js @@ -0,0 +1,36 @@ +import { WebComponent, html } from 'web-component-base' + +/** + * v5 attribute-change correctness: + * + * - `label=""` (empty string) stays the string `''` — it is no longer + * coerced to boolean `true` / echoed as `"true"`. + * - `removeAttribute('label')` resets the prop to its **declared default** + * instead of writing `null` and throwing before render(). + * - an attribute authored in markup **wins** over the default: defaults are + * reflected on connect and skip any attribute already present. + */ +export class AttrDemo extends WebComponent { + static props = { + label: 'default-label', + } + + setValue = () => this.setAttribute('label', 'hello') + setEmpty = () => this.setAttribute('label', '') + remove = () => this.removeAttribute('label') + + get template() { + const value = this.props.label + return html` +

label prop: ${JSON.stringify(value)}

+

typeof: ${typeof value}

+ + + + ` + } +} + +customElements.define('attr-demo', AttrDemo) diff --git a/demo/examples/constructed-styles/index.html b/demo/examples/constructed-styles/index.html new file mode 100644 index 0000000..be40af2 --- /dev/null +++ b/demo/examples/constructed-styles/index.html @@ -0,0 +1,15 @@ + + + + + + Constructable styles + + + + + + + + + diff --git a/examples/constructed-styles/index.js b/demo/examples/constructed-styles/index.js similarity index 88% rename from examples/constructed-styles/index.js rename to demo/examples/constructed-styles/index.js index c04a292..a2876b7 100644 --- a/examples/constructed-styles/index.js +++ b/demo/examples/constructed-styles/index.js @@ -1,5 +1,5 @@ // @ts-check -import { WebComponent, html } from '../../src/index.js' +import { WebComponent, html } from 'web-component-base' class StyledElements extends WebComponent { static shadowRootInit = { diff --git a/examples/demo/BooleanPropTest.mjs b/demo/examples/demo/BooleanPropTest.mjs similarity index 82% rename from examples/demo/BooleanPropTest.mjs rename to demo/examples/demo/BooleanPropTest.mjs index 4259050..4e675b7 100644 --- a/examples/demo/BooleanPropTest.mjs +++ b/demo/examples/demo/BooleanPropTest.mjs @@ -1,4 +1,5 @@ -import { html, WebComponent } from '../../src/index.js' +//@ts-check +import { html, WebComponent } from 'web-component-base' export class BooleanPropTest extends WebComponent { static props = { diff --git a/examples/demo/Counter.mjs b/demo/examples/demo/Counter.mjs similarity index 83% rename from examples/demo/Counter.mjs rename to demo/examples/demo/Counter.mjs index 8607207..bcba2e1 100644 --- a/examples/demo/Counter.mjs +++ b/demo/examples/demo/Counter.mjs @@ -1,5 +1,5 @@ // @ts-check -import { WebComponent, html } from '../../src/index.js' +import { WebComponent, html } from 'web-component-base' export class Counter extends WebComponent { static props = { diff --git a/examples/demo/HelloWorld.mjs b/demo/examples/demo/HelloWorld.mjs similarity index 90% rename from examples/demo/HelloWorld.mjs rename to demo/examples/demo/HelloWorld.mjs index 079423b..4d0d7b0 100644 --- a/examples/demo/HelloWorld.mjs +++ b/demo/examples/demo/HelloWorld.mjs @@ -1,5 +1,5 @@ // @ts-check -import { html, WebComponent } from '../../src/index.js' +import { html, WebComponent } from 'web-component-base' export class HelloWorld extends WebComponent { static props = { diff --git a/examples/demo/SimpleText.mjs b/demo/examples/demo/SimpleText.mjs similarity index 88% rename from examples/demo/SimpleText.mjs rename to demo/examples/demo/SimpleText.mjs index 3857060..fe28472 100644 --- a/examples/demo/SimpleText.mjs +++ b/demo/examples/demo/SimpleText.mjs @@ -1,5 +1,5 @@ // @ts-check -import { html, WebComponent } from '../../src/index.js' +import { html, WebComponent } from 'web-component-base' class SimpleText extends WebComponent { clickCallback() { diff --git a/examples/demo/Toggle.js b/demo/examples/demo/Toggle.js similarity index 83% rename from examples/demo/Toggle.js rename to demo/examples/demo/Toggle.js index 238bad4..2e2dc52 100644 --- a/examples/demo/Toggle.js +++ b/demo/examples/demo/Toggle.js @@ -1,4 +1,4 @@ -import { WebComponent, html } from '../../src/index.js' +import { WebComponent, html } from 'web-component-base' class Toggle extends WebComponent { static props = { diff --git a/examples/demo/index.html b/demo/examples/demo/index.html similarity index 74% rename from examples/demo/index.html rename to demo/examples/demo/index.html index eca5e44..fb99862 100644 --- a/examples/demo/index.html +++ b/demo/examples/demo/index.html @@ -3,12 +3,15 @@ - WC demo + Kitchen sink + + + diff --git a/demo/examples/just-parts/index.html b/demo/examples/just-parts/index.html new file mode 100644 index 0000000..6b8a46a --- /dev/null +++ b/demo/examples/just-parts/index.html @@ -0,0 +1,21 @@ + + + + + + Using just some parts + + + + + + +

Using the html tag + createElement directly

+

+ my-quote extends the vanilla HTMLElement — not + WebComponent — yet still builds its DOM from a tagged + template. +

+ + + diff --git a/demo/examples/just-parts/index.js b/demo/examples/just-parts/index.js new file mode 100644 index 0000000..1b84c28 --- /dev/null +++ b/demo/examples/just-parts/index.js @@ -0,0 +1,26 @@ +import { html } from 'web-component-base/html' +import { createElement } from 'web-component-base/utils' + +/** + * You don't have to extend `WebComponent` to use its parts. The `html` tag and + * the `createElement` util work on a plain `HTMLElement`, so you can build the + * reactive-template behavior into your own classes. + */ +class MyQuote extends HTMLElement { + connectedCallback() { + let count = 0 + const el = createElement( + html`` + ) + this.appendChild(el) + } +} + +customElements.define('my-quote', MyQuote) diff --git a/demo/examples/lifecycle-order/index.html b/demo/examples/lifecycle-order/index.html new file mode 100644 index 0000000..6e0894b --- /dev/null +++ b/demo/examples/lifecycle-order/index.html @@ -0,0 +1,27 @@ + + + + + + Lifecycle order & buffering + + + + + + +

onInit runs before the first render

+ +

With an authored attribute

+

+ <lifecycle-order label="authored"> — note that + onInit already sees "authored", the single + render runs after it, and the pre-connect change is not + replayed through onChanges. +

+ + +

Without an authored attribute (uses the default)

+ + + diff --git a/demo/examples/lifecycle-order/index.js b/demo/examples/lifecycle-order/index.js new file mode 100644 index 0000000..451b51e --- /dev/null +++ b/demo/examples/lifecycle-order/index.js @@ -0,0 +1,57 @@ +import { WebComponent, html } from 'web-component-base' + +/** + * v5 buffering guarantee. Per the Custom Elements spec, an element upgraded + * with authored attributes (``) fires + * `attributeChangedCallback` **before** `connectedCallback`. Taken literally + * that means `render()` could run before `onInit()`. + * + * WebComponent buffers pre-connect attribute changes: the prop value is applied + * immediately (so `this.props` is correct inside `onInit`), but the `render()` + * and `onChanges()` side effects are deferred until after `onInit`. The order + * on connect is always: onInit → render → afterViewInit, and pre-connect + * changes are NOT replayed through onChanges. + * + * This component records the order it observes and surfaces it in the page. + */ +export class LifecycleOrder extends WebComponent { + static props = { + label: 'default', + } + + #log = [] + + onInit() { + // props already reflect any authored attribute here, before the first render + this.#log.push(`onInit (props.label = "${this.props.label}")`) + } + + render() { + this.#log.push(`render (props.label = "${this.props.label}")`) + super.render() + } + + onChanges(changes) { + this.#log.push( + `onChanges (${changes.attribute} → "${changes.currentValue}")` + ) + } + + afterViewInit() { + this.#log.push('afterViewInit') + const list = document.createElement('ol') + list.className = 'lifecycle-log' + for (const line of this.#log) { + const li = document.createElement('li') + li.textContent = line + list.appendChild(li) + } + this.after(list) + } + + get template() { + return html`

label: ${this.props.label}

` + } +} + +customElements.define('lifecycle-order', LifecycleOrder) diff --git a/demo/examples/on-changes/index.html b/demo/examples/on-changes/index.html new file mode 100644 index 0000000..a310c2a --- /dev/null +++ b/demo/examples/on-changes/index.html @@ -0,0 +1,20 @@ + + + + + + onChanges: property vs attribute + + + + + + +

onChanges: property (camelCase) vs attribute (kebab-case)

+

+ Click Rename to change the my-name attribute and + watch the onChanges payload below. +

+ + + diff --git a/demo/examples/on-changes/index.js b/demo/examples/on-changes/index.js new file mode 100644 index 0000000..7472581 --- /dev/null +++ b/demo/examples/on-changes/index.js @@ -0,0 +1,55 @@ +import { WebComponent, html } from 'web-component-base' + +/** + * v5 `onChanges` payload: the `changes` object cleanly separates the + * camelCase **property** key from the kebab-case **attribute** name. + * + * - `property` → camelCase prop key, matching `this.props` access (`myName`) + * - `attribute` → kebab-case attribute name that changed (`my-name`) + * - `previousValue` / `currentValue` + * + * Before v5, `property` held the kebab-case attribute name. Code that read + * `changes.property` for the attribute name must now read `changes.attribute`. + */ +export class ChangeLogger extends WebComponent { + static props = { + myName: 'World', + } + + #last = null + + onChanges(changes) { + this.#last = changes + console.log('>>> onChanges', changes) + // onChanges fires after render(), so re-render to surface the payload + this.render() + } + + rename = () => { + this.props.myName = this.props.myName === 'World' ? 'Ayo' : 'World' + } + + get template() { + const c = this.#last + return html` + +

Hello ${this.props.myName}

+ ${c + ? html` +
+
property (camelCase)
+
${c.property}
+
attribute (kebab-case)
+
${c.attribute}
+
previousValue
+
${c.previousValue}
+
currentValue
+
${c.currentValue}
+
+ ` + : html`

No changes yet

`} + ` + } +} + +customElements.define('change-logger', ChangeLogger) diff --git a/examples/pens/counter-toggle.html b/demo/examples/pens/counter-toggle.html similarity index 77% rename from examples/pens/counter-toggle.html rename to demo/examples/pens/counter-toggle.html index 0bb541a..6fdf6a5 100644 --- a/examples/pens/counter-toggle.html +++ b/demo/examples/pens/counter-toggle.html @@ -3,12 +3,7 @@ - WC demo - + Single-file pen (CDN) + + +
diff --git a/examples/props-blueprint/hello-world.js b/demo/examples/props-blueprint/hello-world.js similarity index 79% rename from examples/props-blueprint/hello-world.js rename to demo/examples/props-blueprint/hello-world.js index 9f40800..26eb5be 100644 --- a/examples/props-blueprint/hello-world.js +++ b/demo/examples/props-blueprint/hello-world.js @@ -1,4 +1,4 @@ -import { html, WebComponent } from '../../src/index.js' +import { html, WebComponent } from 'web-component-base' export class HelloWorld extends WebComponent { static props = { diff --git a/examples/props-blueprint/index.html b/demo/examples/props-blueprint/index.html similarity index 52% rename from examples/props-blueprint/index.html rename to demo/examples/props-blueprint/index.html index d42eb38..3397343 100644 --- a/examples/props-blueprint/index.html +++ b/demo/examples/props-blueprint/index.html @@ -3,9 +3,12 @@ - WC demo + Props blueprint + + + diff --git a/examples/props-blueprint/index.js b/demo/examples/props-blueprint/index.js similarity index 82% rename from examples/props-blueprint/index.js rename to demo/examples/props-blueprint/index.js index 95b3a9d..ac5bd31 100644 --- a/examples/props-blueprint/index.js +++ b/demo/examples/props-blueprint/index.js @@ -1,4 +1,4 @@ -import { html, WebComponent } from '../../src/index.js' +import { html, WebComponent } from 'web-component-base' export class Counter extends WebComponent { static props = { diff --git a/demo/examples/strict-props/index.html b/demo/examples/strict-props/index.html new file mode 100644 index 0000000..ae5e345 --- /dev/null +++ b/demo/examples/strict-props/index.html @@ -0,0 +1,22 @@ + + + + + + strictProps type enforcement + + + + + + +

Prop type enforcement

+

+ By default a type violation is logged (see the console) and skipped. With + static strictProps = true it throws a TypeError. +

+ +
+ + + diff --git a/demo/examples/strict-props/index.js b/demo/examples/strict-props/index.js new file mode 100644 index 0000000..dc8fb80 --- /dev/null +++ b/demo/examples/strict-props/index.js @@ -0,0 +1,70 @@ +import { WebComponent, html } from 'web-component-base' + +/** + * v5 derives a prop's type from its `static props` default and, by default, + * treats a type violation as **loud but non-fatal**: the bad write is logged + * via `console.error` and skipped, so the prop keeps its value and + * `render()` / `onChanges()` still run. + * + * Here `count` is typed `number` (default `0`). Assigning a string is skipped. + */ +export class LenientCounter extends WebComponent { + static props = { count: 0 } + + #status = 'ok' + + bump = () => ++this.props.count + + violate = () => { + this.props.count = 'not-a-number' // logged + skipped; count stays a number + this.#status = `count is still ${this.props.count} (${typeof this.props.count})` + this.render() + } + + get template() { + return html` +

Default: log & skip

+ + +

${this.#status}

+ ` + } +} + +/** + * Teams wanting hard enforcement opt in with `static strictProps = true`. + * The same violation now throws a `TypeError` instead of being skipped. + */ +export class StrictCounter extends WebComponent { + static strictProps = true + static props = { count: 0 } + + #status = 'ok' + + violate = () => { + try { + this.props.count = 'not-a-number' + this.#status = 'no error (unexpected)' + } catch (e) { + this.#status = `threw: ${e.name}` + } + this.render() + } + + get template() { + return html` +

strictProps = true

+ +

${this.#status}

+ ` + } +} + +customElements.define('lenient-counter', LenientCounter) +customElements.define('strict-counter', StrictCounter) diff --git a/demo/examples/style-objects/index.html b/demo/examples/style-objects/index.html new file mode 100644 index 0000000..93051a8 --- /dev/null +++ b/demo/examples/style-objects/index.html @@ -0,0 +1,15 @@ + + + + + + Style objects + + + + + + + + + diff --git a/examples/style-objects/index.js b/demo/examples/style-objects/index.js similarity index 92% rename from examples/style-objects/index.js rename to demo/examples/style-objects/index.js index 1ccc685..c57c6f9 100644 --- a/examples/style-objects/index.js +++ b/demo/examples/style-objects/index.js @@ -1,5 +1,5 @@ // @ts-check -import { WebComponent, html } from '../../src/index.js' +import { WebComponent, html } from 'web-component-base' class StyledElements extends WebComponent { static props = { diff --git a/examples/templating/index.html b/demo/examples/templating/index.html similarity index 56% rename from examples/templating/index.html rename to demo/examples/templating/index.html index fb8ac4c..754c79a 100644 --- a/examples/templating/index.html +++ b/demo/examples/templating/index.html @@ -3,9 +3,12 @@ - WC demo + Templating + + +

With our html

diff --git a/examples/templating/index.js b/demo/examples/templating/index.js similarity index 95% rename from examples/templating/index.js rename to demo/examples/templating/index.js index 605021d..6516fca 100644 --- a/examples/templating/index.js +++ b/demo/examples/templating/index.js @@ -1,5 +1,5 @@ // @ts-check -import { WebComponent, html } from '../../src/index.js' +import { WebComponent, html } from 'web-component-base' export class Counter extends WebComponent { static props = { diff --git a/examples/templating/with-lit.js b/demo/examples/templating/with-lit.js similarity index 96% rename from examples/templating/with-lit.js rename to demo/examples/templating/with-lit.js index 6535444..7d441c3 100644 --- a/examples/templating/with-lit.js +++ b/demo/examples/templating/with-lit.js @@ -1,4 +1,4 @@ -import { WebComponent } from '../../src/index.js' +import { WebComponent } from 'web-component-base' import { html, render as lit, diff --git a/examples/type-restore/Counter.mjs b/demo/examples/type-restore/Counter.mjs similarity index 82% rename from examples/type-restore/Counter.mjs rename to demo/examples/type-restore/Counter.mjs index 8016ab7..4221766 100644 --- a/examples/type-restore/Counter.mjs +++ b/demo/examples/type-restore/Counter.mjs @@ -1,5 +1,5 @@ // @ts-check -import { html, WebComponent } from '../../src/index.js' +import { html, WebComponent } from 'web-component-base' export class Counter extends WebComponent { static props = { diff --git a/examples/type-restore/HelloWorld.mjs b/demo/examples/type-restore/HelloWorld.mjs similarity index 83% rename from examples/type-restore/HelloWorld.mjs rename to demo/examples/type-restore/HelloWorld.mjs index fb12975..d581393 100644 --- a/examples/type-restore/HelloWorld.mjs +++ b/demo/examples/type-restore/HelloWorld.mjs @@ -1,4 +1,4 @@ -import { html, WebComponent } from '../../src/index.js' +import { html, WebComponent } from 'web-component-base' export class HelloWorld extends WebComponent { static props = { diff --git a/examples/type-restore/Object.mjs b/demo/examples/type-restore/Object.mjs similarity index 94% rename from examples/type-restore/Object.mjs rename to demo/examples/type-restore/Object.mjs index 71e70b8..abc222e 100644 --- a/examples/type-restore/Object.mjs +++ b/demo/examples/type-restore/Object.mjs @@ -1,4 +1,4 @@ -import { html, WebComponent } from '../../src/index.js' +import { html, WebComponent } from 'web-component-base' /** * TODO: rendering currently wipes all children so focus gets removed on fields diff --git a/examples/type-restore/Toggle.mjs b/demo/examples/type-restore/Toggle.mjs similarity index 87% rename from examples/type-restore/Toggle.mjs rename to demo/examples/type-restore/Toggle.mjs index 686a594..a9ce8a4 100644 --- a/examples/type-restore/Toggle.mjs +++ b/demo/examples/type-restore/Toggle.mjs @@ -1,5 +1,5 @@ // @ts-check -import { html, WebComponent } from '../../src/index.js' +import { html, WebComponent } from 'web-component-base' export class Toggle extends WebComponent { static props = { diff --git a/examples/type-restore/index.html b/demo/examples/type-restore/index.html similarity index 74% rename from examples/type-restore/index.html rename to demo/examples/type-restore/index.html index 0de918a..a62ed3a 100644 --- a/examples/type-restore/index.html +++ b/demo/examples/type-restore/index.html @@ -4,16 +4,14 @@ - WC demo + Typed props - + + + @@ -29,7 +27,7 @@ * TODO: fix using custom events */ - // import { attachEffect } from '../../src/index.js' + // import { attachEffect } from 'web-component-base' // const myObjectEl = document.querySelector('my-object') // const objectProp = myObjectEl.props.object // const displayPanelEl = document.querySelector('#display-panel') diff --git a/demo/examples/use-shadow/index.html b/demo/examples/use-shadow/index.html new file mode 100644 index 0000000..f75c64a --- /dev/null +++ b/demo/examples/use-shadow/index.html @@ -0,0 +1,16 @@ + + + + + + Shadow DOM + + + + + + +

With our html

+ + + diff --git a/examples/use-shadow/index.js b/demo/examples/use-shadow/index.js similarity index 95% rename from examples/use-shadow/index.js rename to demo/examples/use-shadow/index.js index 3f560cf..da04d85 100644 --- a/examples/use-shadow/index.js +++ b/demo/examples/use-shadow/index.js @@ -1,5 +1,5 @@ // @ts-check -import { WebComponent, html } from '../../src/index.js' +import { WebComponent, html } from 'web-component-base' export class Counter extends WebComponent { static props = { diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..7779446 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,117 @@ + + + + + + web-component-base — examples + + + + + +
+

web-component-base examples

+

+ A live gallery of the demos using web components. Each + card opens a standalone example page. +

+
+ + + + + + + +
+ Run with pnpm --filter demo dev. Source lives in + demo/examples/. See the + documentation. +
+ + diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 0000000..57c9eee --- /dev/null +++ b/demo/package.json @@ -0,0 +1,19 @@ +{ + "name": "demo", + "private": true, + "type": "module", + "version": "0.0.0", + "description": "Vite showcase app for web-component-base examples", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "shiki": "^4.3.1", + "web-component-base": "workspace:*" + }, + "devDependencies": { + "vite": "^7.3.1" + } +} diff --git a/demo/shell.css b/demo/shell.css new file mode 100644 index 0000000..467a1b8 --- /dev/null +++ b/demo/shell.css @@ -0,0 +1,383 @@ +/* + * Single shared stylesheet for the demo app shell. + * Linked by the landing page and every example page so the whole showcase + * shares one set of design tokens and component styles. + */ + +/* Dark is the default (and the no-JS fallback). The theme toggle overrides it + by setting `data-theme` on ; when unset, the OS preference wins. */ +:root { + --bg: #0b0d12; + --card: #151922; + --card-hover: #1c2230; + --surface: #10141c; + --border: #2a3140; + --text: #e6e9ef; + --muted: #97a0b0; + --accent: #7c9cff; + --tag: #263042; + --radius: 12px; + color-scheme: dark; +} + +/* Light tokens, shared by the OS preference (no explicit choice) and the + explicit `data-theme="light"` override. */ +@media (prefers-color-scheme: light) { + :root:not([data-theme]) { + --bg: #f6f7f9; + --card: #ffffff; + --card-hover: #ffffff; + --surface: #ffffff; + --border: #e2e5ea; + --text: #1a1d24; + --muted: #5b6472; + --accent: #3355dd; + --tag: #eef1f7; + color-scheme: light; + } +} +:root[data-theme='light'] { + --bg: #f6f7f9; + --card: #ffffff; + --card-hover: #ffffff; + --surface: #ffffff; + --border: #e2e5ea; + --text: #1a1d24; + --muted: #5b6472; + --accent: #3355dd; + --tag: #eef1f7; + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +/* Full-bleed background on , centered content column on . */ +html { + background: var(--bg); + color-scheme: light dark; +} +body { + max-width: 960px; + margin: 0 auto; + padding: 1.5rem 1.25rem 4rem; + color: var(--text); + font: + 16px/1.6 system-ui, + -apple-system, + 'Segoe UI', + Roboto, + sans-serif; +} + +/* ---------- shared app header (injected on example pages) ---------- */ +app-header { + display: flex; + align-items: center; + gap: 1rem; + padding-bottom: 0.9rem; + margin-bottom: 1.75rem; + border-bottom: 1px solid var(--border); +} +app-header .brand { + font-weight: 700; + color: var(--accent); + text-decoration: none; +} +app-header .crumb { + color: var(--muted); +} +app-header .spacer { + flex: 1; +} +app-header .back { + color: var(--muted); + text-decoration: none; + font-size: 0.9rem; + white-space: nowrap; +} +app-header .back:hover { + color: var(--accent); +} + +/* ---------- theme toggle (in the nav / landing header) ---------- */ +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--card); + color: var(--muted); + cursor: pointer; + transition: + color 0.15s, + border-color 0.15s, + background 0.15s; +} +.theme-toggle:hover { + color: var(--accent); + border-color: var(--accent); +} +.theme-toggle svg { + display: block; +} + +/* ---------- typography ---------- */ +h1 { + font-size: 1.6rem; + line-height: 1.25; + margin: 0 0 0.5rem; +} +h2 { + font-size: 1.05rem; + margin: 2rem 0 0.75rem; +} +p { + margin: 0 0 1rem; + max-width: 62ch; +} +.lede { + color: var(--muted); +} +a { + color: var(--accent); +} +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.9em; + padding: 0.1em 0.35em; + border-radius: 5px; +} +hr { + border: none; + border-top: 1px solid var(--border); + margin: 1.75rem 0; +} + +/* ---------- interactive bits used across the examples ---------- */ +button { + font: inherit; + color: var(--text); + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.45rem 0.85rem; + cursor: pointer; + transition: + border-color 0.15s, + background 0.15s; +} +button:hover { + border-color: var(--accent); + background: var(--card-hover); +} +input, +textarea { + font: inherit; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.4rem 0.55rem; +} +label { + color: var(--muted); + font-size: 0.9rem; + margin-right: 0.4rem; +} +form { + display: grid; + gap: 0.5rem; + max-width: 24rem; +} + +dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.35rem 1rem; + align-items: baseline; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1.15rem; + max-width: 34rem; +} +dt { + color: var(--muted); +} +dd { + margin: 0; + font-weight: 600; +} + +ol.lifecycle-log { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.9rem 0.9rem 0.9rem 2.4rem; + max-width: 34rem; +} +ol.lifecycle-log li { + margin: 0.15rem 0; + font-family: ui-monospace, monospace; + font-size: 0.9rem; +} + +/* Give custom-element hosts a bit of breathing room in the column. */ +change-logger, +attr-demo, +lenient-counter, +strict-counter, +lifecycle-order, +my-object, +my-quote { + display: block; + margin: 0.75rem 0; +} + +/* ---------- source preview (shown on every example page) ---------- */ +.source { + margin-top: 2.75rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); +} +.source-title { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin: 0 0 1rem; +} +details.source-file { + /* `--code-bg` is set from Shiki's theme background once highlighting loads, + so the whole block matches the code area (falls back to the card color). */ + background: var(--code-bg, var(--card)); + border: 1px solid var(--border); + border-radius: var(--radius); + margin: 0 0 0.75rem; + overflow: hidden; +} +details.source-file > summary { + cursor: pointer; + padding: 0.6rem 0.9rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85rem; + color: var(--accent); + user-select: none; +} +details.source-file[open] > summary { + border-bottom: 1px solid var(--border); +} +/* Box styles shared by the plain fallback and Shiki's
.
+   Both are transparent so the single block background (above) shows through —
+   one uniform color across the filename bar and the code, with no seams. */
+details.source-file pre {
+  margin: 0;
+  padding: 0.9rem 1rem;
+  overflow-x: auto;
+  font-size: 0.82rem;
+  line-height: 1.55;
+  tab-size: 2;
+  background: transparent !important;
+}
+details.source-file pre.source-plain {
+  color: var(--text);
+}
+details.source-file pre.source-plain code {
+  background: none;
+  padding: 0;
+  border-radius: 0;
+  font-size: inherit;
+}
+/* Shiki dual-theme: light token colors are inline; flip to the dark theme when
+   the effective theme is dark. The source preview only renders with JS, which
+   always sets a concrete `data-theme`, so key the flip off that attribute
+   (background is handled by the block, not per span). */
+:root[data-theme='dark'] details.source-file {
+  background: var(--code-bg-dark, var(--card));
+}
+:root[data-theme='dark'] details.source-file .shiki span {
+  color: var(--shiki-dark) !important;
+}
+
+/* ---------- landing page ---------- */
+.home-header {
+  position: relative;
+}
+.home-header .theme-toggle {
+  position: absolute;
+  top: 0;
+  right: 0;
+}
+.home-header p {
+  color: var(--muted);
+  max-width: 46rem;
+}
+.home-header h1 {
+  font-size: 2rem;
+}
+.home-header h1 code {
+  font-size: 1.5rem;
+  color: var(--accent);
+  background: none;
+  padding: 0;
+}
+.section-label {
+  font-size: 0.85rem;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--muted);
+  margin: 2.5rem 0 1rem;
+}
+.grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+  gap: 0.9rem;
+}
+a.card {
+  display: block;
+  padding: 1.1rem 1.15rem;
+  background: var(--card);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  text-decoration: none;
+  color: inherit;
+  transition:
+    background 0.15s,
+    border-color 0.15s,
+    transform 0.15s;
+}
+a.card:hover {
+  background: var(--card-hover);
+  border-color: var(--accent);
+  transform: translateY(-2px);
+}
+a.card .name {
+  font-weight: 600;
+  margin-bottom: 0.3rem;
+}
+a.card .desc {
+  color: var(--muted);
+  font-size: 0.9rem;
+}
+.tag {
+  display: inline-block;
+  font-size: 0.7rem;
+  font-weight: 600;
+  color: var(--accent);
+  background: var(--tag);
+  border-radius: 999px;
+  padding: 0.1rem 0.5rem;
+  margin-left: 0.4rem;
+  vertical-align: middle;
+}
+footer {
+  margin-top: 3rem;
+  color: var(--muted);
+  font-size: 0.85rem;
+}
diff --git a/demo/shell.js b/demo/shell.js
new file mode 100644
index 0000000..db32171
--- /dev/null
+++ b/demo/shell.js
@@ -0,0 +1,257 @@
+import { WebComponent } from 'web-component-base'
+
+/**
+ * The shared chrome for every example page — itself built with
+ * `web-component-base` (dogfooding). It renders into light DOM, so the shared
+ * `shell.css` styles it like everything else.
+ */
+class AppHeader extends WebComponent {
+  static props = { heading: '' }
+
+  get template() {
+    return `
+      web-component-base
+      ${this.props.heading}
+      
+      ← All examples
+    `
+  }
+}
+
+customElements.define('app-header', AppHeader)
+
+// ---------------------------------------------------------------------------
+// Theme toggle
+//
+// The initial theme is applied by a tiny inline  script (so there is no
+// flash of the wrong theme). Here we build the toggle control, keep it in sync,
+// and follow the OS while the user hasn't made an explicit choice.
+// ---------------------------------------------------------------------------
+
+const THEME_KEY = 'wcb-theme'
+const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')
+const effectiveTheme = () =>
+  document.documentElement.dataset.theme ||
+  (prefersDark.matches ? 'dark' : 'light')
+
+const SUN_ICON = ``
+const MOON_ICON = ``
+
+const themeToggle = document.createElement('button')
+themeToggle.type = 'button'
+themeToggle.className = 'theme-toggle'
+
+function syncToggle() {
+  const dark = effectiveTheme() === 'dark'
+  themeToggle.innerHTML = dark ? SUN_ICON : MOON_ICON
+  const label = `Switch to ${dark ? 'light' : 'dark'} theme`
+  themeToggle.setAttribute('aria-label', label)
+  themeToggle.title = label
+}
+
+themeToggle.addEventListener('click', () => {
+  const next = effectiveTheme() === 'dark' ? 'light' : 'dark'
+  document.documentElement.dataset.theme = next
+  try {
+    localStorage.setItem(THEME_KEY, next)
+  } catch {
+    /* private mode / storage disabled — the choice just won't persist */
+  }
+  syncToggle()
+})
+
+// Follow OS changes only while the user hasn't chosen explicitly.
+prefersDark.addEventListener('change', () => {
+  let stored = null
+  try {
+    stored = localStorage.getItem(THEME_KEY)
+  } catch {
+    /* ignore */
+  }
+  if (!stored) {
+    document.documentElement.dataset.theme = prefersDark.matches
+      ? 'dark'
+      : 'light'
+  }
+  syncToggle()
+})
+
+syncToggle()
+
+// ---------------------------------------------------------------------------
+// Source preview
+//
+// Every example page shows the source of its own implementation. Vite inlines
+// each file's raw text at build time via `?raw` glob imports; we look them up
+// by the example's folder (taken from the URL) so this works identically in dev
+// and in the hashed production build (unlike reading rewritten 
-  
-  
-    
-  
-
diff --git a/examples/style-objects/index.html b/examples/style-objects/index.html
deleted file mode 100644
index 4ef7b41..0000000
--- a/examples/style-objects/index.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-  
-    
-    
-    WC demo
-    
-  
-  
-    
-  
-
diff --git a/examples/use-shadow/index.html b/examples/use-shadow/index.html
deleted file mode 100644
index 3493307..0000000
--- a/examples/use-shadow/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-  
-    
-    
-    WC demo
-    
-  
-  
-    

With our html

- - - diff --git a/package.json b/package.json index 4ccfa25..c53b34b 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,14 @@ "types": "./dist/index.d.ts", "scripts": { "preinstall": "npx only-allow pnpm", - "start": "npx simple-server .", - "dev": "npm start", + "start": "pnpm -F demo dev", + "dev": "pnpm -F demo dev", "test": "vitest --run", "test:watch": "vitest", - "demo": "npx simple-server .", + "test:e2e": "vitest --run --config vitest.e2e.config.mjs", + "test:all": "pnpm test && pnpm test:e2e", + "demo": "pnpm -F demo dev", + "demo:build": "pnpm -F demo build", "docs": "pnpm -F docs start", "build": "pnpm run clean && tsc && pnpm run copy:source", "size-limit": "pnpm run build && size-limit", @@ -60,6 +63,8 @@ "devDependencies": { "@eslint/js": "^9.39.2", "@size-limit/preset-small-lib": "^12.0.0", + "@vitest/browser": "4.0.18", + "@vitest/browser-playwright": "4.0.18", "@vitest/coverage-v8": "4.0.18", "bumpp": "^11.1.0", "esbuild": "^0.27.2", @@ -69,6 +74,7 @@ "happy-dom": "^20.3.7", "husky": "^9.1.7", "netlify-cli": "^23.13.5", + "playwright": "^1.61.1", "prettier": "^3.8.1", "release-it": "^19.2.4", "simple-git": "^3.36.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f7bd96..5efa677 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,15 @@ importers: '@size-limit/preset-small-lib': specifier: ^12.0.0 version: 12.0.0(size-limit@12.0.0(jiti@2.6.1)) + '@vitest/browser': + specifier: 4.0.18 + version: 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/browser-playwright': + specifier: 4.0.18 + version: 4.0.18(playwright@1.61.1)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18) '@vitest/coverage-v8': specifier: 4.0.18 - version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2)) + version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) bumpp: specifier: ^11.1.0 version: 11.1.0 @@ -41,6 +47,9 @@ importers: netlify-cli: specifier: ^23.13.5 version: 23.13.5(@types/node@25.0.10)(db0@0.3.1)(ioredis@5.6.0)(picomatch@4.0.3)(rollup@4.60.3)(supports-color@10.2.2) + playwright: + specifier: ^1.61.1 + version: 1.61.1 prettier: specifier: ^3.8.1 version: 3.8.1 @@ -61,7 +70,20 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(@vitest/browser-playwright@4.0.18)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2) + + demo: + dependencies: + shiki: + specifier: ^4.3.1 + version: 4.3.1 + web-component-base: + specifier: workspace:* + version: link:.. + devDependencies: + vite: + specifier: ^7.3.1 + version: 7.3.3(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.9.0) docs: dependencies: @@ -1481,6 +1503,9 @@ packages: resolution: {integrity: sha512-bWLDlHsBlgKY/05wDN/V3ETcn5G2SV/SiA2ZmNvKGGlmVX4G5li7GRDhHcgYvHJHyJ8TUStqg2xtHmCs0UbAbg==} engines: {node: '>=18'} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -1776,30 +1801,58 @@ packages: resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} engines: {node: '>=20'} + '@shikijs/core@4.3.1': + resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==} + engines: {node: '>=20'} + '@shikijs/engine-javascript@4.0.2': resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} engines: {node: '>=20'} + '@shikijs/engine-javascript@4.3.1': + resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==} + engines: {node: '>=20'} + '@shikijs/engine-oniguruma@4.0.2': resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} engines: {node: '>=20'} + '@shikijs/engine-oniguruma@4.3.1': + resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==} + engines: {node: '>=20'} + '@shikijs/langs@4.0.2': resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} engines: {node: '>=20'} + '@shikijs/langs@4.3.1': + resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==} + engines: {node: '>=20'} + '@shikijs/primitive@4.0.2': resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} engines: {node: '>=20'} + '@shikijs/primitive@4.3.1': + resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==} + engines: {node: '>=20'} + '@shikijs/themes@4.0.2': resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} engines: {node: '>=20'} + '@shikijs/themes@4.3.1': + resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==} + engines: {node: '>=20'} + '@shikijs/types@4.0.2': resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} engines: {node: '>=20'} + '@shikijs/types@4.3.1': + resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==} + engines: {node: '>=20'} + '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -1990,6 +2043,17 @@ packages: engines: {node: '>=18'} hasBin: true + '@vitest/browser-playwright@4.0.18': + resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==} + peerDependencies: + playwright: '*' + vitest: 4.0.18 + + '@vitest/browser@4.0.18': + resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==} + peerDependencies: + vitest: 4.0.18 + '@vitest/coverage-v8@4.0.18': resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} peerDependencies: @@ -3597,6 +3661,11 @@ packages: fs-extra@6.0.1: resolution: {integrity: sha512-GnyIkKhhzXZUWFCaJzvyDLEEgDkPfb4/TPvJCJVuS8MWZgoSsErf++QpiAlDnKFcqhRlm+tIOcencCjyJE6ZCA==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5337,12 +5406,30 @@ packages: resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} hasBin: true + pixelmatch@7.1.0: + resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} + hasBin: true + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + postcss-nested@6.2.0: resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} engines: {node: '>=12.0'} @@ -5869,6 +5956,10 @@ packages: resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} engines: {node: '>=20'} + shiki@4.3.1: + resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==} + engines: {node: '>=20'} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -5903,6 +5994,10 @@ packages: engines: {node: '>=6'} hasBin: true + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -6268,6 +6363,10 @@ packages: tomlify-j0.4@3.0.0: resolution: {integrity: sha512-2Ulkc8T7mXJ2l0W476YC/A209PR38Nw8PuaCNtk9uI3t1zzFdGQeWYGQvmj2PZkVvRC/Yoi4xQKMRnWc/N29tQ==} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -8493,6 +8592,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@polka/url@1.0.0-next.29': {} + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -8665,36 +8766,74 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 + '@shikijs/core@4.3.1': + dependencies: + '@shikijs/primitive': 4.3.1 + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + '@shikijs/engine-javascript@4.0.2': dependencies: '@shikijs/types': 4.0.2 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.6 + '@shikijs/engine-javascript@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + '@shikijs/engine-oniguruma@4.0.2': dependencies: '@shikijs/types': 4.0.2 '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/engine-oniguruma@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/langs@4.0.2': dependencies: '@shikijs/types': 4.0.2 + '@shikijs/langs@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/primitive@4.0.2': dependencies: '@shikijs/types': 4.0.2 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + '@shikijs/primitive@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/themes@4.0.2': dependencies: '@shikijs/types': 4.0.2 + '@shikijs/themes@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/types@4.0.2': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + '@shikijs/types@4.3.1': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/vscode-textmate@10.0.2': {} '@simple-git/args-pathspec@1.0.3': {} @@ -8895,7 +9034,37 @@ snapshots: - rollup - supports-color - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))': + '@vitest/browser-playwright@4.0.18(playwright@1.61.1)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18)': + dependencies: + '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2)) + playwright: 1.61.1 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(@vitest/browser-playwright@4.0.18)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18)': + dependencies: + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2)) + '@vitest/utils': 4.0.18 + magic-string: 0.30.21 + pixelmatch: 7.1.0 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(@vitest/browser-playwright@4.0.18)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2) + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -8907,7 +9076,9 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(@vitest/browser-playwright@4.0.18)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2) + optionalDependencies: + '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18) '@vitest/expect@4.0.18': dependencies: @@ -8970,7 +9141,7 @@ snapshots: '@vue/shared': 3.5.27 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.6 + postcss: 8.5.14 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.27': @@ -10009,11 +10180,11 @@ snapshots: dependencies: node-source-walk: 7.0.1 - detective-postcss@7.0.1(postcss@8.5.6): + detective-postcss@7.0.1(postcss@8.5.14): dependencies: is-url: 1.2.4 - postcss: 8.5.6 - postcss-values-parser: 6.0.2(postcss@8.5.6) + postcss: 8.5.14 + postcss-values-parser: 6.0.2(postcss@8.5.14) detective-sass@6.0.1: dependencies: @@ -10379,7 +10550,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esutils@2.0.3: {} @@ -10733,6 +10904,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -13015,6 +13189,10 @@ snapshots: sonic-boom: 4.2.0 thread-stream: 3.1.0 + pixelmatch@7.1.0: + dependencies: + pngjs: 7.0.0 + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -13027,6 +13205,16 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + + pngjs@7.0.0: {} + postcss-nested@6.2.0(postcss@8.5.14): dependencies: postcss: 8.5.14 @@ -13037,11 +13225,11 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-values-parser@6.0.2(postcss@8.5.6): + postcss-values-parser@6.0.2(postcss@8.5.14): dependencies: color-name: 1.1.4 is-url-superb: 4.0.0 - postcss: 8.5.6 + postcss: 8.5.14 quote-unquote: 1.0.0 postcss@8.5.14: @@ -13063,7 +13251,7 @@ snapshots: detective-amd: 6.0.1 detective-cjs: 6.0.1 detective-es6: 5.0.1 - detective-postcss: 7.0.1(postcss@8.5.6) + detective-postcss: 7.0.1(postcss@8.5.14) detective-sass: 6.0.1 detective-scss: 5.0.1 detective-stylus: 5.0.1 @@ -13071,7 +13259,7 @@ snapshots: detective-vue2: 2.2.0(supports-color@10.2.2)(typescript@5.9.3) module-definition: 6.0.1 node-source-walk: 7.0.1 - postcss: 8.5.6 + postcss: 8.5.14 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -13790,6 +13978,17 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + shiki@4.3.1: + dependencies: + '@shikijs/core': 4.3.1 + '@shikijs/engine-javascript': 4.3.1 + '@shikijs/engine-oniguruma': 4.3.1 + '@shikijs/langs': 4.3.1 + '@shikijs/themes': 4.3.1 + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -13840,6 +14039,12 @@ snapshots: transitivePeerDependencies: - supports-color + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + sisteransi@1.0.5: {} sitemap@9.0.1: @@ -14202,6 +14407,8 @@ snapshots: tomlify-j0.4@3.0.0: {} + totalist@3.0.1: {} + tr46@0.0.3: {} trim-lines@3.0.1: {} @@ -14518,11 +14725,26 @@ snapshots: terser: 5.39.0 yaml: 2.8.2 + vite@7.3.3(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.9.0): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.14 + rollup: 4.60.3 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.0.10 + fsevents: 2.3.3 + jiti: 2.6.1 + terser: 5.39.0 + yaml: 2.9.0 + vitefu@1.1.3(vite@7.3.3(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2)): optionalDependencies: vite: 7.3.3(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2) - vitest@4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(@vitest/browser-playwright@4.0.18)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2)) @@ -14547,6 +14769,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.8.0 '@types/node': 25.0.10 + '@vitest/browser-playwright': 4.0.18(playwright@1.61.1)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18) happy-dom: 20.3.7 transitivePeerDependencies: - jiti diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a553da6..9c06989 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: # include packages in subfolders (e.g. apps/ and packages/) - 'docs/' + - 'demo/' allowBuilds: netlify-cli: false diff --git a/test/e2e/README.md b/test/e2e/README.md new file mode 100644 index 0000000..2bf1e95 --- /dev/null +++ b/test/e2e/README.md @@ -0,0 +1,49 @@ +# End-to-end example tests + +These specs load each `demo/examples/*` component into a **real browser** (Chromium +via Playwright) and drive it the way a user would. They exist because the v5 +correctness work targets behaviors that happy-dom cannot reproduce — custom +element upgrade ordering, the constructor-attribute rule, empty-string/removed +attributes, and adopted stylesheets — so the unit suite alone can pass while a +real browser misbehaves. + +## Running + +```sh +pnpm test:e2e # browser specs only (this folder) +pnpm test # fast unit suite (happy-dom), unchanged +pnpm test:all # both +``` + +Config lives in `vitest.e2e.config.mjs` (Vitest browser mode + the +`@vitest/browser-playwright` provider). It is kept separate from +`vitest.config.mjs` so `pnpm test` stays fast and needs no browser. The +Chromium binary is installed with `npx playwright install chromium`. + +## Coverage + +One spec per example folder: + +| Example folder | Behavior exercised | +| --------------------- | ------------------------------------------------------------------------- | +| `on-changes` | v5 `onChanges` payload — camelCase `property` vs kebab `attribute` | +| `strict-props` | v5 default log-and-skip vs `static strictProps` throwing | +| `attribute-lifecycle` | v5 empty-string stays `''`, removal resets to default, markup wins | +| `lifecycle-order` | v5 buffering — `onInit` runs before the first render, no replayed onChanges | +| `just-parts` | `html` + `createElement` on a vanilla `HTMLElement` | +| `demo` | counter / toggle / hello-world / boolean props / onDestroy span | +| `type-restore` | number, boolean, string, and object prop round-trips | +| `props-blueprint` | non-zero default, camelCase↔kebab, authored attribute override | +| `style-objects` | computed inline style objects, type-driven style sets | +| `use-shadow` | rendering into an open shadow root | +| `constructed-styles` | `static styles` via `adoptedStyleSheets` in the shadow root | +| `templating` | interpolated lists / links via the built-in `html` | + +## Intentionally not covered + +- **`demo/examples/pens/counter-toggle.html`** and + **`demo/examples/templating/with-lit.js`** load their dependencies from a CDN + (`esm.sh` / `unpkg`) at runtime. They are omitted from the hermetic e2e run to + avoid a network dependency; the same `WebComponent` behaviors they show are + already covered by the local specs above (the `templating` spec covers + `demo/examples/templating/index.js`). diff --git a/test/e2e/attribute-lifecycle.test.mjs b/test/e2e/attribute-lifecycle.test.mjs new file mode 100644 index 0000000..161ead7 --- /dev/null +++ b/test/e2e/attribute-lifecycle.test.mjs @@ -0,0 +1,37 @@ +import { beforeEach, expect, test } from 'vitest' +import '../../demo/examples/attribute-lifecycle/index.js' + +beforeEach(() => { + document.body.innerHTML = '' +}) + +test('the declared default is used when no attribute is authored', () => { + document.body.innerHTML = '' + const el = document.querySelector('attr-demo') + expect(el.querySelector('.value').textContent).toBe('"default-label"') + expect(el.querySelector('.type').textContent).toBe('string') +}) + +test('empty string stays an empty string (not coerced to boolean)', () => { + document.body.innerHTML = '' + const el = document.querySelector('attr-demo') + el.querySelector('.set-empty').click() + expect(el.getAttribute('label')).toBe('') + expect(el.querySelector('.value').textContent).toBe('""') + expect(el.querySelector('.type').textContent).toBe('string') +}) + +test('removeAttribute resets the prop to the declared default', () => { + document.body.innerHTML = '' + const el = document.querySelector('attr-demo') + el.querySelector('.set-value').click() + expect(el.querySelector('.value').textContent).toBe('"hello"') + el.querySelector('.remove').click() + expect(el.querySelector('.value').textContent).toBe('"default-label"') +}) + +test('an attribute authored in markup wins over the default', () => { + document.body.innerHTML = '' + const el = document.querySelector('attr-demo') + expect(el.querySelector('.value').textContent).toBe('"from markup"') +}) diff --git a/test/e2e/constructed-styles.test.mjs b/test/e2e/constructed-styles.test.mjs new file mode 100644 index 0000000..97be1b3 --- /dev/null +++ b/test/e2e/constructed-styles.test.mjs @@ -0,0 +1,15 @@ +import { beforeEach, expect, test } from 'vitest' +import '../../demo/examples/constructed-styles/index.js' + +beforeEach(() => { + document.body.innerHTML = '' +}) + +test('applies a constructable stylesheet via adoptedStyleSheets in the shadow root', () => { + document.body.innerHTML = '' + const el = document.querySelector('styled-elements') + + expect(el.shadowRoot).toBeTruthy() + expect(el.shadowRoot.adoptedStyleSheets.length).toBeGreaterThan(0) + expect(el.shadowRoot.querySelector('p').textContent.trim()).toBe('Wow!?') +}) diff --git a/test/e2e/demo.test.mjs b/test/e2e/demo.test.mjs new file mode 100644 index 0000000..4f303f5 --- /dev/null +++ b/test/e2e/demo.test.mjs @@ -0,0 +1,48 @@ +import { beforeEach, expect, test } from 'vitest' +import '../../demo/examples/demo/Counter.mjs' +import '../../demo/examples/demo/Toggle.js' +import '../../demo/examples/demo/HelloWorld.mjs' +import '../../demo/examples/demo/BooleanPropTest.mjs' +import '../../demo/examples/demo/SimpleText.mjs' + +beforeEach(() => { + document.body.innerHTML = '' +}) + +test('Counter increments on click', () => { + document.body.innerHTML = '' + const el = document.querySelector('my-counter') + // re-query after each render: object templates rebuild child nodes + expect(el.querySelector('#btn').textContent.trim()).toBe('0') + el.querySelector('#btn').click() + expect(el.querySelector('#btn').textContent.trim()).toBe('1') +}) + +test('Toggle flips its boolean value on click', () => { + document.body.innerHTML = '' + const el = document.querySelector('my-toggle') + expect(el.querySelector('button').textContent.trim()).toBe('false') + el.querySelector('button').click() + expect(el.querySelector('button').textContent.trim()).toBe('true') +}) + +test('HelloWorld renders defaults and counts clicks', () => { + document.body.innerHTML = '' + const el = document.querySelector('hello-world') + expect(el.textContent).toContain('Hello World') + el.querySelector('button').click() + expect(el.textContent).toContain('Clicked 1') +}) + +test('BooleanPropTest reflects camelCase↔kebab boolean props', () => { + document.body.innerHTML = '' + const el = document.querySelector('boolean-prop-test') + expect(el.textContent).toContain('is-inline: false') + expect(el.textContent).toContain('another-one: false') +}) + +test('SimpleText renders its clickable span', () => { + document.body.innerHTML = '' + const el = document.querySelector('simple-text') + expect(el.textContent).toContain('Click me!') +}) diff --git a/test/e2e/just-parts.test.mjs b/test/e2e/just-parts.test.mjs new file mode 100644 index 0000000..7066deb --- /dev/null +++ b/test/e2e/just-parts.test.mjs @@ -0,0 +1,19 @@ +import { beforeEach, expect, test } from 'vitest' +import '../../demo/examples/just-parts/index.js' + +beforeEach(() => { + document.body.innerHTML = '' +}) + +test('html + createElement build interactive DOM on a vanilla HTMLElement', () => { + document.body.innerHTML = '' + const btn = document.querySelector('#quote-btn') + + expect(btn).toBeTruthy() + expect(btn.textContent.trim()).toBe('click me') + + btn.click() + expect(btn.textContent).toBe('clicked 1') + btn.click() + expect(btn.textContent).toBe('clicked 2') +}) diff --git a/test/e2e/lifecycle-order.test.mjs b/test/e2e/lifecycle-order.test.mjs new file mode 100644 index 0000000..d4a1770 --- /dev/null +++ b/test/e2e/lifecycle-order.test.mjs @@ -0,0 +1,36 @@ +import { beforeEach, expect, test } from 'vitest' +import '../../demo/examples/lifecycle-order/index.js' + +beforeEach(() => { + document.body.innerHTML = '' +}) + +function logLines(el) { + const ol = el.nextElementSibling + return [...ol.querySelectorAll('li')].map((li) => li.textContent) +} + +test('onInit runs before the first render and already sees authored attributes', () => { + document.body.innerHTML = + '' + const el = document.querySelector('#authored') + const lines = logLines(el) + + expect(lines[0]).toBe('onInit (props.label = "authored")') + expect(lines[1]).toBe('render (props.label = "authored")') + expect(lines[2]).toBe('afterViewInit') + // exactly one render, and the pre-connect change is not replayed via onChanges + expect(lines).toHaveLength(3) + expect(lines.some((l) => l.startsWith('onChanges'))).toBe(false) +}) + +test('default-value path keeps the onInit → render → afterViewInit order', () => { + document.body.innerHTML = '' + const el = document.querySelector('#default') + const lines = logLines(el) + + expect(lines[0]).toBe('onInit (props.label = "default")') + expect(lines[1]).toBe('render (props.label = "default")') + expect(lines[2]).toBe('afterViewInit') + expect(lines).toHaveLength(3) +}) diff --git a/test/e2e/on-changes.test.mjs b/test/e2e/on-changes.test.mjs new file mode 100644 index 0000000..cd833e8 --- /dev/null +++ b/test/e2e/on-changes.test.mjs @@ -0,0 +1,23 @@ +import { beforeEach, expect, test } from 'vitest' +import '../../demo/examples/on-changes/index.js' + +beforeEach(() => { + document.body.innerHTML = '' +}) + +test('onChanges payload separates camelCase property from kebab attribute', () => { + document.body.innerHTML = '' + const el = document.querySelector('change-logger') + + // nothing has changed yet + expect(el.querySelector('#no-changes')).toBeTruthy() + expect(el.querySelector('#greeting').textContent.trim()).toBe('Hello World') + + el.querySelector('#rename').click() + + expect(el.querySelector('#property').textContent).toBe('myName') + expect(el.querySelector('#attribute').textContent).toBe('my-name') + expect(el.querySelector('#previous').textContent).toBe('World') + expect(el.querySelector('#current').textContent).toBe('Ayo') + expect(el.querySelector('#greeting').textContent.trim()).toBe('Hello Ayo') +}) diff --git a/test/e2e/props-blueprint.test.mjs b/test/e2e/props-blueprint.test.mjs new file mode 100644 index 0000000..5cea2fa --- /dev/null +++ b/test/e2e/props-blueprint.test.mjs @@ -0,0 +1,27 @@ +import { beforeEach, expect, test } from 'vitest' +import '../../demo/examples/props-blueprint/index.js' +import '../../demo/examples/props-blueprint/hello-world.js' + +beforeEach(() => { + document.body.innerHTML = '' +}) + +test('Counter starts at its non-zero default and increments', () => { + document.body.innerHTML = '' + const el = document.querySelector('my-counter') + expect(el.querySelector('#btn').textContent.trim()).toBe('123') + el.querySelector('#btn').click() + expect(el.querySelector('#btn').textContent.trim()).toBe('124') +}) + +test('camelCase prop myName renders', () => { + document.body.innerHTML = '' + const el = document.querySelector('hello-world') + expect(el.textContent).toContain('Hello World') +}) + +test('authored my-name attribute overrides the default', () => { + document.body.innerHTML = '' + const el = document.querySelector('hello-world') + expect(el.textContent).toContain('Hello Ayo') +}) diff --git a/test/e2e/strict-props.test.mjs b/test/e2e/strict-props.test.mjs new file mode 100644 index 0000000..399e276 --- /dev/null +++ b/test/e2e/strict-props.test.mjs @@ -0,0 +1,33 @@ +import { beforeEach, expect, test, vi } from 'vitest' +import '../../demo/examples/strict-props/index.js' + +beforeEach(() => { + document.body.innerHTML = '' +}) + +test('default mode logs and skips a type violation, keeping the value/type', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + document.body.innerHTML = '' + const el = document.querySelector('lenient-counter') + + el.querySelector('#lenient-bump').click() + expect(el.querySelector('#lenient-bump').textContent.trim()).toBe('count = 1') + + el.querySelector('#lenient-violate').click() + // the string write was skipped; count is still the number 1 + expect(el.querySelector('#lenient-status').textContent.trim()).toBe( + 'count is still 1 (number)' + ) + expect(spy).toHaveBeenCalled() + spy.mockRestore() +}) + +test('strictProps throws a TypeError on a type violation', () => { + document.body.innerHTML = '' + const el = document.querySelector('strict-counter') + + el.querySelector('#strict-violate').click() + expect(el.querySelector('#strict-status').textContent.trim()).toBe( + 'threw: TypeError' + ) +}) diff --git a/test/e2e/style-objects.test.mjs b/test/e2e/style-objects.test.mjs new file mode 100644 index 0000000..3bf5edd --- /dev/null +++ b/test/e2e/style-objects.test.mjs @@ -0,0 +1,22 @@ +import { beforeEach, expect, test } from 'vitest' +import '../../demo/examples/style-objects/index.js' + +beforeEach(() => { + document.body.innerHTML = '' +}) + +test('style object applies computed inline styles for the default type', () => { + document.body.innerHTML = '' + const div = document.querySelector('styled-elements div') + expect(div.style.backgroundColor).toBe('blue') + expect(div.style.padding).toBe('1em') + expect(document.querySelector('styled-elements p').textContent.trim()).toBe( + 'Wow!' + ) +}) + +test('the type attribute selects a different style set', () => { + document.body.innerHTML = '' + const div = document.querySelector('styled-elements div') + expect(div.style.backgroundColor).toBe('yellow') +}) diff --git a/test/e2e/templating.test.mjs b/test/e2e/templating.test.mjs new file mode 100644 index 0000000..655f290 --- /dev/null +++ b/test/e2e/templating.test.mjs @@ -0,0 +1,25 @@ +import { beforeEach, expect, test } from 'vitest' +import '../../demo/examples/templating/index.js' + +beforeEach(() => { + document.body.innerHTML = '' +}) + +test('renders interpolated lists and links and counts on click', () => { + document.body.innerHTML = '' + const el = document.querySelector('my-counter') + + expect(el.querySelector('#btn span').textContent.trim()).toBe('123') + + const paragraphs = [...el.querySelectorAll('p')].map((p) => + p.textContent.trim() + ) + expect(paragraphs).toContain('what') + + const links = el.querySelectorAll('li a') + expect(links.length).toBe(2) + expect(links[0].getAttribute('href')).toBe('https://ayco.io') + + el.querySelector('#btn').click() + expect(el.querySelector('#btn span').textContent.trim()).toBe('124') +}) diff --git a/test/e2e/type-restore.test.mjs b/test/e2e/type-restore.test.mjs new file mode 100644 index 0000000..fbcba0e --- /dev/null +++ b/test/e2e/type-restore.test.mjs @@ -0,0 +1,40 @@ +import { beforeEach, expect, test } from 'vitest' +import '../../demo/examples/type-restore/Counter.mjs' +import '../../demo/examples/type-restore/Toggle.mjs' +import '../../demo/examples/type-restore/HelloWorld.mjs' +import '../../demo/examples/type-restore/Object.mjs' + +beforeEach(() => { + document.body.innerHTML = '' +}) + +test('number prop restores and increments', () => { + document.body.innerHTML = '' + const el = document.querySelector('my-counter') + expect(el.querySelector('button').textContent.trim()).toBe('1') + el.querySelector('button').click() + expect(el.querySelector('button').textContent.trim()).toBe('2') +}) + +test('boolean prop drives On/Off label', () => { + document.body.innerHTML = '' + const el = document.querySelector('my-toggle') + expect(el.querySelector('#toggle').textContent.trim()).toBe('Off') + el.querySelector('#toggle').click() + expect(el.querySelector('#toggle').textContent.trim()).toBe('On') +}) + +test('string prop appends on click', () => { + document.body.innerHTML = '' + const el = document.querySelector('my-hello-world') + expect(el.querySelector('button').textContent.trim()).toBe('Wah!') + el.querySelector('button').click() + expect(el.querySelector('button').textContent.trim()).toBe('Waah!') +}) + +test('object prop renders its nested values', () => { + document.body.innerHTML = '' + const el = document.querySelector('my-object') + expect(el.querySelector('#greeting-field').textContent).toContain('worldzz') + expect(el.querySelector('#age-field').value).toBe('2') +}) diff --git a/test/e2e/use-shadow.test.mjs b/test/e2e/use-shadow.test.mjs new file mode 100644 index 0000000..f906aef --- /dev/null +++ b/test/e2e/use-shadow.test.mjs @@ -0,0 +1,23 @@ +import { beforeEach, expect, test } from 'vitest' +import '../../demo/examples/use-shadow/index.js' + +beforeEach(() => { + document.body.innerHTML = '' +}) + +test('renders into an open shadow root and increments on click', () => { + document.body.innerHTML = '' + const el = document.querySelector('my-counter') + + expect(el.shadowRoot).toBeTruthy() + // light DOM stays empty; content lives in the shadow root + expect(el.querySelector('#btn')).toBeNull() + expect(el.shadowRoot.querySelector('#btn span').textContent.trim()).toBe( + '123' + ) + + el.shadowRoot.querySelector('#btn').click() + expect(el.shadowRoot.querySelector('#btn span').textContent.trim()).toBe( + '124' + ) +}) diff --git a/vitest.config.mjs b/vitest.config.mjs index 6f2ecbc..7fe7e10 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -7,6 +7,8 @@ export default defineConfig({ '**/node_modules/**', '**/dist/**', './temp/**', + // e2e specs run in a real browser via vitest.e2e.config.mjs + 'test/e2e/**', ], coverage: { enabled: true, diff --git a/vitest.e2e.config.mjs b/vitest.e2e.config.mjs new file mode 100644 index 0000000..79c5f91 --- /dev/null +++ b/vitest.e2e.config.mjs @@ -0,0 +1,35 @@ +import { defineConfig } from 'vitest/config' +import { playwright } from '@vitest/browser-playwright' +import { fileURLToPath } from 'node:url' + +const src = (p) => fileURLToPath(new URL(`./src/${p}`, import.meta.url)) + +// End-to-end specs run against the real `src/` in a real browser (Chromium via +// Playwright), so behaviors that happy-dom cannot reproduce — custom-element +// upgrade ordering, constructor attribute rules, adopted stylesheets — are +// actually exercised. Kept separate from the unit config so `pnpm test` stays +// fast and headless-browser-free. +export default defineConfig({ + // The demo examples import the `web-component-base` package; resolve it to + // local source (mirrors demo/vite.config.js) so e2e tests exercise src. + resolve: { + alias: [ + { + find: /^web-component-base\/utils$/, + replacement: src('utils/index.js'), + }, + { find: /^web-component-base\/html$/, replacement: src('html.js') }, + { find: /^web-component-base$/, replacement: src('index.js') }, + ], + }, + test: { + include: ['test/e2e/**/*.test.mjs'], + browser: { + enabled: true, + provider: playwright(), + headless: true, + screenshotFailures: false, + instances: [{ browser: 'chromium' }], + }, + }, +})