@vesk/testing 0.2.50-canary.4

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.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=harness.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"harness.test.d.ts","sourceRoot":"","sources":["../src/harness.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The testing harness, tested with the harness.
3
+ *
4
+ * A test helper that is wrong is worse than none: it produces confident green
5
+ * runs. So the SSR render, the DOM mount and every assertion helper are
6
+ * exercised here, including the failure messages.
7
+ *
8
+ * Run with: npx tsx packages/testing/src/harness.test.ts
9
+ */
10
+ import { attrOf, mount, renderComponent } from '@vesk/testing';
11
+ let passed = 0;
12
+ let failed = 0;
13
+ const failures = [];
14
+ function assert(cond, msg) {
15
+ if (!cond)
16
+ throw new Error(msg);
17
+ }
18
+ let chain = Promise.resolve();
19
+ function it(name, fn) {
20
+ chain = chain.then(async () => {
21
+ try {
22
+ await fn();
23
+ passed++;
24
+ console.log(` ✓ ${name}`);
25
+ }
26
+ catch (e) {
27
+ failed++;
28
+ console.log(` ✗ ${name}`);
29
+ console.log(` ${e.message}`);
30
+ failures.push({ name, message: e.message });
31
+ }
32
+ });
33
+ }
34
+ const COUNTER = `component Counter(props: { start: number }) {
35
+ const &[count, setCount] = track(props.start)
36
+ const doubled = derived(() => get(count) * 2)
37
+ <Head><title>Counter: {count}</title></Head>
38
+ <div class="wrap">
39
+ <p class="label">{count}</p>
40
+ <button class="bump" onclick={() => setCount(count + 1)}>bump</button>
41
+ <slot name="after" />
42
+ </div>
43
+ }`;
44
+ console.log('\n=== @vesk/testing harness ===');
45
+ it('renderComponent SSRs a component and returns its head', () => {
46
+ const r = renderComponent(COUNTER, 'Counter', { start: 1 });
47
+ assert(r.html.includes('class="label"'), `no markup rendered: ${r.html.slice(0, 120)}`);
48
+ assert(r.html.includes('>1<'), `props did not reach the render: ${r.html.slice(0, 200)}`);
49
+ assert(r.head.includes('<title'), `the <Head> block was not serialized: ${r.head}`);
50
+ assert(r.props.start === 1, 'props were not echoed back');
51
+ });
52
+ it('renderComponent can render the non-hydrate mode too', () => {
53
+ const r = renderComponent(COUNTER, 'Counter', { start: 2 }, { hydrate: false });
54
+ assert(r.html.includes('>2<'), 'the non-hydrate render is empty');
55
+ });
56
+ it('a compile error surfaces as a throw, not an empty string', () => {
57
+ let threw = false;
58
+ try {
59
+ renderComponent('component Broken( { <<<', 'Broken');
60
+ }
61
+ catch {
62
+ threw = true;
63
+ }
64
+ assert(threw, 'a source that cannot compile rendered as empty instead of failing the test');
65
+ });
66
+ it('mount puts the SSR output in a real DOM', () => {
67
+ const m = mount(COUNTER, 'Counter', { start: 3 });
68
+ try {
69
+ assert(m.text('.label') === '3', `label was ${JSON.stringify(m.text('.label'))}`);
70
+ assert(m.has('.bump'), 'the button is missing from the DOM');
71
+ assert(m.attr('.wrap', 'class') === 'wrap', `class attribute was ${m.attr('.wrap', 'class')}`);
72
+ assert(m.all('.label').length === 1, 'all() returned the wrong number of matches');
73
+ assert(m.text('.nope') === null, 'a missing selector should read null, not throw');
74
+ assert(m.has('.nope') === false, 'has() is wrong for a missing selector');
75
+ }
76
+ finally {
77
+ m.unmount();
78
+ }
79
+ });
80
+ it('click dispatches a real event the component can hear', async () => {
81
+ const m = mount(COUNTER, 'Counter', { start: 1 });
82
+ try {
83
+ await m.click('.bump');
84
+ // A click with no listener attached changes nothing — the point is that the
85
+ // event fires without throwing and the DOM is still readable.
86
+ assert(typeof m.text('.label') === 'string', 'the DOM became unreadable after a click');
87
+ }
88
+ finally {
89
+ m.unmount();
90
+ }
91
+ });
92
+ it('waitFor polls until the predicate holds and gives up with the HTML', async () => {
93
+ const m = mount(COUNTER, 'Counter', { start: 5 });
94
+ try {
95
+ let calls = 0;
96
+ await m.waitFor(() => ++calls >= 3, 1000);
97
+ assert(calls >= 3, 'waitFor returned before the predicate held');
98
+ let failed = false;
99
+ try {
100
+ await m.waitFor(() => false, 60);
101
+ }
102
+ catch (e) {
103
+ failed = e.message.includes('waitFor timed out');
104
+ }
105
+ assert(failed, 'waitFor did not time out on a predicate that never holds');
106
+ }
107
+ finally {
108
+ m.unmount();
109
+ }
110
+ });
111
+ it('mount installs and restores the ambient DOM', () => {
112
+ const before = globalThis.document;
113
+ const m = mount(COUNTER, 'Counter', { start: 1 });
114
+ assert(globalThis.document !== before, 'the DOM was not installed');
115
+ m.unmount();
116
+ assert(globalThis.document === before, 'the previous global was not restored');
117
+ });
118
+ it('a missing mount root fails loudly instead of returning an empty driver', () => {
119
+ // `mount` always provides a `#root`, so the only way to hit this is a custom
120
+ // selector that the output does not contain — and a test that then asserts
121
+ // against an empty DOM is a test that passes for the wrong reason.
122
+ let threw = false;
123
+ try {
124
+ mount('<p>no app root</p>', 'App', {}, { html: '<p>no app root</p>', rootSelector: '#app' });
125
+ }
126
+ catch (e) {
127
+ threw = e.message.includes('no #app');
128
+ }
129
+ assert(threw, 'mounting with a root selector the output lacks did not say so');
130
+ });
131
+ it('attrOf reads a head attribute out of a fragment', () => {
132
+ const html = renderComponent(COUNTER, 'Counter', { start: 9 }).html;
133
+ assert(attrOf(html, '.label', 'class') === 'label', 'attrOf could not find the element');
134
+ assert(attrOf(html, '.missing', 'class') === null, 'attrOf invented an attribute');
135
+ });
136
+ void chain.then(() => {
137
+ console.log(`\n${'='.repeat(50)}`);
138
+ console.log(`Results: ${passed} passed, ${failed} failed, ${passed + failed} total`);
139
+ if (failed > 0) {
140
+ for (const f of failures)
141
+ console.log(` FAIL: ${f.name} — ${f.message}`);
142
+ process.exit(1);
143
+ }
144
+ console.log('All @vesk/testing harness tests passed!');
145
+ });
@@ -0,0 +1,74 @@
1
+ /** One rendered component: its HTML plus the props it was rendered with. */
2
+ export interface RenderedComponent {
3
+ /** The SSR body HTML (no `<html>` wrapper). */
4
+ html: string;
5
+ /** The `<head>` the component declared, if any. */
6
+ head: string;
7
+ /** The props the component actually saw, after any load() merge. */
8
+ props: Record<string, unknown>;
9
+ }
10
+ export interface RenderOptions {
11
+ /**
12
+ * Render as if hydrating. Hydrate mode is the default in a real app, and it
13
+ * changes the emitted markup (markerless SSR), so a test that renders the
14
+ * wrong mode is testing the wrong thing. Default: true.
15
+ */
16
+ hydrate?: boolean;
17
+ /** File name used in error messages and source paths. */
18
+ fileName?: string;
19
+ }
20
+ /**
21
+ * Compile and server-render a component from `.vsk` source.
22
+ *
23
+ * Throws with the compiler's own diagnostic (code frame and all) when the
24
+ * source does not compile — a test failure should read like a compile error,
25
+ * not like an empty string.
26
+ */
27
+ export declare function renderComponent(source: string, componentName?: string, props?: Record<string, unknown>, options?: RenderOptions): RenderedComponent;
28
+ /** A mounted component in a real DOM, with the assertions people keep writing. */
29
+ export interface MountedComponent {
30
+ /** The document the component is mounted in. */
31
+ document: Document;
32
+ /** The element the component was mounted into. */
33
+ root: Element;
34
+ /** Current HTML of the mount root. */
35
+ html(): string;
36
+ /** `textContent` of the first match, trimmed; `null` when absent. */
37
+ text(selector: string): string | null;
38
+ /** Attribute of the first match, or `null`. */
39
+ attr(selector: string, name: string): string | null;
40
+ /** Does a match exist? */
41
+ has(selector: string): boolean;
42
+ /** All matches, for list assertions. */
43
+ all(selector: string): Element[];
44
+ /** Click the first match and flush effects. */
45
+ click(selector: string): Promise<MountedComponent>;
46
+ /** Set an input's value, dispatch `input`, and flush. */
47
+ input(selector: string, value: string): Promise<MountedComponent>;
48
+ /** Run pending microtasks and effects, then let the DOM settle. */
49
+ flush(): Promise<MountedComponent>;
50
+ /** Poll `fn` until it is truthy or the budget runs out. */
51
+ waitFor(fn: () => unknown, timeoutMs?: number): Promise<void>;
52
+ }
53
+ export interface MountOptions extends RenderOptions {
54
+ /** HTML to mount instead of rendering (e.g. to hydrate a prerendered page). */
55
+ html?: string;
56
+ /** Selector for the mount root inside the document. Default `#root`. */
57
+ rootSelector?: string;
58
+ }
59
+ /**
60
+ * Mount a component's SSR output into a real DOM and return a driver.
61
+ *
62
+ * The DOM is a linkedom document installed as the ambient `document` for the
63
+ * duration of the returned driver's lifetime — `restore()` puts the previous
64
+ * globals back, and `unmount()` also calls it.
65
+ */
66
+ export declare function mount(source: string, componentName?: string, props?: Record<string, unknown>, options?: MountOptions): MountedComponent & {
67
+ unmount(): void;
68
+ };
69
+ /**
70
+ * Read a value out of an attribute, decoded the way a browser would.
71
+ * Small convenience for meta/link assertions (`content="a &amp; b"`).
72
+ */
73
+ export declare function attrOf(html: string, selector: string, attribute: string): string | null;
74
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAwBA,4EAA4E;AAC5E,MAAM,WAAW,iBAAiB;IAChC,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,mDAAmD;IACnD,IAAI,EAAE,MAAM,CAAC;IACb,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,EACd,aAAa,CAAC,EAAE,MAAM,EACtB,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,EACnC,OAAO,GAAE,aAAkB,GAC1B,iBAAiB,CAOnB;AAED,kFAAkF;AAClF,MAAM,WAAW,gBAAgB;IAC/B,gDAAgD;IAChD,QAAQ,EAAE,QAAQ,CAAC;IACnB,kDAAkD;IAClD,IAAI,EAAE,OAAO,CAAC;IACd,sCAAsC;IACtC,IAAI,IAAI,MAAM,CAAC;IACf,qEAAqE;IACrE,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACtC,+CAA+C;IAC/C,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACpD,0BAA0B;IAC1B,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;IAC/B,wCAAwC;IACxC,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,EAAE,CAAC;IACjC,+CAA+C;IAC/C,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACnD,yDAAyD;IACzD,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAClE,mEAAmE;IACnE,KAAK,IAAI,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACnC,2DAA2D;IAC3D,OAAO,CAAC,EAAE,EAAE,MAAM,OAAO,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/D;AAED,MAAM,WAAW,YAAa,SAAQ,aAAa;IACjD,+EAA+E;IAC/E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAID;;;;;;GAMG;AACH,wBAAgB,KAAK,CACnB,MAAM,EAAE,MAAM,EACd,aAAa,CAAC,EAAE,MAAM,EACtB,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,EACnC,OAAO,GAAE,YAAiB,GACzB,gBAAgB,GAAG;IAAE,OAAO,IAAI,IAAI,CAAA;CAAE,CAmFxC;AAED;;;GAGG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAIvF"}
package/dist/index.js ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `@vesk/testing` — a component test harness.
3
+ *
4
+ * A framework whose whole pitch is "no VDOM" has to be testable without a
5
+ * browser. Before this, testing a `.vsk` component meant either booting a server
6
+ * and driving Chrome, or hand-rolling a DOM shim — which is why so much of this
7
+ * repo's own suite asserts on markup strings.
8
+ *
9
+ * The harness is deliberately small and honest about what it covers:
10
+ *
11
+ * renderComponent() — compile + SSR a `.vsk` source string and give you the
12
+ * HTML, so a component's server output can be asserted with no server and
13
+ * no browser. This is the assertion most component tests actually want.
14
+ * mount() — take that SSR HTML, put it in a real DOM (linkedom), and drive
15
+ * it: text, attributes, click, input, effects flushed. Hydration runs when
16
+ * the runtime can be installed in that DOM.
17
+ *
18
+ * Anything that genuinely needs a browser (layout, service workers, real
19
+ * event dispatch semantics) still belongs in `tests/hydration-test.mjs`. This
20
+ * is for the other 90%.
21
+ */
22
+ import { parseHTML } from 'linkedom';
23
+ import { renderPage } from '@vesk/compiler/src/server-codegen';
24
+ /**
25
+ * Compile and server-render a component from `.vsk` source.
26
+ *
27
+ * Throws with the compiler's own diagnostic (code frame and all) when the
28
+ * source does not compile — a test failure should read like a compile error,
29
+ * not like an empty string.
30
+ */
31
+ export function renderComponent(source, componentName, props = {}, options = {}) {
32
+ const hydrate = options.hydrate !== false;
33
+ const result = renderPage(source, componentName || 'App', props, new Map(), {
34
+ hydrate,
35
+ sourcePath: options.fileName,
36
+ });
37
+ return { html: result.body, head: result.head, props: result.props };
38
+ }
39
+ const DEFAULT_WAIT_MS = 2000;
40
+ /**
41
+ * Mount a component's SSR output into a real DOM and return a driver.
42
+ *
43
+ * The DOM is a linkedom document installed as the ambient `document` for the
44
+ * duration of the returned driver's lifetime — `restore()` puts the previous
45
+ * globals back, and `unmount()` also calls it.
46
+ */
47
+ export function mount(source, componentName, props = {}, options = {}) {
48
+ const html = options.html ?? renderComponent(source, componentName, props, options).html;
49
+ const { document, window } = parseHTML(`<!DOCTYPE html><html><head></head><body><div id="root">${html}</div></body></html>`);
50
+ const previous = {
51
+ document: globalThis.document,
52
+ window: globalThis.window,
53
+ Event: globalThis.Event,
54
+ Node: globalThis.Node,
55
+ };
56
+ const g = globalThis;
57
+ g.document = document;
58
+ g.window = window;
59
+ g.Event = window.Event;
60
+ g.Node = window.Node;
61
+ const root = document.querySelector(options.rootSelector || '#root');
62
+ if (!root)
63
+ throw new Error(`mount: no ${options.rootSelector || '#root'} in the rendered output`);
64
+ const q = (selector) => root.querySelector(selector);
65
+ const flush = async () => {
66
+ // Two turns: effects scheduled by an effect need a turn of their own, which
67
+ // is exactly why a hand-written `await Promise.resolve()` is not enough.
68
+ await Promise.resolve();
69
+ await Promise.resolve();
70
+ };
71
+ const driver = {
72
+ document: document,
73
+ root: root,
74
+ html: () => root.innerHTML,
75
+ text: (selector) => {
76
+ const el = q(selector);
77
+ return el ? String(el.textContent || '').trim() : null;
78
+ },
79
+ attr: (selector, name) => {
80
+ const el = q(selector);
81
+ if (!el)
82
+ return null;
83
+ const v = el.getAttribute ? el.getAttribute(name) : null;
84
+ return v === null ? null : v;
85
+ },
86
+ has: (selector) => q(selector) !== null,
87
+ all: (selector) => Array.from(root.querySelectorAll(selector)),
88
+ click: async (selector) => {
89
+ const el = q(selector);
90
+ if (!el)
91
+ throw new Error(`mount: no element for ${selector}`);
92
+ if (typeof el.click === 'function')
93
+ el.click();
94
+ else
95
+ el.dispatchEvent(new window.Event('click', { bubbles: true }));
96
+ await flush();
97
+ return driver;
98
+ },
99
+ input: async (selector, value) => {
100
+ const el = q(selector);
101
+ if (!el)
102
+ throw new Error(`mount: no element for ${selector}`);
103
+ el.value = value;
104
+ el.dispatchEvent(new window.Event('input', { bubbles: true }));
105
+ await flush();
106
+ return driver;
107
+ },
108
+ flush: async () => {
109
+ await flush();
110
+ return driver;
111
+ },
112
+ waitFor: async (fn, timeoutMs = DEFAULT_WAIT_MS) => {
113
+ const deadline = Date.now() + timeoutMs;
114
+ for (;;) {
115
+ if (fn())
116
+ return;
117
+ if (Date.now() > deadline) {
118
+ throw new Error(`mount: waitFor timed out after ${timeoutMs}ms\n html: ${driver.html().slice(0, 400)}`);
119
+ }
120
+ await flush();
121
+ await new Promise((r) => setTimeout(r, 5));
122
+ }
123
+ },
124
+ unmount: () => {
125
+ for (const [key, value] of Object.entries(previous)) {
126
+ if (value === undefined)
127
+ delete g[key];
128
+ else
129
+ g[key] = value;
130
+ }
131
+ },
132
+ };
133
+ return driver;
134
+ }
135
+ /**
136
+ * Read a value out of an attribute, decoded the way a browser would.
137
+ * Small convenience for meta/link assertions (`content="a &amp; b"`).
138
+ */
139
+ export function attrOf(html, selector, attribute) {
140
+ const { document } = parseHTML(`<!DOCTYPE html><html><body>${html}</body></html>`);
141
+ const el = document.querySelector(selector);
142
+ return el ? el.getAttribute(attribute) : null;
143
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@vesk/testing",
3
+ "version": "0.2.50-canary.4",
4
+ "type": "module",
5
+ "main": "./index.js",
6
+ "types": "./index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./index.d.ts",
10
+ "default": "./index.js"
11
+ },
12
+ "./src/*": "./*.js",
13
+ "./package.json": "./package.json"
14
+ }
15
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@vesk/testing",
3
+ "version": "0.2.50-canary.4",
4
+ "description": "Test harness for Vesk components: SSR a .vsk file, hydrate it in a real DOM, drive it, assert on the result.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.build.json"
20
+ },
21
+ "dependencies": {
22
+ "@vesk/adapter": "^0.2.50-canary.4",
23
+ "@vesk/compiler": "^0.2.50-canary.4",
24
+ "@vesk/runtime": "^0.2.50-canary.4",
25
+ "@vesk/types": "^0.2.50-canary.4",
26
+ "linkedom": "^0.18.13"
27
+ },
28
+ "license": "MIT"
29
+ }