Skip to content

Commit 32557e6

Browse files
authored
test(web-test-runner): run hydration tests in CI @W-18780671 (#5398)
* refactor(wtr): move NODE_ENV_FOR_TEST default into options file * chore(wtr): move hooks util to separate file * chore(wtr): move aria utils to separate file * chore(wtr): move constants to separate file * chore(wtr): clean up custom rollup plugin * test(wtr): get hydration tests kinda running * test(wtr): kinda start moving to ESM instead of IIFE * fix(shared): make sanitizeHtmlContent work * chore(ci): run hydration tests in ci * test(wtr): remove unused script
1 parent 692dee1 commit 32557e6

File tree

14 files changed

+565
-167
lines changed

14 files changed

+565
-167
lines changed

.github/workflows/web-test-runner.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ jobs:
4949
# region: us
5050

5151
- run: yarn test
52+
- run: yarn test:hydration
5253

5354
run-karma-tests:
5455
runs-on: ubuntu-22.04
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// This mapping should be kept up-to-date with the mapping in @lwc/shared -> aria.ts
2+
export const ariaPropertiesMapping = {
3+
ariaAutoComplete: 'aria-autocomplete',
4+
ariaChecked: 'aria-checked',
5+
ariaCurrent: 'aria-current',
6+
ariaDisabled: 'aria-disabled',
7+
ariaExpanded: 'aria-expanded',
8+
ariaHasPopup: 'aria-haspopup',
9+
ariaHidden: 'aria-hidden',
10+
ariaInvalid: 'aria-invalid',
11+
ariaLabel: 'aria-label',
12+
ariaLevel: 'aria-level',
13+
ariaMultiLine: 'aria-multiline',
14+
ariaMultiSelectable: 'aria-multiselectable',
15+
ariaOrientation: 'aria-orientation',
16+
ariaPressed: 'aria-pressed',
17+
ariaReadOnly: 'aria-readonly',
18+
ariaRequired: 'aria-required',
19+
ariaSelected: 'aria-selected',
20+
ariaSort: 'aria-sort',
21+
ariaValueMax: 'aria-valuemax',
22+
ariaValueMin: 'aria-valuemin',
23+
ariaValueNow: 'aria-valuenow',
24+
ariaValueText: 'aria-valuetext',
25+
ariaLive: 'aria-live',
26+
ariaRelevant: 'aria-relevant',
27+
ariaAtomic: 'aria-atomic',
28+
ariaBusy: 'aria-busy',
29+
ariaActiveDescendant: 'aria-activedescendant',
30+
ariaControls: 'aria-controls',
31+
ariaDescribedBy: 'aria-describedby',
32+
ariaFlowTo: 'aria-flowto',
33+
ariaLabelledBy: 'aria-labelledby',
34+
ariaOwns: 'aria-owns',
35+
ariaPosInSet: 'aria-posinset',
36+
ariaSetSize: 'aria-setsize',
37+
ariaColCount: 'aria-colcount',
38+
ariaColSpan: 'aria-colspan',
39+
ariaColIndex: 'aria-colindex',
40+
ariaColIndexText: 'aria-colindextext',
41+
ariaDescription: 'aria-description',
42+
ariaDetails: 'aria-details',
43+
ariaErrorMessage: 'aria-errormessage',
44+
ariaKeyShortcuts: 'aria-keyshortcuts',
45+
ariaModal: 'aria-modal',
46+
ariaPlaceholder: 'aria-placeholder',
47+
ariaRoleDescription: 'aria-roledescription',
48+
ariaRowCount: 'aria-rowcount',
49+
ariaRowIndex: 'aria-rowindex',
50+
ariaRowIndexText: 'aria-rowindextext',
51+
ariaRowSpan: 'aria-rowspan',
52+
ariaBrailleLabel: 'aria-braillelabel',
53+
ariaBrailleRoleDescription: 'aria-brailleroledescription',
54+
role: 'role',
55+
};
56+
57+
// See the README for @lwc/aria-reflection
58+
export const nonStandardAriaProperties = [
59+
'ariaActiveDescendant',
60+
'ariaControls',
61+
'ariaDescribedBy',
62+
'ariaDetails',
63+
'ariaErrorMessage',
64+
'ariaFlowTo',
65+
'ariaLabelledBy',
66+
'ariaOwns',
67+
];
68+
69+
// These properties are not included in the global polyfill, but were added to LightningElement/BridgeElement
70+
// prototypes in https://github.com/salesforce/lwc/pull/3702
71+
export const nonPolyfilledAriaProperties = [
72+
'ariaColIndexText',
73+
'ariaBrailleLabel',
74+
'ariaBrailleRoleDescription',
75+
'ariaDescription',
76+
'ariaRowIndexText',
77+
];
78+
79+
export const ariaProperties = Object.keys(ariaPropertiesMapping);
80+
export const ariaAttributes = Object.values(ariaPropertiesMapping);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { API_VERSION } from './options.mjs';
2+
3+
// These values are based on the API versions in @lwc/shared/api-version
4+
export const LOWERCASE_SCOPE_TOKENS = API_VERSION >= 59,
5+
USE_COMMENTS_FOR_FRAGMENT_BOOKENDS = API_VERSION >= 60,
6+
USE_FRAGMENTS_FOR_LIGHT_DOM_SLOTS = API_VERSION >= 60,
7+
DISABLE_OBJECT_REST_SPREAD_TRANSFORMATION = API_VERSION >= 60,
8+
ENABLE_ELEMENT_INTERNALS_AND_FACE = API_VERSION >= 61,
9+
USE_LIGHT_DOM_SLOT_FORWARDING = API_VERSION >= 61,
10+
ENABLE_THIS_DOT_HOST_ELEMENT = API_VERSION >= 62,
11+
ENABLE_THIS_DOT_STYLE = API_VERSION >= 62,
12+
TEMPLATE_CLASS_NAME_OBJECT_BINDING = API_VERSION >= 62;
13+
14+
export const IS_SYNTHETIC_SHADOW_LOADED = !`${ShadowRoot}`.includes('[native code]');
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* LWC only lets us call `setHooks` once. But we need to do it multiple times
3+
* for tests, so we implement
4+
*/
5+
import { setHooks as lwcSetHooks } from 'lwc';
6+
7+
let sanitizeHtmlContentHook = function shouldBeReplaced() {
8+
throw new Error('[TEST] sanitizeHtmlContent hook must be implemented.');
9+
};
10+
11+
lwcSetHooks({
12+
sanitizeHtmlContent(content) {
13+
return sanitizeHtmlContentHook(content);
14+
},
15+
});
16+
17+
export function getHooks() {
18+
return { sanitizeHtmlContent: sanitizeHtmlContentHook };
19+
}
20+
21+
export function setHooks(hooks) {
22+
sanitizeHtmlContentHook = hooks.sanitizeHtmlContent;
23+
}
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import path from 'node:path';
2+
import vm from 'node:vm';
3+
import fs from 'node:fs/promises';
4+
import { rollup } from 'rollup';
5+
import lwcRollupPlugin from '@lwc/rollup-plugin';
6+
import { DISABLE_STATIC_CONTENT_OPTIMIZATION, ENGINE_SERVER } from './options.mjs';
7+
const lwcSsr = await (ENGINE_SERVER ? import('@lwc/engine-server') : import('@lwc/ssr-runtime'));
8+
9+
const ROOT_DIR = path.join(import.meta.dirname, '..');
10+
11+
const context = {
12+
LWC: lwcSsr,
13+
moduleOutput: null,
14+
};
15+
16+
lwcSsr.setHooks({
17+
sanitizeHtmlContent(content) {
18+
return content;
19+
},
20+
});
21+
22+
let guid = 0;
23+
const COMPONENT_UNDER_TEST = 'main';
24+
25+
// Like `fs.existsSync` but async
26+
async function exists(path) {
27+
try {
28+
await fs.access(path);
29+
return true;
30+
} catch (_err) {
31+
return false;
32+
}
33+
}
34+
35+
async function getCompiledModule(dir, compileForSSR) {
36+
const bundle = await rollup({
37+
input: path.join(dir, 'x', COMPONENT_UNDER_TEST, `${COMPONENT_UNDER_TEST}.js`),
38+
plugins: [
39+
lwcRollupPlugin({
40+
targetSSR: !!compileForSSR,
41+
modules: [{ dir: path.join(ROOT_DIR, dir) }],
42+
experimentalDynamicComponent: {
43+
loader: 'test-utils',
44+
strict: true,
45+
},
46+
enableDynamicComponents: true,
47+
enableLwcOn: true,
48+
enableStaticContentOptimization: !DISABLE_STATIC_CONTENT_OPTIMIZATION,
49+
experimentalDynamicDirective: true,
50+
}),
51+
],
52+
53+
external: ['lwc', '@lwc/ssr-runtime', 'test-utils', '@test/loader'], // @todo: add ssr modules for test-utils and @test/loader
54+
55+
onwarn(warning, warn) {
56+
// Ignore warnings from our own Rollup plugin
57+
if (warning.plugin !== 'rollup-plugin-lwc-compiler') {
58+
warn(warning);
59+
}
60+
},
61+
});
62+
63+
const { output } = await bundle.generate({
64+
format: 'iife',
65+
name: 'Main',
66+
globals: {
67+
lwc: 'LWC',
68+
'@lwc/ssr-runtime': 'LWC',
69+
'test-utils': 'TestUtils',
70+
},
71+
});
72+
73+
return output[0].code;
74+
}
75+
76+
function throwOnUnexpectedConsoleCalls(runnable, expectedConsoleCalls = {}) {
77+
// The console is shared between the VM and the main realm. Here we ensure that known warnings
78+
// are ignored and any others cause an explicit error.
79+
const methods = ['error', 'warn', 'log', 'info'];
80+
const originals = {};
81+
for (const method of methods) {
82+
// eslint-disable-next-line no-console
83+
originals[method] = console[method];
84+
// eslint-disable-next-line no-console
85+
console[method] = function (error) {
86+
if (
87+
method === 'warn' &&
88+
// This eslint warning is a false positive due to RegExp.prototype.test
89+
// eslint-disable-next-line vitest/no-conditional-tests
90+
/Cannot set property "(inner|outer)HTML"/.test(error?.message)
91+
) {
92+
return;
93+
} else if (
94+
expectedConsoleCalls[method]?.some((matcher) => error.message.includes(matcher))
95+
) {
96+
return;
97+
}
98+
99+
throw new Error(`Unexpected console.${method} call: ${error}`);
100+
};
101+
}
102+
try {
103+
runnable();
104+
} finally {
105+
Object.assign(console, originals);
106+
}
107+
}
108+
109+
/**
110+
* This is the function that takes SSR bundle code and test config, constructs a script that will
111+
* run in a separate JS runtime environment with its own global scope. The `context` object
112+
* (defined at the top of this file) is passed in as the global scope for that script. The script
113+
* runs, utilizing the `LWC` object that we've attached to the global scope, it sets a
114+
* new value (the rendered markup) to `globalThis.moduleOutput`, which corresponds to
115+
* `context.moduleOutput in this file's scope.
116+
*
117+
* So, script runs, generates markup, & we get that markup out and return it for use
118+
* in client-side tests.
119+
*/
120+
async function getSsrCode(moduleCode, testConfig, filename, expectedSSRConsoleCalls) {
121+
const script = new vm.Script(
122+
// FIXME: Can these IIFEs be converted to ESM imports?
123+
// No, vm.Script doesn't support that. But might be doable with experimental vm.Module
124+
`
125+
${testConfig};
126+
config = config || {};
127+
${moduleCode};
128+
moduleOutput = LWC.renderComponent(
129+
'x-${COMPONENT_UNDER_TEST}-${guid++}',
130+
Main,
131+
config.props || {},
132+
false,
133+
'sync'
134+
);
135+
`,
136+
{ filename }
137+
);
138+
139+
throwOnUnexpectedConsoleCalls(() => {
140+
vm.createContext(context);
141+
script.runInContext(context);
142+
}, expectedSSRConsoleCalls);
143+
144+
return await context.moduleOutput;
145+
}
146+
147+
async function getTestConfig(input) {
148+
const bundle = await rollup({
149+
input,
150+
external: ['lwc', 'test-utils', '@test/loader'],
151+
});
152+
153+
const { output } = await bundle.generate({
154+
format: 'iife',
155+
globals: {
156+
lwc: 'LWC',
157+
'test-utils': 'TestUtils',
158+
},
159+
name: 'config',
160+
});
161+
162+
const { code } = output[0];
163+
164+
return code;
165+
}
166+
167+
async function existsUp(dir, file) {
168+
while (true) {
169+
if (await exists(path.join(dir, file))) return true;
170+
dir = path.join(dir, '..');
171+
const basename = path.basename(dir);
172+
if (basename === '.') return false;
173+
}
174+
}
175+
176+
/**
177+
* Hydration test `index.spec.js` files are actually config files, not spec files.
178+
* This function wraps those configs in the test code to be executed.
179+
*/
180+
export default async function wrapHydrationTest(filePath /* .../index.spec.js */) {
181+
const suiteDir = path.dirname(filePath);
182+
183+
// Wrap all the tests into a describe block with the file stricture name
184+
const describeTitle = path.relative(ROOT_DIR, suiteDir).split(path.sep).join(' ');
185+
186+
const testCode = await getTestConfig(filePath);
187+
188+
// Create a temporary module to evaluate the bundled code and extract config properties for test configuration
189+
const configModule = new vm.Script(testCode);
190+
const configContext = { config: {} };
191+
vm.createContext(configContext);
192+
configModule.runInContext(configContext);
193+
const { expectedSSRConsoleCalls, requiredFeatureFlags } = configContext.config;
194+
195+
requiredFeatureFlags?.forEach((featureFlag) => {
196+
lwcSsr.setFeatureFlagForTest(featureFlag, true);
197+
});
198+
199+
try {
200+
// You can add an `.only` file alongside an `index.spec.js` file to make it `fdescribe()`
201+
const onlyFileExists = await existsUp(suiteDir, '.only');
202+
203+
const describeFn = onlyFileExists ? 'describe.only' : 'describe';
204+
const componentDefCSR = await getCompiledModule(suiteDir, false);
205+
const componentDefSSR = ENGINE_SERVER
206+
? componentDefCSR
207+
: await getCompiledModule(suiteDir, true);
208+
const ssrOutput = await getSsrCode(
209+
componentDefSSR,
210+
testCode,
211+
path.join(suiteDir, 'ssr.js'),
212+
expectedSSRConsoleCalls
213+
);
214+
215+
// FIXME: can we turn these IIFEs into ESM imports?
216+
return `
217+
import { runTest } from '/helpers/test-hydrate.js';
218+
import config from '/${filePath}?original=1';
219+
${describeFn}("${describeTitle}", () => {
220+
it('test', async () => {
221+
const ssrRendered = ${JSON.stringify(ssrOutput) /* escape quotes */};
222+
// Component code, IIFE set as Main
223+
${componentDefCSR};
224+
return await runTest(ssrRendered, Main, config);
225+
})
226+
});`;
227+
} finally {
228+
requiredFeatureFlags?.forEach((featureFlag) => {
229+
lwcSsr.setFeatureFlagForTest(featureFlag, false);
230+
});
231+
}
232+
}

0 commit comments

Comments
 (0)