@webjsdev/core 0.7.9 → 0.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.9",
3
+ "version": "0.7.10",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
package/src/metadata.d.ts CHANGED
@@ -152,6 +152,14 @@ export type AppleWebAppMetadata =
152
152
  */
153
153
  export type JsonLd = Record<string, unknown>;
154
154
 
155
+ /**
156
+ * A `metadata.preconnect` / `metadata.dnsPrefetch` hint. A bare URL string,
157
+ * or an object form. `crossorigin` (preconnect only) emits the `crossorigin`
158
+ * attribute; `true` / `''` -> a bare `crossorigin`, a string -> its value
159
+ * (e.g. `'anonymous'`, `'use-credentials'`).
160
+ */
161
+ export type PreconnectHint = string | { url: string; crossorigin?: string | boolean };
162
+
155
163
  /** A `metadata.preload` link descriptor (emitted as `<link rel="preload">`). */
156
164
  export interface PreloadDescriptor {
157
165
  href: MetadataUrl;
@@ -247,6 +255,23 @@ export interface Metadata {
247
255
  /** `<link rel="preload">` hints (fonts, images, etc.). */
248
256
  preload?: PreloadDescriptor[];
249
257
 
258
+ /**
259
+ * `<link rel="preconnect">` hints: warm DNS + TLS + TCP to a cross-origin
260
+ * the page is about to talk to (an API host, a font / image CDN). Each
261
+ * entry is a URL string or `{ url, crossorigin? }` (a font CDN needs
262
+ * `crossorigin`). A single value or an array. webjs ALSO auto-emits one
263
+ * preconnect to the resolved vendor CDN origin for an unpinned app (deduped
264
+ * against an author-declared one).
265
+ */
266
+ preconnect?: PreconnectHint | PreconnectHint[];
267
+
268
+ /**
269
+ * `<link rel="dns-prefetch">` hints: resolve a cross-origin's DNS ahead of
270
+ * use (a lighter-weight precursor to `preconnect`). A URL string,
271
+ * `{ url }`, or an array.
272
+ */
273
+ dnsPrefetch?: PreconnectHint | PreconnectHint[];
274
+
250
275
  /**
251
276
  * JSON-LD structured data (schema.org), emitted as one or more
252
277
  * `<script type="application/ld+json">` blocks in `<head>`. A single
package/src/testing.js CHANGED
@@ -57,6 +57,60 @@ export async function fixture(template) {
57
57
  return el;
58
58
  }
59
59
 
60
+ /**
61
+ * Server-render a template THEN hydrate it in the same browser session,
62
+ * awaiting the element's native `updateComplete` so the post-hydration DOM
63
+ * is observable deterministically.
64
+ *
65
+ * This is the SSR + hydrate entry, distinct from `fixture()`. `fixture()`
66
+ * server-renders and parses the HTML into the container, but it only waits
67
+ * two macrotasks and never awaits the real update cycle. `ssrFixture()`
68
+ * renders the SAME SSR markup (with DSD), lets the browser upgrade the
69
+ * custom element, then awaits the element's `updateComplete` promise (the
70
+ * actual render-cycle resolution), not a timer.
71
+ *
72
+ * **What it proves.** The SSR'd markup and the post-hydration DOM agree.
73
+ * Because the returned element is the live, hydrated element, a hydration
74
+ * mismatch (the server rendered one thing, the client rendered another) is
75
+ * observable: compare the SSR'd inner HTML against `el.innerHTML` /
76
+ * `el.shadowRoot.innerHTML` after this resolves and a divergence shows up.
77
+ *
78
+ * **When to use (AI hint):** Use `ssrFixture()` when the test is about the
79
+ * SSR-then-hydrate round-trip (does the server paint survive hydration, does
80
+ * a `.prop` decode back, does a signal-backed render match). Use the plain
81
+ * `fixture()` for a quick mount where the SSR-vs-hydrate distinction does not
82
+ * matter.
83
+ *
84
+ * Requires a real DOM (the WTR Chromium session). The component class must
85
+ * already be registered (the test imports its module, same as `fixture()`).
86
+ *
87
+ * @param {import('./html.js').TemplateResult | string} template
88
+ * Either a `html\`…\`` result or a raw HTML string.
89
+ * @returns {Promise<Element>} The hydrated root element.
90
+ */
91
+ export async function ssrFixture(template) {
92
+ const container = getContainer();
93
+ if (typeof template === 'string') {
94
+ container.innerHTML = template;
95
+ } else if (template && typeof template === 'object' && template._$webjs === 'template') {
96
+ const { renderToString } = await import('./render-server.js');
97
+ const html = await renderToString(template, { ssr: true });
98
+ container.innerHTML = html;
99
+ } else {
100
+ throw new Error('ssrFixture() expects an html`…` template or an HTML string');
101
+ }
102
+
103
+ const el = container.firstElementChild;
104
+ if (!el) throw new Error('ssrFixture() produced no element');
105
+
106
+ // Drive the real update cycle. The browser upgrades the SSR'd custom
107
+ // element on innerHTML assignment; its connectedCallback queues the first
108
+ // render. Awaiting the native updateComplete promise (not a timer) is the
109
+ // whole point: the test observes the post-hydration DOM deterministically.
110
+ await flushUpdate(el);
111
+ return el;
112
+ }
113
+
60
114
  /**
61
115
  * Wait for a component's next render cycle to complete.
62
116
  *
@@ -65,17 +119,105 @@ export async function fixture(template) {
65
119
  * subscribes to, any change that triggers a re-render. The re-render
66
120
  * is async (microtask-batched), so you need to await before asserting.
67
121
  *
122
+ * Awaits the element's native `updateComplete` promise when present (the
123
+ * real render-cycle resolution), falling back to a microtask flush for a
124
+ * plain element that has no `updateComplete` (back-compatible).
125
+ *
68
126
  * @param {Element} el The component element.
69
127
  * @returns {Promise<void>}
70
128
  */
71
129
  export async function waitForUpdate(el) {
72
- // WebComponent batches via queueMicrotask, so two microtask yields
73
- // is sufficient: one for the scheduling microtask, one for any
74
- // cascading updates.
130
+ await flushUpdate(el);
131
+ }
132
+
133
+ /**
134
+ * Await the real update cycle of an element. For a WebComponent this awaits
135
+ * the native `updateComplete` promise; for anything else it yields two
136
+ * macrotasks (the legacy behaviour) so cascading updates settle.
137
+ *
138
+ * @param {Element} el
139
+ * @returns {Promise<void>}
140
+ */
141
+ async function flushUpdate(el) {
142
+ const uc = el && /** @type {any} */ (el).updateComplete;
143
+ if (uc && typeof uc.then === 'function') {
144
+ await uc;
145
+ // A microtask yield lets any cascading child update flush too.
146
+ await Promise.resolve();
147
+ return;
148
+ }
75
149
  await new Promise((r) => setTimeout(r, 0));
76
150
  await new Promise((r) => setTimeout(r, 0));
77
151
  }
78
152
 
153
+ /**
154
+ * Assert an element's subtree has no accessibility violations, using the
155
+ * axe-core engine in the real browser. OPT-IN: nothing calls this for you,
156
+ * it is never a forced gate.
157
+ *
158
+ * axe-core is a TEST-ONLY peer: it is imported dynamically here, so it is
159
+ * NOT a hard dependency of `@webjsdev/core`. An app that does not do a11y
160
+ * testing never needs it. Install it where you run the test:
161
+ * `npm install -D axe-core`.
162
+ *
163
+ * On a violation it throws an Error whose message lists each violation's id,
164
+ * impact, a short help string, and the failing nodes' selectors, so the test
165
+ * failure is actionable. On zero violations it resolves.
166
+ *
167
+ * ```js
168
+ * import { ssrFixture, assertNoA11yViolations } from '@webjsdev/core/testing';
169
+ * const el = await ssrFixture(html`<my-form></my-form>`);
170
+ * await assertNoA11yViolations(el);
171
+ * // tune rules: await assertNoA11yViolations(el, { rules: { 'color-contrast': { enabled: false } } });
172
+ * ```
173
+ *
174
+ * @param {Element} el The element (subtree root) to scan.
175
+ * @param {object} [opts] Options passed through to `axe.run` (e.g. `rules`).
176
+ * @returns {Promise<void>}
177
+ */
178
+ export async function assertNoA11yViolations(el, opts) {
179
+ if (!el || typeof el !== 'object') {
180
+ throw new Error('assertNoA11yViolations() expects a DOM element');
181
+ }
182
+ let axe;
183
+ try {
184
+ const mod = await import('axe-core');
185
+ // axe-core ships a UMD bundle. Depending on how the test runner / bundler
186
+ // wraps it, the engine may sit on the namespace, on `.default`, on a
187
+ // nested `.default.default`, or only as the `window.axe` side-effect the
188
+ // UMD installs. Probe in that order for a `run` function.
189
+ const candidates = [mod, mod && mod.default, mod && mod.default && mod.default.default,
190
+ typeof globalThis !== 'undefined' ? globalThis.axe : undefined];
191
+ axe = candidates.find((c) => c && typeof c.run === 'function');
192
+ } catch {
193
+ throw new Error(
194
+ 'assertNoA11yViolations needs axe-core. Install it: npm install -D axe-core'
195
+ );
196
+ }
197
+ if (!axe || typeof axe.run !== 'function') {
198
+ throw new Error(
199
+ 'assertNoA11yViolations needs axe-core. Install it: npm install -D axe-core'
200
+ );
201
+ }
202
+
203
+ const results = await axe.run(el, opts || {});
204
+ const violations = results.violations || [];
205
+ if (violations.length === 0) return;
206
+
207
+ const lines = violations.map((v) => {
208
+ const targets = (v.nodes || [])
209
+ .map((n) => (n.target || []).join(' '))
210
+ .filter(Boolean)
211
+ .join(', ');
212
+ return ` - [${v.id}] (${v.impact || 'unknown'}) ${v.help}${targets ? ` -> ${targets}` : ''}`;
213
+ });
214
+ const err = new Error(
215
+ `a11y: ${violations.length} accessibility violation(s) found:\n${lines.join('\n')}`
216
+ );
217
+ err.name = 'AssertionError';
218
+ throw err;
219
+ }
220
+
79
221
  /**
80
222
  * Simulate a click event on an element.
81
223
  *