mirror of https://github.com/sveltejs/svelte
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 @@
|
||||
<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…
Reference in new issue