From 960b18f448f7ca2d1f81adaae487382ca64b5eb3 Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Wed, 14 Oct 2026 19:15:28 +0100 Subject: [PATCH 1/3] feat: support v5 standalone output --- src/load-config-meta.ts | 32 +++++++++++++++++--- src/page/utils/set-content.ts | 9 ++++-- src/test/load-config-meta.spec.ts | 49 +++++++++++++++++++++++++++++-- src/test/wizard.spec.ts | 17 +++++++++-- src/wizard.ts | 22 +++++++------- 5 files changed, 108 insertions(+), 21 deletions(-) diff --git a/src/load-config-meta.ts b/src/load-config-meta.ts index f9e38c6..5bdf921 100644 --- a/src/load-config-meta.ts +++ b/src/load-config-meta.ts @@ -1,5 +1,5 @@ // @ts-ignore - position of type import changed in Stencil 5 -import { loadConfig, OutputTargetWww } from '@stencil/core/compiler'; +import { loadConfig, OutputTargetStandalone, OutputTargetWww, OutputTargetLoaderBundle } from '@stencil/core/compiler'; import { findUp } from 'find-up'; import { isAbsolute, join, relative } from 'path'; @@ -17,6 +17,19 @@ const DEFAULT_NAMESPACE = 'app'; const DEFAULT_BASE_URL = 'http://localhost:3333'; const DEFAULT_WEB_SERVER_URL = `${DEFAULT_BASE_URL}/ping`; +/** + * The file name of a `standalone` output target's auto-loader, if it has one that starts on import. + * @param target The `standalone` output target. + * @returns The loader's file name, or `undefined` when there's no auto-starting loader. + */ +const getStandaloneLoaderFile = (target: OutputTargetStandalone): string | undefined => { + const { autoLoader } = target; + if (autoLoader === false) return undefined; + if (typeof autoLoader === 'object' && autoLoader.autoStart === false) return undefined; + const fileName = (typeof autoLoader === 'object' && autoLoader.fileName) || 'loader'; + return fileName.endsWith('.js') ? fileName : `${fileName}.js`; +}; + const DEFAULT_STENCIL_ENTRY_PATH_PREFIX = './build'; const DEFAULT_STENCIL_ENTRY_PATH = `${DEFAULT_STENCIL_ENTRY_PATH_PREFIX}/${DEFAULT_NAMESPACE}`; @@ -47,13 +60,17 @@ export const loadConfigMeta = async (cwd?: string) => { const { devServer, fsNamespace, outputTargets, rootDir: resolvedRootDir } = results.config; // Grab a suitable output target for script injection. - // Priority: www > dist (v4) > loader-bundle (v5) + // Priority: www > dist (v4) > loader-bundle (v5) > standalone (v5) const wwwTarget = outputTargets.find((o): o is OutputTargetWww => o.type === 'www'); const distTarget = outputTargets.find((o) => (o as OutputTargetWithDir).type === 'dist') as OutputTargetWithDir | undefined; // loader-bundle is a v5 output target type, so we need to cast to avoid type errors on v4 const loaderBundleTarget = outputTargets.find((o) => (o as OutputTargetWithDir).type === 'loader-bundle') as - OutputTargetWithDir | undefined; + OutputTargetLoaderBundle | undefined; + + const standaloneTarget = outputTargets.find((o) => (o as OutputTargetWithDir).type === 'standalone') as + OutputTargetStandalone | undefined; + const standaloneLoaderFile = standaloneTarget ? getStandaloneLoaderFile(standaloneTarget) : undefined; // Use the resolved project rootDir as fallback if devServer.root is not usable const rootDir = devServer.root && devServer.root !== '/' ? devServer.root : resolvedRootDir; @@ -92,12 +109,19 @@ export const loadConfigMeta = async (cwd?: string) => { // Path structure: dir/buildDir/namespace/namespace (extra namespace folder) const entryPath = join(relativePath, buildDir, fsNamespace, fsNamespace); stencilEntryPath = entryPath === '' ? '.' : entryPath.startsWith('.') ? entryPath : `./${entryPath}`; + } else if (standaloneTarget?.dir && standaloneLoaderFile) { + // A standalone-only project: its auto-loader defines components as they appear in the page. + // The entry path is the full file name, which tells `setContent` to load it as one module. + const targetDir = standaloneTarget.dir; + const relativePath = isAbsolute(targetDir) ? relative(rootDir, targetDir) : targetDir; + const entryPath = join(relativePath, standaloneLoaderFile); + stencilEntryPath = entryPath.startsWith('.') ? entryPath : `./${entryPath}`; } else { // Make a best guess at the entry path stencilEntryPath = `${DEFAULT_STENCIL_ENTRY_PATH_PREFIX}/${fsNamespace}`; console.warn( - `No "www", "dist", or "loader-bundle" output target found in the Stencil config. Using default entry path: "${stencilEntryPath}". Tests using 'setContent' may fail to execute.`, + `No "www", "dist", "loader-bundle", or auto-loading "standalone" output target found in the Stencil config. Using default entry path: "${stencilEntryPath}". Tests using 'setContent' may fail to execute.`, ); } diff --git a/src/page/utils/set-content.ts b/src/page/utils/set-content.ts index 852e89e..d3afe19 100644 --- a/src/page/utils/set-content.ts +++ b/src/page/utils/set-content.ts @@ -28,6 +28,12 @@ export const setContent = async (page: Page, html: string, testInfo: TestInfo, o const headContent = headMatch ? headMatch[1] : ''; const bodyContent = bodyMatch ? bodyMatch[1] : html; + // A `standalone` auto-loader is one module (its entry path is the full file name); + // the lazy loader has a module build and a legacy `nomodule` one. + const entryScripts = baseEntryPath?.endsWith('.js') + ? `` + : ``; + const output = ` @@ -36,8 +42,7 @@ export const setContent = async (page: Page, html: string, testInfo: TestInfo, o ${headContent} - - + ${entryScripts} ${bodyContent} diff --git a/src/test/load-config-meta.spec.ts b/src/test/load-config-meta.spec.ts index bb9e40b..d23a559 100644 --- a/src/test/load-config-meta.spec.ts +++ b/src/test/load-config-meta.spec.ts @@ -12,7 +12,7 @@ const stencilConfig: { pingRoute: string; root: string; }; - outputTargets: Array<{ type: string; dir: string; buildDir?: string }>; + outputTargets: Array<{ type: string; dir: string; buildDir?: string; autoLoader?: unknown }>; } = { rootDir: '/mock-path', fsNamespace: 'mock-namespace', @@ -123,7 +123,7 @@ describe('loadConfigMeta', () => { const configMeta = await loadConfigMeta(); expect(consoleWarnSpy).toHaveBeenCalledWith( - `No "www", "dist", or "loader-bundle" output target found in the Stencil config. Using default entry path: "./build/mock-namespace". Tests using 'setContent' may fail to execute.`, + `No "www", "dist", "loader-bundle", or auto-loading "standalone" output target found in the Stencil config. Using default entry path: "./build/mock-namespace". Tests using 'setContent' may fail to execute.`, ); expect(configMeta).toEqual({ baseURL: 'http://localhost:4444', @@ -191,6 +191,51 @@ describe('loadConfigMeta', () => { }); }); + it('should fall back to the standalone auto-loader when it is the only usable target', async () => { + stencilConfig.outputTargets = [ + { + type: 'standalone', + dir: '/mock-path/dist/standalone', + autoLoader: { fileName: 'loader', autoStart: true }, + }, + ]; + findUpMock.mockResolvedValueOnce('/mock-path/stencil.config.ts'); + + const configMeta = await loadConfigMeta(); + + expect(configMeta).toEqual({ + baseURL: 'http://localhost:4444', + stencilEntryPath: './dist/standalone/loader.js', + stencilNamespace: 'mock-namespace', + webServerUrl: 'http://localhost:4444/status', + }); + }); + + it('should prefer loader-bundle over standalone', async () => { + stencilConfig.outputTargets = [ + { type: 'standalone', dir: '/mock-path/dist/standalone', autoLoader: { fileName: 'loader', autoStart: true } }, + { type: 'loader-bundle', dir: '/mock-path/dist/loader-bundle' }, + ]; + findUpMock.mockResolvedValueOnce('/mock-path/stencil.config.ts'); + + const configMeta = await loadConfigMeta(); + + expect(configMeta.stencilEntryPath).toBe('./dist/loader-bundle/mock-namespace/mock-namespace'); + }); + + it('should not use a standalone target whose auto-loader is off or does not start on import', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + for (const autoLoader of [false, { fileName: 'loader', autoStart: false }]) { + stencilConfig.outputTargets = [{ type: 'standalone', dir: '/mock-path/dist/standalone', autoLoader }]; + findUpMock.mockResolvedValueOnce('/mock-path/stencil.config.ts'); + + const configMeta = await loadConfigMeta(); + + expect(configMeta.stencilEntryPath).toBe('./build/mock-namespace'); + } + expect(consoleWarnSpy).toHaveBeenCalledTimes(2); + }); + it('should prefer www target over dist and loader-bundle', async () => { stencilConfig.outputTargets = [ { diff --git a/src/test/wizard.spec.ts b/src/test/wizard.spec.ts index af70ea7..91418bc 100644 --- a/src/test/wizard.spec.ts +++ b/src/test/wizard.spec.ts @@ -155,7 +155,7 @@ describe('wizard.init.run', () => { expect(ctx.openStencilConfig).not.toHaveBeenCalled(); }); - it('offers to add a www output target when stencil.config.ts exists and none is configured', async () => { + it('offers to add a loader-bundle output target when stencil.config.ts exists and none is configured', async () => { writeFileSync(join(tmpDir, 'stencil.config.ts'), 'export const config = {};\n'); const ctx = makeInitCtx(tmpDir, []); const editor = { addOutputTarget: vi.fn(), save: vi.fn().mockResolvedValue(undefined) }; @@ -164,7 +164,7 @@ describe('wizard.init.run', () => { await wizard.init!.run(ctx as any); - expect(editor.addOutputTarget).toHaveBeenCalledWith(expect.stringContaining("type: 'www'")); + expect(editor.addOutputTarget).toHaveBeenCalledWith("{ type: 'loader-bundle' }"); expect(editor.save).toHaveBeenCalled(); }); @@ -177,10 +177,21 @@ describe('wizard.init.run', () => { expect(ctx.openStencilConfig).not.toHaveBeenCalled(); expect(ctx.prompts.log.warn).toHaveBeenCalledWith( - expect.stringContaining('No "www" or "loader-bundle" output target configured'), + expect.stringContaining('No "www", "loader-bundle", or "standalone" output target configured'), ); }); + it('does not prompt or warn when only a "standalone" output target is configured', async () => { + writeFileSync(join(tmpDir, 'stencil.config.ts'), 'export const config = {};\n'); + const ctx = makeInitCtx(tmpDir, [{ type: 'standalone' }]); + + await wizard.init!.run(ctx as any); + + expect(ctx.prompts.confirm).not.toHaveBeenCalled(); + expect(ctx.openStencilConfig).not.toHaveBeenCalled(); + expect(ctx.prompts.log.warn).not.toHaveBeenCalled(); + }); + it('does not warn when a "www" output target is already configured', async () => { const ctx = makeInitCtx(tmpDir, [{ type: 'www' }]); diff --git a/src/wizard.ts b/src/wizard.ts index 8684f50..fc4d7a2 100644 --- a/src/wizard.ts +++ b/src/wizard.ts @@ -13,15 +13,16 @@ async function fileExists(path: string): Promise { } /** - * Checks for a "www" or "loader-bundle" output target. + * Checks for an output target the tests can load components from. * @param config The resolved Stencil project config. - * @returns Whether a "www" and/or "loader-bundle" output target is present. + * @returns Whether a "www", "loader-bundle", and/or "standalone" output target is present. */ function detectOutputTargets(config: ProjectConfig) { const outputs = config.outputTargets as ReadonlyArray<{ type: string }>; const www = outputs.some((o) => o.type === 'www'); const loaderBundle = outputs.some((o) => o.type === 'loader-bundle'); - return { www, loaderBundle }; + const standalone = outputs.some((o) => o.type === 'standalone'); + return { www, loaderBundle, standalone }; } const PLAYWRIGHT_CONFIG_TEMPLATE = `import { expect } from '@playwright/test'; @@ -79,9 +80,10 @@ async function ensureDisposableLib(rootDir: string): Promise { */ async function ensureOutputTarget(context: WizardContext): Promise { const { config, prompts } = context; - const { www, loaderBundle } = detectOutputTargets(config); + const { www, loaderBundle, standalone } = detectOutputTargets(config); - if (www || loaderBundle) return; + // A standalone-only project builds `standalone` in dev too, and its auto-loader is enough to test against. + if (www || loaderBundle || standalone) return; const stencilConfigPath = join(config.rootDir, 'stencil.config.ts'); if (!(await fileExists(stencilConfigPath))) { @@ -90,19 +92,19 @@ async function ensureOutputTarget(context: WizardContext): Promise { return; } - const addWww = await prompts.confirm({ - message: 'No "www" or "loader-bundle" output target found. Add a "www" output target now?', + const addLoaderBundle = await prompts.confirm({ + message: 'No "www", "loader-bundle", or "standalone" output target found. Add a "loader-bundle" output target now?', initialValue: true, }); - if (!prompts.isCancel(addWww) && addWww) { + if (!prompts.isCancel(addLoaderBundle) && addLoaderBundle) { const editor = await context.openStencilConfig(); - editor.addOutputTarget("{ type: 'www', serviceWorker: null }"); + editor.addOutputTarget("{ type: 'loader-bundle' }"); await editor.save(); return; } prompts.log.warn( - 'No "www" or "loader-bundle" output target configured - Playwright tests may not have anything to run against.', + 'No "www", "loader-bundle", or "standalone" output target configured - Playwright tests may not have anything to run against.', ); } From 2c13d80552b5aa6048b19496f896a0e41dbe53fc Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Wed, 14 Oct 2026 19:20:29 +0100 Subject: [PATCH 2/3] chore: lint --- src/load-config-meta.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/load-config-meta.ts b/src/load-config-meta.ts index 5bdf921..d08cbed 100644 --- a/src/load-config-meta.ts +++ b/src/load-config-meta.ts @@ -1,5 +1,5 @@ // @ts-ignore - position of type import changed in Stencil 5 -import { loadConfig, OutputTargetStandalone, OutputTargetWww, OutputTargetLoaderBundle } from '@stencil/core/compiler'; +import { loadConfig, OutputTargetLoaderBundle,OutputTargetStandalone, OutputTargetWww } from '@stencil/core/compiler'; import { findUp } from 'find-up'; import { isAbsolute, join, relative } from 'path'; From cd3db3c2f0acc17ecb5dbf683228502c29c975ef Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Wed, 14 Oct 2026 19:22:41 +0100 Subject: [PATCH 3/3] chore: prettier --- src/load-config-meta.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/load-config-meta.ts b/src/load-config-meta.ts index d08cbed..18303d1 100644 --- a/src/load-config-meta.ts +++ b/src/load-config-meta.ts @@ -1,5 +1,5 @@ // @ts-ignore - position of type import changed in Stencil 5 -import { loadConfig, OutputTargetLoaderBundle,OutputTargetStandalone, OutputTargetWww } from '@stencil/core/compiler'; +import { loadConfig, OutputTargetLoaderBundle, OutputTargetStandalone, OutputTargetWww } from '@stencil/core/compiler'; import { findUp } from 'find-up'; import { isAbsolute, join, relative } from 'path';