chore: initial test suite

pull/18058/head
paoloricciuti 4 months ago
parent bd70ab621c
commit 49cff549c7

@ -0,0 +1,256 @@
/**
* An object-based renderer for testing Svelte's custom renderer functionality.
*
* Runs in plain Node.js no DOM required. Creates a tree of plain objects
* with simple properties (type, name, children, attributes, value, etc).
* Proves that Svelte can render into non-DOM targets.
*/
import { createRenderer } from '../../src/renderer/index.js';
/**
* @typedef {{ type: 'element', name: string, attributes: Record<string, string>, children: ObjNode[], listeners: Record<string, Array<{handler: any, options?: any}>>, parent: ObjNode | null, next_sibling: ObjNode | null, prev_sibling: ObjNode | null }} ObjElement
* @typedef {{ type: 'text', value: string, parent: ObjNode | null, next_sibling: ObjNode | null, prev_sibling: ObjNode | null }} ObjText
* @typedef {{ type: 'comment', value: string, parent: ObjNode | null, next_sibling: ObjNode | null, prev_sibling: ObjNode | null }} ObjComment
* @typedef {{ type: 'fragment', children: ObjNode[], parent: ObjNode | null, next_sibling: ObjNode | null, prev_sibling: ObjNode | null }} ObjFragment
* @typedef {ObjElement | ObjText | ObjComment | ObjFragment} ObjNode
*/
/**
* @param {ObjNode & { children?: ObjNode[] }} parent
* @param {ObjNode} node
* @param {ObjNode | null} anchor
*/
function insert_node(parent, node, anchor) {
if (node.type === 'fragment') {
const children = [...(node.children ?? [])];
for (const child of children) {
insert_node(parent, child, anchor);
}
return;
}
// Remove from old parent first
if (node.parent) {
remove_from_parent(node);
}
const children = /** @type {ObjNode[]} */ (parent.children);
node.parent = parent;
if (anchor === null) {
const last = children[children.length - 1] ?? null;
if (last) {
last.next_sibling = node;
node.prev_sibling = last;
}
node.next_sibling = null;
children.push(node);
} else {
const idx = children.indexOf(anchor);
if (idx === -1) throw new Error('Anchor not found in parent');
const prev = children[idx - 1] ?? null;
if (prev) {
prev.next_sibling = node;
node.prev_sibling = prev;
}
node.next_sibling = anchor;
anchor.prev_sibling = node;
children.splice(idx, 0, node);
}
}
/** @param {ObjNode} node */
function remove_from_parent(node) {
const parent = node.parent;
if (!parent || !('children' in parent)) return;
const children = parent.children;
const idx = children.indexOf(node);
if (idx === -1) return;
const prev = children[idx - 1] ?? null;
const next = children[idx + 1] ?? null;
if (prev) prev.next_sibling = next;
if (next) next.prev_sibling = prev;
node.prev_sibling = null;
node.next_sibling = null;
node.parent = null;
children.splice(idx, 1);
}
const renderer = createRenderer({
createFragment() {
return /** @type {ObjFragment} */ ({
type: 'fragment',
children: [],
parent: null,
next_sibling: null,
prev_sibling: null
});
},
createElement(name) {
return /** @type {ObjElement} */ ({
type: 'element',
name,
attributes: {},
children: [],
listeners: {},
parent: null,
next_sibling: null,
prev_sibling: null
});
},
createTextNode(data) {
return /** @type {ObjText} */ ({
type: 'text',
value: data,
parent: null,
next_sibling: null,
prev_sibling: null
});
},
createComment(data) {
return /** @type {ObjComment} */ ({
type: 'comment',
value: data,
parent: null,
next_sibling: null,
prev_sibling: null
});
},
nodeType(node) {
return node.type;
},
getNodeValue(node) {
if (node.type === 'text' || node.type === 'comment') return node.value;
return null;
},
getAttribute(element, name) {
return element.attributes?.[name] ?? null;
},
setAttribute(element, key, value) {
element.attributes[key] = String(value);
},
removeAttribute(element, name) {
delete element.attributes[name];
},
hasAttribute(element, name) {
return name in (element.attributes ?? {});
},
setText(node, text) {
if (node.type === 'text' || node.type === 'comment') {
node.value = text;
}
},
getFirstChild(element) {
return element.children?.[0] ?? null;
},
getLastChild(element) {
const c = element.children;
return c?.[c.length - 1] ?? null;
},
getNextSibling(node) {
return node.next_sibling ?? null;
},
insert(parent, element, anchor) {
insert_node(parent, element, anchor);
},
remove(node) {
remove_from_parent(node);
},
getParent(node) {
return node.parent ?? null;
},
addEventListener(target, type, handler, options) {
if (target.type !== 'element') return;
if (!target.listeners) target.listeners = {};
if (!target.listeners[type]) target.listeners[type] = [];
target.listeners[type].push({ handler, options });
},
removeEventListener(target, type, handler, options) {
if (target.type !== 'element') return;
if (!target.listeners?.[type]) return;
target.listeners[type] = target.listeners[type].filter(
(/** @type {any} */ l) => l.handler !== handler
);
}
});
// ---- Test helpers ----
/**
* Create a root object for mounting components into.
* @returns {ObjElement}
*/
export function create_root() {
return renderer.createElement('root');
}
/**
* Dispatch a synthetic event on an object node.
* @param {any} node
* @param {string} type
* @param {any} [detail]
*/
export function dispatch_event(node, type, detail) {
const listeners = node.listeners?.[type];
if (!listeners) return;
const event = { type, detail, target: node };
for (const { handler } of listeners) {
handler(event);
}
}
/**
* Serialize an object tree to an HTML-like string for easy assertion.
* @param {ObjNode} node
* @returns {string}
*/
export function serialize(node) {
if (!node) return '';
switch (node.type) {
case 'text':
return node.value ?? '';
case 'comment':
return '';
case 'fragment':
return node.children.map(serialize).join('');
case 'element': {
const tag = node.name;
let attrs = '';
const sorted_keys = Object.keys(node.attributes).sort();
for (const key of sorted_keys) {
attrs += ` ${key}="${node.attributes[key]}"`;
}
const children = node.children.map(serialize).join('');
return `<${tag}${attrs}>${children}</${tag}>`;
}
default:
return '';
}
}
export default renderer;

