Skip to content
Merged
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
8c7ed79
test(wtr): update tests to use relative path to utils
wjhsf Aug 28, 2025
e2f88d5
test(wtr): revert removing TestUtils
wjhsf Sep 2, 2025
81275d0
test(wtr): remove useless describe
wjhsf Sep 2, 2025
39533d3
test(wtr): replace IIFE script with module import
wjhsf Sep 2, 2025
33e58c8
test(wtr): clean up SSR execution script
wjhsf Sep 2, 2025
d0b8003
test(wtr): always use DISABLE_SYNTHETIC
wjhsf Sep 3, 2025
976c002
test(wtr): enable all hydration tests
wjhsf Sep 3, 2025
8d39505
Merge branch 'master' into wjh/wtr-hyd
wjhsf Sep 4, 2025
c66afad
test(wtr): remove side effect from signals helper
wjhsf Sep 4, 2025
f1c50b0
test(wtr): import directly from file, not from barrel exporter
wjhsf Sep 4, 2025
7a73bf0
test(wtr): change bulk export statement to individual exports
wjhsf Sep 4, 2025
9b7b21b
test(wtr): remove unnecessary aria re-export
wjhsf Sep 4, 2025
9deaca2
test(wtr): fix a few more ARIA util imports
wjhsf Sep 4, 2025
1cf1275
chore: move comment for nicer aesthetic
wjhsf Sep 4, 2025
7fc137c
test(wtr): remove unused option
wjhsf Sep 4, 2025
ae937cc
test(wtr): fix another ARIA util import
wjhsf Sep 4, 2025
9250dd5
test(wtr): import directly from hooks file rather than utils
wjhsf Sep 4, 2025
d4280e8
test(wtr): import directly from signals file rather than utils
wjhsf Sep 4, 2025
bbffbf3
test(wtr): import directly from console helper rather than utils
wjhsf Sep 4, 2025
1ef88ed
test(wtr): import directly from constants helper rather than utils
wjhsf Sep 4, 2025
02f8ed1
test(wtr): only import what is needed from LWC
wjhsf Sep 4, 2025
7a5260a
test(wtr): split lwc:dynamic load helpers into separate file
wjhsf Sep 4, 2025
1a08a62
test(wtr): avoid relying on global LWC
wjhsf Sep 4, 2025
f14c829
Merge branch 'master' into wjh/wtr-smaller-utils
wjhsf Sep 9, 2025
289deeb
Merge branch 'master' into wjh/wtr-smaller-utils
wjhsf Sep 9, 2025
b97f783
test(wtr): remove unnecessary wrapping of hydration test config
wjhsf Sep 9, 2025
3cdb65c
test(wtr): remove test-utils logic from resolveImport
wjhsf Sep 9, 2025
7009a26
test(wtr): move plugin from shared config to only config that uses it
wjhsf Sep 9, 2025
19c1090
test(wtr): remove useless guid
wjhsf Sep 9, 2025
5e75329
test(wtr): remove unnecessary test-utils global
wjhsf Sep 9, 2025
497bfa8
test(wtr): clean up component definition
wjhsf Sep 9, 2025
8ff59c5
test(wtr): change component in test from IIFE to import
wjhsf Sep 11, 2025
885ce57
test(wtr): shift logic out of wrapper into static test runner file
wjhsf Sep 11, 2025
aa47c49
test(wtr): make test setup/teardown more idiomatic
wjhsf Sep 11, 2025
6185811
test(wtr): clean up module compilation
wjhsf Sep 11, 2025
ec0186f
chore(wtr): make dependency on @vitest/spy explicit
wjhsf Sep 11, 2025
3463450
test(wtr): remove console suppression
wjhsf Sep 11, 2025
7c0eb0a
test(wtr): clean up names and comments
wjhsf Sep 11, 2025
d91c8aa
Merge branch 'master' into wjh/wtr-clean-hydration
wjhsf Sep 11, 2025
371032d
Merge branch 'master' into wjh/wtr-clean-hydration
wjhsf Sep 12, 2025
c47a44d
test(wtr): move and rename file
wjhsf Sep 12, 2025
639f754
Merge branch 'master' into wjh/wtr-clean-hydration
wjhsf Sep 12, 2025
83cbf04
Merge branch 'master' into wjh/wtr-clean-hydration
wjhsf Sep 16, 2025
7479525
test(wtr): remove rogue test-utils
wjhsf Sep 16, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
export default {
// server is expected to generate the same console error as the client
expectedSSRConsoleCalls: {
error: [],
warn: [
'Attempted to connect to trusted context but received the following error',
'Multiple contexts of the same variety were provided. Only the first context will be used.',
],
},
requiredFeatureFlags: ['ENABLE_EXPERIMENTAL_SIGNALS'],
snapshot(target) {
const grandparent = target.shadowRoot.querySelector('x-grandparent');
Expand Down
6 changes: 1 addition & 5 deletions packages/@lwc/integration-not-karma/configs/base.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { join } from 'node:path';
import { LWC_VERSION } from '@lwc/shared';
import { importMapsPlugin } from '@web/dev-server-import-maps';
import * as options from '../helpers/options.js';

const pluck = (obj, keys) => Object.fromEntries(keys.map((k) => [k, obj[k]]));
Expand Down Expand Up @@ -32,12 +31,9 @@ export default {
nodeResolve: true,
rootDir: join(import.meta.dirname, '..'),
plugins: [
importMapsPlugin({ inject: { importMap: { imports: { lwc: './mocks/lwc.js' } } } }),
{
resolveImport({ source }) {
if (source === 'test-utils') {
return '/helpers/utils.js';
} else if (source === 'wire-service') {
if (source === 'wire-service') {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We no longer import from test-utils, so we don't need to resolve it. (I missed this in the other PRs.)

// To serve files outside the web root (e.g. node_modules in the monorepo root),
// @web/dev-server provides this "magic" path. It's hacky of us to use it directly.
// `/__wds-outside-root__/${depth}/` === '../'.repeat(depth)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { importMapsPlugin } from '@web/dev-server-import-maps';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved from the base config to this config, because only this config needs this plugin.

import baseConfig from './base.js';
import testPlugin from './plugins/serve-integration.js';

Expand All @@ -17,5 +18,9 @@ export default {
// Implement objectContaining / arrayWithExactContents
'!test/profiler/mutation-logging/index.spec.js',
],
plugins: [...baseConfig.plugins, testPlugin],
plugins: [
...baseConfig.plugins,
importMapsPlugin({ inject: { importMap: { imports: { lwc: './mocks/lwc.js' } } } }),
testPlugin,
],
};
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@ lwcSsr.setHooks({
});

const ROOT_DIR = path.join(import.meta.dirname, '../..');

let guid = 0;
const COMPONENT_UNDER_TEST = 'main';
const COMPONENT_NAME = 'x-main';
const COMPONENT_ENTRYPOINT = 'x/main/main.js';

// Like `fs.existsSync` but async
async function exists(path) {
Expand All @@ -32,13 +31,14 @@ async function exists(path) {
}
}

async function getCompiledModule(dir, compileForSSR) {
async function compileModule(input, targetSSR, format) {
const modulesDir = path.join(ROOT_DIR, input.slice(0, -COMPONENT_ENTRYPOINT.length));
const bundle = await rollup({
input: path.join(dir, 'x', COMPONENT_UNDER_TEST, `${COMPONENT_UNDER_TEST}.js`),
input,
plugins: [
lwcRollupPlugin({
targetSSR: !!compileForSSR,
modules: [{ dir: path.join(ROOT_DIR, dir) }],
targetSSR,
modules: [{ dir: modulesDir }],
experimentalDynamicComponent: {
loader: fileURLToPath(new URL('../../helpers/loader.js', import.meta.url)),
strict: true,
Expand All @@ -61,102 +61,49 @@ async function getCompiledModule(dir, compileForSSR) {
});

const { output } = await bundle.generate({
format: 'iife',
name: 'Main',
format,
name: 'Component',
globals: {
lwc: 'LWC',
'@lwc/ssr-runtime': 'LWC',
'test-utils': 'TestUtils',
},
});

return output[0].code;
}

function throwOnUnexpectedConsoleCalls(runnable, expectedConsoleCalls = {}) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was added in #4649 to suppress logs for cosmetic reasons. WTR surfaces logs differently, so we don't need to keep it.

// 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.
* This function takes a path to a component definition and a config file and returns the
* SSR-generated markup for the component. It does so by compiling the component and then
* running a script in a separate JS runtime environment to render it.
*/
async function getSsrCode(moduleCode, testConfig, filePath, expectedSSRConsoleCalls) {
async function getSsrMarkup(componentEntrypoint, configPath) {
const componentIife = await compileModule(componentEntrypoint, !ENGINE_SERVER, 'iife');
// To minimize the amount of code in the generated script, ideally we'd do `import Component`
// and delegate the bundling to the loader. However, that's complicated to configure and using
// imports with vm.Script/vm.Module is still experimental, so we use an IIFE for simplicity.
// Additionally, we could import LWC, but the framework requires configuration before each test
// (setHooks/setFeatureFlagForTest), so instead we configure it once in the top-level context
// and inject it as a global variable.
const script = new vm.Script(
`(() => {
${testConfig}
${moduleCode}
`(async () => {
const {default: config} = await import('./${configPath}');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Converting from IIFE to ESM import because the config file doesn't need to be bundled, it just works.

${componentIife /* var Component = ... */}
return LWC.renderComponent(
'x-${COMPONENT_UNDER_TEST}-${guid++}',
Main,
'${COMPONENT_NAME}',
Component,
config.props || {},
false,
'sync'
);
})()`,
{ filename: `[SSR] ${filePath}` }
{
filename: `[SSR] ${configPath}`,
importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER,
}
);

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;
return await script.runInContext(vm.createContext({ LWC: lwcSsr }));
}

async function existsUp(dir, file) {
Expand All @@ -172,45 +119,32 @@ async function existsUp(dir, file) {
* 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));
async function wrapHydrationTest(configPath) {
const { default: config } = await import(path.join(ROOT_DIR, configPath));

try {
requiredFeatureFlags?.forEach((featureFlag) => {
config.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 suiteDir = path.dirname(configPath);
const componentEntrypoint = path.join(suiteDir, COMPONENT_ENTRYPOINT);
// You can add an `.only` file alongside an `index.spec.js` file to make the test focused
const onlyFileExists = await existsUp(suiteDir, '.only');
const ssrOutput = await getSsrMarkup(componentEntrypoint, configPath);

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);
});
runTest(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved as much logic as I could from the generated code into runTest.

'/${configPath}?original=1',
'/${componentEntrypoint}',
${JSON.stringify(ssrOutput) /* escape quotes */},
${onlyFileExists}
);
`;
} finally {
requiredFeatureFlags?.forEach((featureFlag) => {
config.requiredFeatureFlags?.forEach((featureFlag) => {
lwcSsr.setFeatureFlagForTest(featureFlag, false);
});
}
Expand All @@ -226,6 +160,8 @@ export default {
// to return the file unmodified.
if (ctx.path.endsWith('.spec.js') && !ctx.query.original) {
return await wrapHydrationTest(ctx.path.slice(1)); // remove leading /
} else if (ctx.path.endsWith('/' + COMPONENT_ENTRYPOINT)) {
return await compileModule(ctx.path.slice(1) /* remove leading / */, false, 'esm');
}
},
};
87 changes: 52 additions & 35 deletions packages/@lwc/integration-not-karma/helpers/test-hydrate.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,39 +22,56 @@ function setFeatureFlags(requiredFeatureFlags, value) {
});
}

async function runTest(ssrRendered, Component, testConfig) {
const container = appendTestTarget(ssrRendered);
const selector = container.firstChild.tagName.toLowerCase();
let target = container.querySelector(selector);

let testResult;
const consoleSpy = spyConsole();
setFeatureFlags(testConfig.requiredFeatureFlags, true);

if (testConfig.test) {
const snapshot = testConfig.snapshot ? testConfig.snapshot(target) : {};

const props = testConfig.props || {};
const clientProps = testConfig.clientProps || props;

LWC.hydrateComponent(target, Component, clientProps);

// let's select again the target, it should be the same elements as in the snapshot
target = container.querySelector(selector);
testResult = await testConfig.test(target, snapshot, consoleSpy.calls);
} else if (testConfig.advancedTest) {
testResult = await testConfig.advancedTest(target, {
Component,
hydrateComponent: LWC.hydrateComponent.bind(LWC),
consoleSpy,
container,
selector,
});
}

consoleSpy.reset();

return testResult;
}
// Must be sync to properly register tests; async behavior can happen in before/after blocks
export function runTest(configPath, componentPath, ssrRendered, focused) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed this to define the tests (and use idiomatic setup/teardown) rather than be a helper within a test.

const test = focused ? it.only : it;
const description = new URL(configPath, location.href).pathname;
let consoleSpy;
let testConfig;
let Component;

beforeAll(async () => {
testConfig = await import(configPath);
Component = await import(componentPath);
setFeatureFlags(testConfig.requiredFeatureFlags, true);
});

beforeEach(async () => {
consoleSpy = spyConsole();
});

afterEach(() => {
consoleSpy.reset();
});

export { runTest };
afterAll(() => {
setFeatureFlags(testConfig.requiredFeatureFlags, false);
});

test(description, async () => {
const container = appendTestTarget(ssrRendered);
const selector = container.firstChild.tagName.toLowerCase();
let target = container.querySelector(selector);

if (testConfig.test) {
const snapshot = testConfig.snapshot ? testConfig.snapshot(target) : {};

const props = testConfig.props || {};
const clientProps = testConfig.clientProps || props;

LWC.hydrateComponent(target, Component, clientProps);

// let's select again the target, it should be the same elements as in the snapshot
target = container.querySelector(selector);
await testConfig.test(target, snapshot, consoleSpy.calls);
} else if (testConfig.advancedTest) {
await testConfig.advancedTest(target, {
Component,
hydrateComponent: LWC.hydrateComponent.bind(LWC),
consoleSpy,
container,
selector,
});
}
});
}
1 change: 1 addition & 0 deletions packages/@lwc/integration-not-karma/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@lwc/synthetic-shadow": "8.21.6",
"@types/chai": "^5.2.2",
"@types/jasmine": "^5.1.9",
"@vitest/spy": "^3.2.4",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explicitly declaring an already-used transitive dependency.

"@web/dev-server-import-maps": "^0.2.1",
"@web/dev-server-rollup": "^0.6.4",
"@web/test-runner": "^0.20.2",
Expand Down
4 changes: 3 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2089,9 +2089,11 @@

"@lwc/eslint-plugin-lwc-internal@link:./scripts/eslint-plugin":
version "0.0.0"
uid ""

"@lwc/test-utils-lwc-internals@link:./scripts/test-utils":
version "0.0.0"
uid ""

"@napi-rs/wasm-runtime@0.2.4":
version "0.2.4"
Expand Down Expand Up @@ -3556,7 +3558,7 @@
magic-string "^0.30.12"
pathe "^1.1.2"

"@vitest/spy@3.2.4":
"@vitest/spy@3.2.4", "@vitest/spy@^3.2.4":
version "3.2.4"
resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.2.4.tgz#cc18f26f40f3f028da6620046881f4e4518c2599"
integrity sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==
Expand Down