-
Notifications
You must be signed in to change notification settings - Fork 439
Expand file tree
/
Copy pathserve-hydration.js
More file actions
231 lines (207 loc) · 7.75 KB
/
serve-hydration.js
File metadata and controls
231 lines (207 loc) · 7.75 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
import path from 'node:path';
import vm from 'node:vm';
import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { rollup } from 'rollup';
import lwcRollupPlugin from '@lwc/rollup-plugin';
import { DISABLE_STATIC_CONTENT_OPTIMIZATION, ENGINE_SERVER } from '../../helpers/options.js';
/** LWC SSR module to use when server-side rendering components. */
const lwcSsr = await (ENGINE_SERVER
? // Using import('literal') rather than import(variable) so static analysis tools work
import('@lwc/engine-server')
: import('@lwc/ssr-runtime'));
lwcSsr.setHooks({
sanitizeHtmlContent(content) {
return content;
},
});
const ROOT_DIR = path.join(import.meta.dirname, '../..');
let guid = 0;
const COMPONENT_UNDER_TEST = 'main';
// Like `fs.existsSync` but async
async function exists(path) {
try {
await fs.access(path);
return true;
} catch (_err) {
return false;
}
}
async function getCompiledModule(dir, compileForSSR) {
const bundle = await rollup({
input: path.join(dir, 'x', COMPONENT_UNDER_TEST, `${COMPONENT_UNDER_TEST}.js`),
plugins: [
lwcRollupPlugin({
targetSSR: !!compileForSSR,
modules: [{ dir: path.join(ROOT_DIR, dir) }],
experimentalDynamicComponent: {
loader: fileURLToPath(new URL('../../helpers/loader.js', import.meta.url)),
strict: true,
},
enableDynamicComponents: true,
enableLwcOn: true,
enableStaticContentOptimization: !DISABLE_STATIC_CONTENT_OPTIMIZATION,
experimentalDynamicDirective: true,
}),
],
external: ['lwc', '@lwc/ssr-runtime', 'test-utils', '@test/loader'], // @todo: add ssr modules for test-utils and @test/loader
onwarn(warning, warn) {
// Ignore warnings from our own Rollup plugin
if (warning.plugin !== 'rollup-plugin-lwc-compiler') {
warn(warning);
}
},
});
const { output } = await bundle.generate({
format: 'iife',
name: 'Main',
globals: {
lwc: 'LWC',
'@lwc/ssr-runtime': 'LWC',
'test-utils': 'TestUtils',
},
});
return output[0].code;
}
function throwOnUnexpectedConsoleCalls(runnable, expectedConsoleCalls = {}) {
// The console is shared between the VM and the main realm. Here we ensure that known warnings
// are ignored and any others cause an explicit error.
const methods = ['error', 'warn', 'log', 'info'];
const originals = {};
for (const method of methods) {
// eslint-disable-next-line no-console
originals[method] = console[method];
// eslint-disable-next-line no-console
console[method] = function (error) {
if (
method === 'warn' &&
// This eslint warning is a false positive due to RegExp.prototype.test
// eslint-disable-next-line vitest/no-conditional-tests
/Cannot set property "(inner|outer)HTML"/.test(error?.message)
) {
return;
} else if (
expectedConsoleCalls[method]?.some((matcher) => error.message.includes(matcher))
) {
return;
}
throw new Error(`Unexpected console.${method} call: ${error}`);
};
}
try {
return runnable();
} finally {
Object.assign(console, originals);
}
}
/**
* This is the function that takes SSR bundle code and test config, constructs a script that will
* run in a separate JS runtime environment with its own global scope. The `context` object
* (defined at the top of this file) is passed in as the global scope for that script. The script
* runs, utilizing the `LWC` object that we've attached to the global scope, it sets a
* new value (the rendered markup) to `globalThis.moduleOutput`, which corresponds to
* `context.moduleOutput in this file's scope.
*
* So, script runs, generates markup, & we get that markup out and return it for use
* in client-side tests.
*/
async function getSsrCode(moduleCode, testConfig, filePath, expectedSSRConsoleCalls) {
const script = new vm.Script(
`(() => {
${testConfig}
${moduleCode}
return LWC.renderComponent(
'x-${COMPONENT_UNDER_TEST}-${guid++}',
Main,
config.props || {},
false,
'sync'
);
})()`,
{ filename: `[SSR] ${filePath}` }
);
return throwOnUnexpectedConsoleCalls(
() => script.runInContext(vm.createContext({ LWC: lwcSsr })),
expectedSSRConsoleCalls
);
}
async function getTestConfig(input) {
const bundle = await rollup({
input,
external: ['lwc', 'test-utils', '@test/loader'],
});
const { output } = await bundle.generate({
format: 'iife',
globals: {
lwc: 'LWC',
'test-utils': 'TestUtils',
},
name: 'config',
});
const { code } = output[0];
return code;
}
async function existsUp(dir, file) {
while (true) {
if (await exists(path.join(dir, file))) return true;
dir = path.join(dir, '..');
const basename = path.basename(dir);
if (basename === '.') return false;
}
}
/**
* Hydration test `index.spec.js` files are actually config files, not spec files.
* This function wraps those configs in the test code to be executed.
*/
async function wrapHydrationTest(filePath) {
const {
default: { expectedSSRConsoleCalls, requiredFeatureFlags },
} = await import(path.join(ROOT_DIR, filePath));
try {
requiredFeatureFlags?.forEach((featureFlag) => {
lwcSsr.setFeatureFlagForTest(featureFlag, true);
});
const suiteDir = path.dirname(filePath);
// You can add an `.only` file alongside an `index.spec.js` file to make it `fdescribe()`
const onlyFileExists = await existsUp(suiteDir, '.only');
const componentDefCSR = await getCompiledModule(suiteDir, false);
const componentDefSSR = ENGINE_SERVER
? componentDefCSR
: await getCompiledModule(suiteDir, true);
const ssrOutput = await getSsrCode(
componentDefSSR,
await getTestConfig(filePath),
filePath,
expectedSSRConsoleCalls
);
// FIXME: can we turn these IIFEs into ESM imports?
return `
import * as LWC from 'lwc';
import { runTest } from '/helpers/test-hydrate.js';
import config from '/${filePath}?original=1';
${onlyFileExists ? 'it.only' : 'it'}('${filePath}', async () => {
const ssrRendered = ${JSON.stringify(ssrOutput) /* escape quotes */};
// Component code, IIFE set as Main
${componentDefCSR};
return await runTest(ssrRendered, Main, config);
});
`;
} finally {
requiredFeatureFlags?.forEach((featureFlag) => {
lwcSsr.setFeatureFlagForTest(featureFlag, false);
});
}
}
/** @type {import('@web/dev-server-core').Plugin} */
export default {
async serve(ctx) {
// Hydration test "index.spec.js" files are actually just config files.
// They don't directly define the tests. Instead, when we request the file,
// we wrap it with some boilerplate. That boilerplate must include the config
// file we originally requested, so the ?original query parameter is used
// to return the file unmodified.
if (ctx.path.endsWith('.spec.js') && !ctx.query.original) {
return await wrapHydrationTest(ctx.path.slice(1)); // remove leading /
}
},
};