@ -0,0 +1,25 @@
import { test } from '../../test';
export default test({
test({ assert, target, serialize }) {
const html = serialize(target);
assert.equal(
html,
'<root><div class="container" data-color="red"><span id="label">colored</span></div></root>'
);
// Verify individual attribute access on the object node
const div = target.children.find(
(/** @type {any} */ n) => n.type === 'element' && n.name === 'div'
);
assert.ok(div);
assert.equal(div.attributes['class'], 'container');
assert.equal(div.attributes['data-color'], 'red');
const span = div.children.find(
(/** @type {any} */ n) => n.type === 'element' && n.name === 'span'
);
assert.ok(span);
assert.equal(span.attributes['id'], 'label');
}
});

@ -0,0 +1,7 @@
<script>
let color = $state('red');
</script>
<div class="container" data-color={color}>
<span id="label">colored</span>
</div>

@ -0,0 +1,5 @@
import { test } from '../../test';
export default test({
html: '<p>hello</p>'
});

@ -0,0 +1,24 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: '<p>visible</p> <button>toggle</button>',
test({ assert, target, serialize, dispatch_event }) {
const button = target.children.find(
(/** @type {any} */ n) => n.type === 'element' && n.name === 'button'
);
assert.ok(button);
dispatch_event(button, 'click');
flushSync();
let html = serialize(target);
assert.equal(html, '<root><p>hidden</p> <button>toggle</button></root>');
dispatch_event(button, 'click');
flushSync();
html = serialize(target);
assert.equal(html, '<root><p>visible</p> <button>toggle</button></root>');
}
});

