@webjsdev/core 0.7.8 → 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/dist/webjs-core-testing.js +23 -2
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +1 -1
- package/dist/webjs-core.js.map +2 -2
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/expose.js +94 -4
- package/src/metadata.d.ts +25 -0
- package/src/testing.js +145 -3
package/index.d.ts
CHANGED
|
@@ -61,7 +61,7 @@ export { renderToString, renderToStream } from './src/render-server.js';
|
|
|
61
61
|
export { render } from './src/render-client.js';
|
|
62
62
|
export { escapeText, escapeAttr } from './src/escape.js';
|
|
63
63
|
export { notFound, redirect, isNotFound, isRedirect } from './src/nav.js';
|
|
64
|
-
export { expose, getExposed } from './src/expose.js';
|
|
64
|
+
export { expose, getExposed, validateInput } from './src/expose.js';
|
|
65
65
|
export { repeat, isRepeat } from './src/repeat.js';
|
|
66
66
|
export { Suspense, isSuspense } from './src/suspense.js';
|
|
67
67
|
export { connectWS } from './src/websocket-client.js';
|
package/index.js
CHANGED
|
@@ -15,7 +15,7 @@ export { render } from './src/render-client.js';
|
|
|
15
15
|
export { escapeText, escapeAttr } from './src/escape.js';
|
|
16
16
|
export { notFound, redirect, isNotFound, isRedirect } from './src/nav.js';
|
|
17
17
|
export { cspNonce, setCspNonceProvider } from './src/csp-nonce.js';
|
|
18
|
-
export { expose, getExposed } from './src/expose.js';
|
|
18
|
+
export { expose, getExposed, validateInput } from './src/expose.js';
|
|
19
19
|
export { repeat, isRepeat } from './src/repeat.js';
|
|
20
20
|
export { Suspense, isSuspense } from './src/suspense.js';
|
|
21
21
|
export { connectWS } from './src/websocket-client.js';
|
package/package.json
CHANGED
package/src/expose.js
CHANGED
|
@@ -22,10 +22,22 @@
|
|
|
22
22
|
* - For methods with a body (POST/PUT/PATCH/DELETE), the parsed JSON body
|
|
23
23
|
* is merged on top of params/query. The function receives ONE argument:
|
|
24
24
|
* the merged object.
|
|
25
|
-
* - If `opts.validate` is provided, it runs BEFORE the function
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* - If `opts.validate` is provided, it runs BEFORE the function on EVERY
|
|
26
|
+
* call path (the REST path here AND the internal RPC path), so a single
|
|
27
|
+
* validator declared once guards both. The framework calls `validate(input)`
|
|
28
|
+
* and interprets the return (see the validator contract below):
|
|
29
|
+
* - a `{ success: true, data? }` envelope passes (the action receives
|
|
30
|
+
* `data` when present, else the original input);
|
|
31
|
+
* - a `{ success: false, fieldErrors, message? }` envelope FAILS with a
|
|
32
|
+
* structured field-error result (422), without calling the action;
|
|
33
|
+
* - a THROW fails (→ 400 on REST, sanitized error on RPC), preserving the
|
|
34
|
+
* classic `Schema.parse`-style contract;
|
|
35
|
+
* - any OTHER returned plain value is treated as the transformed input
|
|
36
|
+
* (back-compat with the `validate: Schema.parse` transform style).
|
|
37
|
+
* The zod adapter is three lines and keeps the framework zod-free:
|
|
38
|
+
* `validate: (i) => { const r = Schema.safeParse(i);
|
|
39
|
+
* return r.success ? { success: true, data: r.data }
|
|
40
|
+
* : { success: false, fieldErrors: r.error.flatten().fieldErrors }; }`
|
|
29
41
|
* - Return value becomes a JSON `Response`; throw or return a `Response`
|
|
30
42
|
* directly for full control.
|
|
31
43
|
*
|
|
@@ -84,6 +96,84 @@ function normaliseCors(c) {
|
|
|
84
96
|
};
|
|
85
97
|
}
|
|
86
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Attach an input validator to a server action WITHOUT exposing it as a REST
|
|
101
|
+
* endpoint. The validator runs SERVER-SIDE before the action body on BOTH call
|
|
102
|
+
* paths: the internal RPC path (a client component importing the action) AND
|
|
103
|
+
* the `expose()` REST path (if the action is also exposed). This is the shared,
|
|
104
|
+
* declare-once validation surface (#245).
|
|
105
|
+
*
|
|
106
|
+
* It reuses the EXACT same `__webjsHttp` storage `expose()` writes, so the
|
|
107
|
+
* action index's `getExposed(fn)` already surfaces the validator with no new
|
|
108
|
+
* plumbing. The action stays a plain callable function (a direct unit-test
|
|
109
|
+
* import still works, just without the framework running validate).
|
|
110
|
+
*
|
|
111
|
+
* If the action is already `expose()`d, prefer passing `{ validate }` to
|
|
112
|
+
* `expose()` directly; calling both keeps the LAST validator attached.
|
|
113
|
+
*
|
|
114
|
+
* ```js
|
|
115
|
+
* // actions/posts.server.js
|
|
116
|
+
* 'use server';
|
|
117
|
+
* import { validateInput } from '@webjsdev/core';
|
|
118
|
+
*
|
|
119
|
+
* export const createPost = validateInput(
|
|
120
|
+
* async ({ title }) => { ... },
|
|
121
|
+
* (input) => {
|
|
122
|
+
* const errs = {};
|
|
123
|
+
* if (!input?.title?.trim()) errs.title = 'Title is required';
|
|
124
|
+
* return Object.keys(errs).length
|
|
125
|
+
* ? { success: false, fieldErrors: errs }
|
|
126
|
+
* : { success: true };
|
|
127
|
+
* },
|
|
128
|
+
* );
|
|
129
|
+
* ```
|
|
130
|
+
*
|
|
131
|
+
* The validator contract (the framework calls `validate(input)`):
|
|
132
|
+
* - returns `{ success: true, data? }` → valid; the action is called with
|
|
133
|
+
* `data` if present, else the original input;
|
|
134
|
+
* - returns `{ success: false, fieldErrors, message? }` (an object with a
|
|
135
|
+
* boolean `success` of `false`, or with a `fieldErrors`) → FAILED; the
|
|
136
|
+
* framework returns a structured `ActionResult`
|
|
137
|
+
* `{ success: false, fieldErrors, error: message?, status: 422 }` without
|
|
138
|
+
* calling the action body;
|
|
139
|
+
* - THROWS → mapped to a sanitized failure result (the classic
|
|
140
|
+
* `Schema.parse` contract);
|
|
141
|
+
* - returns ANY OTHER plain value → treated as the validated/coerced input
|
|
142
|
+
* and passed to the action (the `validate: Schema.parse` transform style).
|
|
143
|
+
*
|
|
144
|
+
* Disambiguation: a return is a result-envelope ONLY when it is an object with
|
|
145
|
+
* a boolean `success` property OR a `fieldErrors` property; otherwise it is a
|
|
146
|
+
* transformed input value. Caveat: if your validator returns the input
|
|
147
|
+
* VERBATIM (the bare-transform style) and that input can itself carry a
|
|
148
|
+
* `success` boolean or a `fieldErrors` key, it would be misread as an envelope.
|
|
149
|
+
* Return the explicit `{ success: true, data }` envelope in that case (the zod
|
|
150
|
+
* adapter and the recommended hand-rolled form already do, so they never
|
|
151
|
+
* collide).
|
|
152
|
+
*
|
|
153
|
+
* Server-side only; import inside `.server.{js,ts}` files. The bare
|
|
154
|
+
* `@webjsdev/core` specifier resolves to the browser entry, which excludes
|
|
155
|
+
* `validateInput` (like `expose`), so an import from client-bound code reads
|
|
156
|
+
* `undefined`. The validator itself lives in the `.server` file and never
|
|
157
|
+
* reaches the client.
|
|
158
|
+
*
|
|
159
|
+
* @template {Function} F
|
|
160
|
+
* @param {F} fn the async action implementation
|
|
161
|
+
* @param {(input: any) => any} validate the validator (a plain function or a
|
|
162
|
+
* zod-`safeParse` adapter)
|
|
163
|
+
* @returns {F} the same function, tagged with the validator
|
|
164
|
+
*/
|
|
165
|
+
export function validateInput(fn, validate) {
|
|
166
|
+
if (typeof fn !== 'function') {
|
|
167
|
+
throw new Error('validateInput(): first argument must be the action function');
|
|
168
|
+
}
|
|
169
|
+
if (typeof validate !== 'function') {
|
|
170
|
+
throw new Error('validateInput(): second argument must be a validator function');
|
|
171
|
+
}
|
|
172
|
+
const existing = /** @type any */ (fn).__webjsHttp || {};
|
|
173
|
+
/** @type any */ (fn).__webjsHttp = { ...existing, validate };
|
|
174
|
+
return fn;
|
|
175
|
+
}
|
|
176
|
+
|
|
87
177
|
/** @param {unknown} fn */
|
|
88
178
|
export function getExposed(fn) {
|
|
89
179
|
return fn && typeof fn === 'function' ? /** @type any */ (fn).__webjsHttp || null : null;
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
*
|