mirror of https://github.com/sveltejs/svelte
429 lines
12 KiB
429 lines
12 KiB
import * as fs from 'node:fs';
|
|
import { setImmediate } from 'node:timers/promises';
|
|
import glob from 'tiny-glob/sync.js';
|
|
import { createClassComponent } from 'svelte/legacy';
|
|
import { proxy } from 'svelte/internal/client';
|
|
import { flushSync, hydrate, mount, unmount } from 'svelte';
|
|
import { render } from 'svelte/server';
|
|
import { afterAll, assert, beforeAll } from 'vitest';
|
|
import { compile_directory } from '../helpers.js';
|
|
import { setup_html_equal } from '../html_equal.js';
|
|
import { raf } from '../animation-helpers.js';
|
|
import type { CompileOptions } from '#compiler';
|
|
import { suite_with_variants, type BaseTest } from '../suite.js';
|
|
|
|
type Assert = typeof import('vitest').assert & {
|
|
htmlEqual(a: string, b: string, description?: string): void;
|
|
htmlEqualWithOptions(
|
|
a: string,
|
|
b: string,
|
|
opts: {
|
|
preserveComments: boolean;
|
|
withoutNormalizeHtml: boolean;
|
|
},
|
|
description?: string
|
|
): void;
|
|
};
|
|
|
|
export interface RuntimeTest<Props extends Record<string, any> = Record<string, any>>
|
|
extends BaseTest {
|
|
/** Use e.g. `mode: ['client']` to indicate that this test should never run in server/hydrate modes */
|
|
mode?: Array<'server' | 'client' | 'hydrate'>;
|
|
/** Temporarily skip specific modes, without skipping the entire test */
|
|
skip_mode?: Array<'server' | 'client' | 'hydrate'>;
|
|
html?: string;
|
|
ssrHtml?: string;
|
|
compileOptions?: Partial<CompileOptions>;
|
|
props?: Props;
|
|
before_test?: () => void;
|
|
after_test?: () => void;
|
|
test?: (args: {
|
|
variant: 'dom' | 'hydrate';
|
|
assert: Assert;
|
|
compileOptions: CompileOptions;
|
|
component: Props & {
|
|
[key: string]: any;
|
|
};
|
|
instance: Record<string, any>;
|
|
mod: any;
|
|
ok: typeof ok;
|
|
raf: {
|
|
tick: (ms: number) => void;
|
|
};
|
|
target: HTMLElement;
|
|
window: Window & {
|
|
Event: typeof Event;
|
|
InputEvent: typeof InputEvent;
|
|
KeyboardEvent: typeof KeyboardEvent;
|
|
MouseEvent: typeof MouseEvent;
|
|
};
|
|
logs: any[];
|
|
warnings: any[];
|
|
}) => void | Promise<void>;
|
|
test_ssr?: (args: { assert: Assert }) => void | Promise<void>;
|
|
accessors?: boolean;
|
|
immutable?: boolean;
|
|
intro?: boolean;
|
|
load_compiled?: boolean;
|
|
error?: string;
|
|
runtime_error?: string;
|
|
warnings?: string[];
|
|
expect_unhandled_rejections?: boolean;
|
|
withoutNormalizeHtml?: boolean | 'only-strip-comments';
|
|
recover?: boolean;
|
|
}
|
|
|
|
let unhandled_rejection: Error | null = null;
|
|
|
|
function unhandled_rejection_handler(err: Error) {
|
|
unhandled_rejection = err;
|
|
}
|
|
|
|
const listeners = process.rawListeners('unhandledRejection');
|
|
|
|
const { assert_html_equal, assert_html_equal_with_options } = setup_html_equal({
|
|
removeDataSvelte: true
|
|
});
|
|
|
|
beforeAll(() => {
|
|
// @ts-expect-error TODO huh?
|
|
process.prependListener('unhandledRejection', unhandled_rejection_handler);
|
|
});
|
|
|
|
afterAll(() => {
|
|
process.removeListener('unhandledRejection', unhandled_rejection_handler);
|
|
});
|
|
|
|
export function runtime_suite(runes: boolean) {
|
|
return suite_with_variants<RuntimeTest, 'hydrate' | 'ssr' | 'dom', CompileOptions>(
|
|
['dom', 'hydrate', 'ssr'],
|
|
(variant, config) => {
|
|
if (variant === 'hydrate') {
|
|
if (config.mode && !config.mode.includes('hydrate')) return 'no-test';
|
|
if (config.skip_mode?.includes('hydrate')) return true;
|
|
}
|
|
|
|
if (variant === 'ssr') {
|
|
if (
|
|
(config.mode && !config.mode.includes('server')) ||
|
|
(!config.test_ssr &&
|
|
config.html === undefined &&
|
|
config.ssrHtml === undefined &&
|
|
config.error === undefined)
|
|
) {
|
|
return 'no-test';
|
|
}
|
|
if (config.skip_mode?.includes('server')) return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
(config, cwd) => {
|
|
return common_setup(cwd, runes, config);
|
|
},
|
|
async (config, cwd, variant, common) => {
|
|
await run_test_variant(cwd, config, variant, common, runes);
|
|
}
|
|
);
|
|
}
|
|
|
|
async function common_setup(cwd: string, runes: boolean | undefined, config: RuntimeTest) {
|
|
const compileOptions: CompileOptions = {
|
|
generate: 'client',
|
|
rootDir: cwd,
|
|
...config.compileOptions,
|
|
immutable: config.immutable,
|
|
accessors: 'accessors' in config ? config.accessors : true,
|
|
runes
|
|
};
|
|
|
|
// load_compiled can be used for debugging a test. It means the compiler will not run on the input
|
|
// so you can manipulate the output manually to see what fixes it, adding console.logs etc.
|
|
if (!config.load_compiled) {
|
|
await compile_directory(cwd, 'client', compileOptions);
|
|
await compile_directory(cwd, 'server', compileOptions);
|
|
}
|
|
|
|
return compileOptions;
|
|
}
|
|
|
|
async function run_test_variant(
|
|
cwd: string,
|
|
config: RuntimeTest,
|
|
variant: 'dom' | 'hydrate' | 'ssr',
|
|
compileOptions: CompileOptions,
|
|
runes: boolean
|
|
) {
|
|
let unintended_error = false;
|
|
|
|
// eslint-disable-next-line no-console
|
|
const { log, warn } = console;
|
|
|
|
let logs: string[] = [];
|
|
let warnings: string[] = [];
|
|
|
|
{
|
|
// use some crude static analysis to determine if logs/warnings are intercepted.
|
|
// we do this instead of using getters on the `test` parameters so that we can
|
|
// squelch logs in SSR tests while printing temporary logs in other cases
|
|
let 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 (args[0].startsWith('%c[svelte]')) {
|
|
// TODO convert this to structured data, for more robust comparison?
|
|
const message = args[0];
|
|
warnings.push(message.slice(message.indexOf('%c', 2) + 2));
|
|
} else {
|
|
warnings.push(...args);
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
try {
|
|
unhandled_rejection = null;
|
|
|
|
if (config.expect_unhandled_rejections) {
|
|
listeners.forEach((listener) => {
|
|
// @ts-expect-error
|
|
process.removeListener('unhandledRejection', listener);
|
|
});
|
|
}
|
|
|
|
// hack to support transition tests
|
|
raf.reset();
|
|
|
|
// Put things we need on window for testing
|
|
const styles = glob('**/*.css', { cwd: `${cwd}/_output/client` })
|
|
.map((file) => fs.readFileSync(`${cwd}/_output/client/${file}`, 'utf-8'))
|
|
.join('\n')
|
|
.replace(/\/\*<\/?style>\*\//g, '');
|
|
|
|
window.location.href = '';
|
|
window.document.title = '';
|
|
window.document.head.innerHTML = styles ? `<style>${styles}</style>` : '';
|
|
window.document.body.innerHTML = '<main></main>';
|
|
|
|
window.addEventListener('error', (e) => {
|
|
unhandled_rejection = e.error;
|
|
e.preventDefault();
|
|
});
|
|
|
|
globalThis.requestAnimationFrame = globalThis.setTimeout;
|
|
|
|
let mod = await import(`${cwd}/_output/client/main.svelte.js`);
|
|
|
|
const target = window.document.querySelector('main') as HTMLElement;
|
|
|
|
let snapshot = undefined;
|
|
|
|
if (variant === 'hydrate' || variant === 'ssr') {
|
|
config.before_test?.();
|
|
// ssr into target
|
|
const SsrSvelteComponent = (await import(`${cwd}/_output/server/main.svelte.js`)).default;
|
|
const { html, head } = render(SsrSvelteComponent, { props: config.props || {} });
|
|
|
|
fs.writeFileSync(`${cwd}/_output/rendered.html`, html);
|
|
target.innerHTML = html;
|
|
|
|
if (head) {
|
|
fs.writeFileSync(`${cwd}/_output/rendered_head.html`, head);
|
|
window.document.head.innerHTML = window.document.head.innerHTML + head;
|
|
}
|
|
|
|
if (variant === 'hydrate') {
|
|
// @ts-expect-error TODO
|
|
if (config.snapshot) {
|
|
// @ts-expect-error
|
|
snapshot = config.snapshot(target);
|
|
}
|
|
}
|
|
} else {
|
|
target.innerHTML = '';
|
|
}
|
|
|
|
if (variant === 'ssr') {
|
|
if (config.ssrHtml) {
|
|
assert_html_equal_with_options(target.innerHTML, config.ssrHtml, {
|
|
preserveComments:
|
|
config.withoutNormalizeHtml === 'only-strip-comments' ? false : undefined,
|
|
withoutNormalizeHtml: !!config.withoutNormalizeHtml
|
|
});
|
|
} else if (config.html) {
|
|
assert_html_equal_with_options(target.innerHTML, config.html, {
|
|
preserveComments:
|
|
config.withoutNormalizeHtml === 'only-strip-comments' ? false : undefined,
|
|
withoutNormalizeHtml: !!config.withoutNormalizeHtml
|
|
});
|
|
}
|
|
|
|
if (config.test_ssr) {
|
|
await config.test_ssr({
|
|
// @ts-expect-error
|
|
assert: {
|
|
...assert,
|
|
htmlEqual: assert_html_equal,
|
|
htmlEqualWithOptions: assert_html_equal_with_options
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
logs.length = warnings.length = 0;
|
|
|
|
config.before_test?.();
|
|
|
|
// eslint-disable-next-line no-console
|
|
const error = console.error;
|
|
// eslint-disable-next-line no-console
|
|
console.error = (error) => {
|
|
if (typeof error === 'string' && error.startsWith('Hydration failed')) {
|
|
throw new Error(error);
|
|
}
|
|
};
|
|
|
|
let instance: any;
|
|
let props: any;
|
|
|
|
if (runes) {
|
|
props = proxy({ ...(config.props || {}) });
|
|
|
|
const render = variant === 'hydrate' ? hydrate : mount;
|
|
instance = render(mod.default, {
|
|
target,
|
|
props,
|
|
intro: config.intro,
|
|
recover: config.recover ?? false
|
|
});
|
|
} else {
|
|
instance = createClassComponent({
|
|
component: mod.default,
|
|
props: config.props,
|
|
target,
|
|
immutable: config.immutable,
|
|
intro: config.intro,
|
|
recover: config.recover ?? false,
|
|
hydrate: variant === 'hydrate'
|
|
});
|
|
}
|
|
|
|
// eslint-disable-next-line no-console
|
|
console.error = error;
|
|
|
|
if (config.error) {
|
|
unintended_error = true;
|
|
assert.fail('Expected a runtime error');
|
|
}
|
|
|
|
if (config.html) {
|
|
flushSync();
|
|
assert_html_equal_with_options(target.innerHTML, config.html, {
|
|
preserveComments:
|
|
config.withoutNormalizeHtml === 'only-strip-comments' ? false : undefined,
|
|
withoutNormalizeHtml: !!config.withoutNormalizeHtml
|
|
});
|
|
}
|
|
|
|
try {
|
|
if (config.test) {
|
|
flushSync();
|
|
await config.test({
|
|
// @ts-expect-error TS doesn't get it
|
|
assert: {
|
|
...assert,
|
|
htmlEqual: assert_html_equal,
|
|
htmlEqualWithOptions: assert_html_equal_with_options
|
|
},
|
|
variant,
|
|
component: runes ? props : instance,
|
|
instance,
|
|
mod,
|
|
target,
|
|
snapshot,
|
|
window,
|
|
raf,
|
|
compileOptions,
|
|
logs,
|
|
warnings
|
|
});
|
|
}
|
|
|
|
if (config.runtime_error && !unhandled_rejection) {
|
|
unintended_error = true;
|
|
assert.fail('Expected a runtime error');
|
|
}
|
|
} finally {
|
|
if (runes) {
|
|
unmount(instance);
|
|
} else {
|
|
instance.$destroy();
|
|
}
|
|
|
|
if (config.warnings) {
|
|
assert.deepEqual(warnings, config.warnings);
|
|
} else if (warnings.length && console.warn === warn) {
|
|
unintended_error = true;
|
|
warn.apply(console, warnings);
|
|
assert.fail('Received unexpected warnings');
|
|
}
|
|
|
|
assert_html_equal(
|
|
target.innerHTML,
|
|
'',
|
|
'Expected component to unmount and leave nothing behind after it was destroyed'
|
|
);
|
|
|
|
// TODO: This seems useless, unhandledRejection is only triggered on the next task
|
|
// by which time the test has already finished and the next test resets it to null above
|
|
if (unhandled_rejection) {
|
|
throw unhandled_rejection; // eslint-disable-line no-unsafe-finally
|
|
}
|
|
}
|
|
}
|
|
} 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 {
|
|
console.log = log;
|
|
console.warn = warn;
|
|
|
|
config.after_test?.();
|
|
|
|
// Free up the microtask queue
|
|
// 1. Vitest's test runner which uses setInterval can log progress
|
|
// 2. Any expected unhandled rejections are ran before we reattach the listeners
|
|
await setImmediate();
|
|
|
|
if (config.expect_unhandled_rejections) {
|
|
listeners.forEach((listener) => {
|
|
// @ts-expect-error
|
|
process.on('unhandledRejection', listener);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export function ok(value: any): asserts value {
|
|
if (!value) {
|
|
throw new Error(`Expected truthy value, got ${value}`);
|
|
}
|
|
}
|