@ -0,0 +1,11 @@
<script>
let visible = $state(true);
</script>
{#if visible}
<p>visible</p>
{:else}
<p>hidden</p>
{/if}
<button onclick={() => visible = !visible}>toggle</button>

@ -0,0 +1,5 @@
import { test } from '../../test';
export default test({
html: '<ul><li>a</li><li>b</li><li>c</li></ul>'
});

@ -0,0 +1,9 @@
<script>
let items = $state(['a', 'b', 'c']);
</script>
<ul>
{#each items as item}
<li>{item}</li>
{/each}
</ul>

@ -0,0 +1,25 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: '<button>click me</button> <p>0</p>',
test({ assert, target, serialize, dispatch_event }) {
const button = target.children.find(
(/** @type {any} */ n) => n.type === 'element' && n.name === 'button'
);
assert.ok(button);
dispatch_event(button, 'click');
flushSync();
const html = serialize(target);
assert.equal(html, '<root><button>click me</button> <p>1</p></root>');
dispatch_event(button, 'click');
dispatch_event(button, 'click');
flushSync();
const html2 = serialize(target);
assert.equal(html2, '<root><button>click me</button> <p>3</p></root>');
}
});

@ -0,0 +1,10 @@
<script>
let count = $state(0);
function increment() {
count++;
}
</script>
<button onclick={increment}>click me</button>
<p>{count}</p>

@ -0,0 +1,6 @@
<script>
/** @type {{ message: string }} */
let { message } = $props();
</script>
<span>{message}</span>

@ -0,0 +1,5 @@
import { test } from '../../test';
export default test({
html: '<div><span>hello from child</span></div>'
});

@ -0,0 +1,7 @@
<script>
import Child from './Child.svelte';
</script>
<div>
<Child message="hello from child" />
</div>

@ -0,0 +1,18 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: '<button>clicks: 0</button>',
test({ assert, target, serialize, dispatch_event }) {
const button = target.children.find(
(/** @type {any} */ n) => n.type === 'element' && n.name === 'button'
);
assert.ok(button);
dispatch_event(button, 'click');
flushSync();
const html = serialize(target);
assert.equal(html, '<root><button>clicks: 1</button></root>');
}
});

@ -0,0 +1,7 @@
<script>
let count = $state(0);
</script>
<button onclick={() => count++}>
clicks: {count}
</button>

@ -0,0 +1,5 @@
import { test } from '../../test';
export default test({
html: '<div><span>hello default</span></div>'
});

