diff --git a/packages/@lwc/integration-karma/test-hydration/context/index.spec.js b/packages/@lwc/integration-karma/test-hydration/context/index.spec.js index ed71537fac..4e8ffea3c7 100644 --- a/packages/@lwc/integration-karma/test-hydration/context/index.spec.js +++ b/packages/@lwc/integration-karma/test-hydration/context/index.spec.js @@ -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'); diff --git a/packages/@lwc/integration-not-karma/configs/base.js b/packages/@lwc/integration-not-karma/configs/base.js index af0380fba7..41c8bde90f 100644 --- a/packages/@lwc/integration-not-karma/configs/base.js +++ b/packages/@lwc/integration-not-karma/configs/base.js @@ -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]])); @@ -33,12 +32,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') { // 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) diff --git a/packages/@lwc/integration-not-karma/configs/integration.js b/packages/@lwc/integration-not-karma/configs/integration.js index b49ae009e2..d5dca29198 100644 --- a/packages/@lwc/integration-not-karma/configs/integration.js +++ b/packages/@lwc/integration-not-karma/configs/integration.js @@ -1,3 +1,4 @@ +import { importMapsPlugin } from '@web/dev-server-import-maps'; import baseConfig from './base.js'; import testPlugin from './plugins/serve-integration.js'; @@ -23,5 +24,9 @@ export default { '!test/template-expressions/errors/index.spec.js', '!test/template-expressions/smoke-test/index.spec.js', ], - plugins: [...baseConfig.plugins, testPlugin], + plugins: [ + ...baseConfig.plugins, + importMapsPlugin({ inject: { importMap: { imports: { lwc: './mocks/lwc.js' } } } }), + testPlugin, + ], }; diff --git a/packages/@lwc/integration-not-karma/configs/plugins/serve-hydration.js b/packages/@lwc/integration-not-karma/configs/plugins/serve-hydration.js index 0ae204ef46..74f66ee942 100644 --- a/packages/@lwc/integration-not-karma/configs/plugins/serve-hydration.js +++ b/packages/@lwc/integration-not-karma/configs/plugins/serve-hydration.js @@ -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) { @@ -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, @@ -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 = {}) { - // 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}'); + ${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) { @@ -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); - }); + import { runTest } from '/configs/plugins/test-hydration.js'; + runTest( + '/${configPath}?original=1', + '/${componentEntrypoint}', + ${JSON.stringify(ssrOutput) /* escape quotes */}, + ${onlyFileExists} + ); `; } finally { - requiredFeatureFlags?.forEach((featureFlag) => { + config.requiredFeatureFlags?.forEach((featureFlag) => { lwcSsr.setFeatureFlagForTest(featureFlag, false); }); } @@ -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'); } }, }; diff --git a/packages/@lwc/integration-not-karma/configs/plugins/test-hydration.js b/packages/@lwc/integration-not-karma/configs/plugins/test-hydration.js new file mode 100644 index 0000000000..96d0371034 --- /dev/null +++ b/packages/@lwc/integration-not-karma/configs/plugins/test-hydration.js @@ -0,0 +1,77 @@ +import * as LWC from 'lwc'; +import { spyConsole } from '../../helpers/console'; +import { setHooks } from '../../helpers/hooks'; + +setHooks({ sanitizeHtmlContent: (content) => content }); + +function parseStringToDom(html) { + return Document.parseHTMLUnsafe(html).body.firstChild; +} + +function appendTestTarget(ssrText) { + const div = document.createElement('div'); + const testTarget = parseStringToDom(ssrText); + div.appendChild(testTarget); + document.body.appendChild(div); + return div; +} + +function setFeatureFlags(requiredFeatureFlags, value) { + requiredFeatureFlags?.forEach((featureFlag) => { + LWC.setFeatureFlagForTest(featureFlag, value); + }); +} + +// Must be sync to properly register tests; async behavior can happen in before/after blocks +export function runTest(configPath, componentPath, ssrRendered, focused) { + 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(); + }); + + 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, + }); + } + }); +} diff --git a/packages/@lwc/integration-not-karma/helpers/test-hydrate.js b/packages/@lwc/integration-not-karma/helpers/test-hydrate.js deleted file mode 100644 index 95c8823860..0000000000 --- a/packages/@lwc/integration-not-karma/helpers/test-hydrate.js +++ /dev/null @@ -1,60 +0,0 @@ -import * as LWC from 'lwc'; -import { spyConsole } from './console'; -import { setHooks } from './hooks'; - -setHooks({ sanitizeHtmlContent: (content) => content }); - -function parseStringToDom(html) { - return Document.parseHTMLUnsafe(html).body.firstChild; -} - -function appendTestTarget(ssrText) { - const div = document.createElement('div'); - const testTarget = parseStringToDom(ssrText); - div.appendChild(testTarget); - document.body.appendChild(div); - return div; -} - -function setFeatureFlags(requiredFeatureFlags, value) { - requiredFeatureFlags?.forEach((featureFlag) => { - LWC.setFeatureFlagForTest(featureFlag, 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; -} - -export { runTest }; diff --git a/packages/@lwc/integration-not-karma/package.json b/packages/@lwc/integration-not-karma/package.json index 2618714eab..fb25ede17b 100644 --- a/packages/@lwc/integration-not-karma/package.json +++ b/packages/@lwc/integration-not-karma/package.json @@ -16,6 +16,7 @@ "@lwc/synthetic-shadow": "8.22.1", "@types/chai": "^5.2.2", "@types/jasmine": "^5.1.9", + "@vitest/spy": "^3.2.4", "@web/dev-server-import-maps": "^0.2.1", "@web/dev-server-rollup": "^0.6.4", "@web/test-runner": "^0.20.2", diff --git a/packages/@lwc/integration-not-karma/test/component/lifecycle-callbacks/index.spec.js b/packages/@lwc/integration-not-karma/test/component/lifecycle-callbacks/index.spec.js index 483d5914e8..56de75af01 100644 --- a/packages/@lwc/integration-not-karma/test/component/lifecycle-callbacks/index.spec.js +++ b/packages/@lwc/integration-not-karma/test/component/lifecycle-callbacks/index.spec.js @@ -14,7 +14,7 @@ import Details from 'x/details'; import MutationsParent from 'mutations/parent'; import MutationsParentLight from 'mutations/parentLight'; -import { extractDataIds } from 'test-utils'; +import { extractDataIds } from '../../../helpers/utils'; function resetTimingBuffer() { window.timingBuffer = []; diff --git a/yarn.lock b/yarn.lock index 86b5d28565..ff830edb48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -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==