From 49cff549c70edbdcc05866cd4fb111fa4a4646e0 Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Mon, 30 Mar 2026 10:34:02 +0200 Subject: [PATCH] chore: initial test suite --- .../svelte/tests/custom-renderers/renderer.js | 256 ++++++++++++++++++ .../samples/attributes/_config.js | 25 ++ .../samples/attributes/main.svelte | 7 + .../samples/basic-element/_config.js | 5 + .../samples/basic-element/main.svelte | 1 + .../samples/conditional-rendering/_config.js | 24 ++ .../samples/conditional-rendering/main.svelte | 11 + .../samples/each-block/_config.js | 5 + .../samples/each-block/main.svelte | 9 + .../samples/event-handler/_config.js | 25 ++ .../samples/event-handler/main.svelte | 10 + .../samples/nested-components/Child.svelte | 6 + .../samples/nested-components/_config.js | 5 + .../samples/nested-components/main.svelte | 7 + .../samples/reactive-state/_config.js | 18 ++ .../samples/reactive-state/main.svelte | 7 + .../samples/snippet/_config.js | 5 + .../samples/snippet/main.svelte | 12 + .../samples/text-expression/_config.js | 5 + .../samples/text-expression/main.svelte | 6 + .../svelte/tests/custom-renderers/shared.ts | 188 +++++++++++++ .../svelte/tests/custom-renderers/test.ts | 8 + 22 files changed, 645 insertions(+) create mode 100644 packages/svelte/tests/custom-renderers/renderer.js create mode 100644 packages/svelte/tests/custom-renderers/samples/attributes/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/attributes/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/basic-element/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/basic-element/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/conditional-rendering/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/conditional-rendering/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/each-block/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/each-block/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/event-handler/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/event-handler/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/nested-components/Child.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/nested-components/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/nested-components/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/reactive-state/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/reactive-state/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/snippet/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/snippet/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/samples/text-expression/_config.js create mode 100644 packages/svelte/tests/custom-renderers/samples/text-expression/main.svelte create mode 100644 packages/svelte/tests/custom-renderers/shared.ts create mode 100644 packages/svelte/tests/custom-renderers/test.ts diff --git a/packages/svelte/tests/custom-renderers/renderer.js b/packages/svelte/tests/custom-renderers/renderer.js new file mode 100644 index 0000000000..3b8b207c9a --- /dev/null +++ b/packages/svelte/tests/custom-renderers/renderer.js @@ -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, children: ObjNode[], listeners: Record>, 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}`; + } + default: + return ''; + } +} + +export default renderer; diff --git a/packages/svelte/tests/custom-renderers/samples/attributes/_config.js b/packages/svelte/tests/custom-renderers/samples/attributes/_config.js new file mode 100644 index 0000000000..318378d0d8 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/attributes/_config.js @@ -0,0 +1,25 @@ +import { test } from '../../test'; + +export default test({ + test({ assert, target, serialize }) { + const html = serialize(target); + assert.equal( + html, + '
colored
' + ); + + // 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'); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/attributes/main.svelte b/packages/svelte/tests/custom-renderers/samples/attributes/main.svelte new file mode 100644 index 0000000000..4f9a55c096 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/attributes/main.svelte @@ -0,0 +1,7 @@ + + +
+ colored +
diff --git a/packages/svelte/tests/custom-renderers/samples/basic-element/_config.js b/packages/svelte/tests/custom-renderers/samples/basic-element/_config.js new file mode 100644 index 0000000000..8de48d5f2d --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/basic-element/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: '

hello

' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/basic-element/main.svelte b/packages/svelte/tests/custom-renderers/samples/basic-element/main.svelte new file mode 100644 index 0000000000..302a01f335 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/basic-element/main.svelte @@ -0,0 +1 @@ +

hello

diff --git a/packages/svelte/tests/custom-renderers/samples/conditional-rendering/_config.js b/packages/svelte/tests/custom-renderers/samples/conditional-rendering/_config.js new file mode 100644 index 0000000000..9538a019c3 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/conditional-rendering/_config.js @@ -0,0 +1,24 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: '

visible

', + 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, '

hidden

'); + + dispatch_event(button, 'click'); + flushSync(); + + html = serialize(target); + assert.equal(html, '

visible

'); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/conditional-rendering/main.svelte b/packages/svelte/tests/custom-renderers/samples/conditional-rendering/main.svelte new file mode 100644 index 0000000000..86e5dd8577 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/conditional-rendering/main.svelte @@ -0,0 +1,11 @@ + + +{#if visible} +

visible

+{:else} +

hidden

+{/if} + + diff --git a/packages/svelte/tests/custom-renderers/samples/each-block/_config.js b/packages/svelte/tests/custom-renderers/samples/each-block/_config.js new file mode 100644 index 0000000000..05e41069c8 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/each-block/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: '
  • a
  • b
  • c
' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/each-block/main.svelte b/packages/svelte/tests/custom-renderers/samples/each-block/main.svelte new file mode 100644 index 0000000000..6b93690b55 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/each-block/main.svelte @@ -0,0 +1,9 @@ + + +
    + {#each items as item} +
  • {item}
  • + {/each} +
diff --git a/packages/svelte/tests/custom-renderers/samples/event-handler/_config.js b/packages/svelte/tests/custom-renderers/samples/event-handler/_config.js new file mode 100644 index 0000000000..013a983464 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/event-handler/_config.js @@ -0,0 +1,25 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: '

0

', + 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, '

1

'); + + dispatch_event(button, 'click'); + dispatch_event(button, 'click'); + flushSync(); + + const html2 = serialize(target); + assert.equal(html2, '

3

'); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/event-handler/main.svelte b/packages/svelte/tests/custom-renderers/samples/event-handler/main.svelte new file mode 100644 index 0000000000..1df8f7f799 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/event-handler/main.svelte @@ -0,0 +1,10 @@ + + + +

{count}

diff --git a/packages/svelte/tests/custom-renderers/samples/nested-components/Child.svelte b/packages/svelte/tests/custom-renderers/samples/nested-components/Child.svelte new file mode 100644 index 0000000000..7253d3efb2 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/nested-components/Child.svelte @@ -0,0 +1,6 @@ + + +{message} diff --git a/packages/svelte/tests/custom-renderers/samples/nested-components/_config.js b/packages/svelte/tests/custom-renderers/samples/nested-components/_config.js new file mode 100644 index 0000000000..49d4f450ec --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/nested-components/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: '
hello from child
' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/nested-components/main.svelte b/packages/svelte/tests/custom-renderers/samples/nested-components/main.svelte new file mode 100644 index 0000000000..c48bc7dac7 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/nested-components/main.svelte @@ -0,0 +1,7 @@ + + +
+ +
diff --git a/packages/svelte/tests/custom-renderers/samples/reactive-state/_config.js b/packages/svelte/tests/custom-renderers/samples/reactive-state/_config.js new file mode 100644 index 0000000000..82a8a02a64 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/reactive-state/_config.js @@ -0,0 +1,18 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: '', + 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, ''); + } +}); diff --git a/packages/svelte/tests/custom-renderers/samples/reactive-state/main.svelte b/packages/svelte/tests/custom-renderers/samples/reactive-state/main.svelte new file mode 100644 index 0000000000..ce9ad28ec5 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/reactive-state/main.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/svelte/tests/custom-renderers/samples/snippet/_config.js b/packages/svelte/tests/custom-renderers/samples/snippet/_config.js new file mode 100644 index 0000000000..fd2a9d2db5 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/snippet/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: '
hello default
' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/snippet/main.svelte b/packages/svelte/tests/custom-renderers/samples/snippet/main.svelte new file mode 100644 index 0000000000..46e069c215 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/snippet/main.svelte @@ -0,0 +1,12 @@ + + +{#snippet greeting(name)} + hello {name} +{/snippet} + +
+ {@render greeting(label)} +
diff --git a/packages/svelte/tests/custom-renderers/samples/text-expression/_config.js b/packages/svelte/tests/custom-renderers/samples/text-expression/_config.js new file mode 100644 index 0000000000..68e35f6a66 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/text-expression/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: '

hello world

' +}); diff --git a/packages/svelte/tests/custom-renderers/samples/text-expression/main.svelte b/packages/svelte/tests/custom-renderers/samples/text-expression/main.svelte new file mode 100644 index 0000000000..afca058e70 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/samples/text-expression/main.svelte @@ -0,0 +1,6 @@ + + +

hello {name}

diff --git a/packages/svelte/tests/custom-renderers/shared.ts b/packages/svelte/tests/custom-renderers/shared.ts new file mode 100644 index 0000000000..fa7e1fb0e9 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/shared.ts @@ -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; + props?: Record; + error?: string; + runtime_error?: string; + warnings?: string[]; + test?: (args: { + assert: typeof import('vitest').assert; + target: any; + component: Record; + mod: any; + logs: any[]; + warnings: any[]; + renderer: typeof renderer; + serialize: typeof serialize; + dispatch_event: typeof dispatch_event; + }) => void | Promise; +} + +// 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( + ['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, `${config.html}`); + } + + 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, + '', + '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}`); + } +} diff --git a/packages/svelte/tests/custom-renderers/test.ts b/packages/svelte/tests/custom-renderers/test.ts new file mode 100644 index 0000000000..f29cb0e322 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/test.ts @@ -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);