@ -0,0 +1,12 @@
<script>
/** @type {{ label?: string }} */
let { label = 'default' } = $props();
</script>
{#snippet greeting(name)}
<span>hello {name}</span>
{/snippet}
<div>
{@render greeting(label)}
</div>

@ -0,0 +1,5 @@
import { test } from '../../test';
export default test({
html: '<p>hello world</p>'
});

@ -0,0 +1,6 @@
<script>
/** @type {{ name?: string }} */
let { name = 'world' } = $props();
</script>
<p>hello {name}</p>

@ -0,0 +1,188 @@
import * as path from 'node:path';
import { setImmediate } from 'node:timers/promises';
import { assert } from 'vitest';
import { compile_directory } from '../helpers.js';
import { suite_with_variants, type BaseTest } from '../suite.js';
import type { CompileOptions } from '#compiler';
import renderer, { create_root, serialize, dispatch_event } from './renderer.js';
export interface CustomRendererTest extends BaseTest {
html?: string;
compileOptions?: Partial<CompileOptions>;
props?: Record<string, any>;
error?: string;
runtime_error?: string;
warnings?: string[];
test?: (args: {
assert: typeof import('vitest').assert;
target: any;
component: Record<string, any>;
mod: any;
logs: any[];
warnings: any[];
renderer: typeof renderer;
serialize: typeof serialize;
dispatch_event: typeof dispatch_event;
}) => void | Promise<void>;
}
// eslint-disable-next-line no-console
const console_log = console.log;
// eslint-disable-next-line no-console
const console_warn = console.warn;
const renderer_path = path.resolve(import.meta.dirname, 'renderer.js');
export function custom_renderer_suite() {
return suite_with_variants<CustomRendererTest, 'custom-renderer', CompileOptions>(
['custom-renderer'],
(_variant, _config) => {
return false;
},
(config, cwd) => {
return common_setup(cwd, config);
},
async (config, cwd, _variant, common) => {
await run_test(cwd, config, common);
}
);
}
async function common_setup(cwd: string, config: CustomRendererTest) {
const compile_options: CompileOptions = {
generate: 'client',
rootDir: cwd,
runes: true,
customRenderer: renderer_path,
...config.compileOptions
};
await compile_directory(cwd, 'client', compile_options);
return compile_options;
}
async function run_test(cwd: string, config: CustomRendererTest, compile_options: CompileOptions) {
let unintended_error = false;
let logs: any[] = [];
let warnings: any[] = [];
{
const str = config.test?.toString() ?? '';
let n = 0;
let i = 0;
while (i < str.length) {
if (str[i] === '(') n++;
if (str[i] === ')' && --n === 0) break;
i++;
}
if (str.slice(0, i).includes('logs')) {
// eslint-disable-next-line no-console
console.log = (...args) => {
logs.push(...args);
};
}
if (str.slice(0, i).includes('warnings') || config.warnings) {
// eslint-disable-next-line no-console
console.warn = (...args) => {
if (typeof args[0] === 'string' && args[0].startsWith('%c[svelte]')) {
let message = args[0];
message = message.slice(message.indexOf('%c', 2) + 2);
const lines = message.split('\n');
if (lines.at(-1)?.startsWith('https://svelte.dev/e/')) {
lines.pop();
}
message = lines.join('\n');
warnings.push(message);
} else {
warnings.push(...args);
}
};
}
}
try {
const mod = await import(`${cwd}/_output/client/main.svelte.js`);
const target = create_root();
let unmount: (() => void) | undefined;
try {
unmount = renderer.render(mod.default, {
target,
props: config.props ?? {}
});
} catch (err) {
if (config.error) {
assert.include((err as Error).message, config.error);
return;
}
throw err;
}
if (config.error) {
unintended_error = true;
assert.fail('Expected a runtime error');
}
if (config.html) {
const html = serialize(target);
assert.equal(html, `<root>${config.html}</root>`);
}
try {
if (config.test) {
await config.test({
assert,
target,
component: config.props ?? {},
mod,
logs,
warnings,
renderer: renderer,
serialize,
dispatch_event
});
}
if (config.runtime_error) {
unintended_error = true;
assert.fail('Expected a runtime error');
}
} finally {
unmount?.();
if (config.warnings) {
assert.deepEqual(warnings, config.warnings);
}
// After unmount the target should be empty (only comments remain, which serialize to '')
const remaining = serialize(target);
assert.equal(
remaining,
'<root></root>',
'Expected component to leave nothing behind after unmount'
);
}
} catch (err) {
if (config.runtime_error) {
assert.include((err as Error).message, config.runtime_error);
} else if (config.error && !unintended_error) {
assert.include((err as Error).message, config.error);
} else {
throw err;
}
} finally {
await setImmediate();
console.log = console_log;
console.warn = console_warn;
}
}
export function ok(value: any): asserts value {
if (!value) {
throw new Error(`Expected truthy value, got ${value}`);
}
}

@ -0,0 +1,8 @@
// @vitest-environment node
import { custom_renderer_suite, ok } from './shared';
const { test, run } = custom_renderer_suite();
export { test, ok };
await run(__dirname);
Loading…
Cancel
Save