diff --git a/build/build.ts b/build/build.ts
index 01c6757..0f5f0a8 100644
--- a/build/build.ts
+++ b/build/build.ts
@@ -15,7 +15,14 @@ async function build() {
...baseOptions,
outfile: 'dist/index.js',
format: 'esm',
- })
+ }),
+ esbuild.build({
+ ...baseOptions,
+ entryPoints: ['src/wizard.ts'],
+ external: [...baseOptions.external!, '@stencil/cli'],
+ outfile: 'dist/wizard.js',
+ format: 'esm',
+ }),
]);
}
diff --git a/cspell.json b/cspell.json
index 3fae701..19da1ca 100644
--- a/cspell.json
+++ b/cspell.json
@@ -10,6 +10,7 @@
"networkidle",
"outfile",
"tada",
- "upvote"
+ "upvote",
+ "nypm"
]
}
diff --git a/package.json b/package.json
index 2323fb2..108a416 100644
--- a/package.json
+++ b/package.json
@@ -5,11 +5,18 @@
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+ "stencil": {
+ "wizard": "./dist/wizard.js"
+ },
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
+ },
+ "./wizard": {
+ "types": "./dist/wizard.d.ts",
+ "import": "./dist/wizard.js"
}
},
"files": [
@@ -51,13 +58,14 @@
},
"peerDependencies": {
"@playwright/test": ">=1.50.0",
- "@stencil/core": "^4.0.0 || >=5.0.0-alpha.0 <5.0.0-beta.0"
+ "@stencil/core": "^4.0.0 || ^5.0.0 || >=5.0.0-alpha.19 <5.0.0-next.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@ionic/prettier-config": "^4.0.0",
"@playwright/test": "^1.61.1",
- "@stencil/core": ">=5.0.0-alpha.0 <5.0.0-beta.0",
+ "@stencil/cli": "^5.0.0 || >=5.0.0-alpha.19 <5.0.0-next.0",
+ "@stencil/core": "^5.0.0 || >=5.0.0-alpha.19 <5.0.0-next.0",
"@types/eslint": "^9.6.1",
"@types/node": "^24.13.3",
"@typescript-eslint/eslint-plugin": "^8.63.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 14ce595..98fd39a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -27,8 +27,11 @@ importers:
'@playwright/test':
specifier: ^1.61.1
version: 1.61.1
+ '@stencil/cli':
+ specifier: ^5.0.0 || >=5.0.0-alpha.19 <5.0.0-next.0
+ version: 5.0.0-alpha.19(@stencil/core@5.0.0-alpha.19)
'@stencil/core':
- specifier: '>=5.0.0-alpha.0 <5.0.0-beta.0'
+ specifier: ^5.0.0 || >=5.0.0-alpha.19 <5.0.0-next.0
version: 5.0.0-alpha.19
'@types/eslint':
specifier: ^9.6.1
@@ -85,6 +88,18 @@ importers:
specifier: ^4.1.10
version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))
+ test-projects/wizard-playground:
+ devDependencies:
+ '@stencil/cli':
+ specifier: ^5.0.0 || >=5.0.0-alpha.19 <5.0.0-next.0
+ version: 5.0.0-alpha.19(@stencil/core@5.0.0-alpha.19)
+ '@stencil/core':
+ specifier: ^5.0.0 || >=5.0.0-alpha.19 <5.0.0-next.0
+ version: 5.0.0-alpha.19
+ '@stencil/playwright':
+ specifier: workspace:*
+ version: link:../..
+
packages:
'@clack/core@1.4.3':
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 44729ac..d031640 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,3 +1,5 @@
+packages:
+ - test-projects/wizard-playground
allowBuilds:
'@parcel/watcher': true
esbuild: true
diff --git a/src/load-config-meta.ts b/src/load-config-meta.ts
index c72adb4..f9e38c6 100644
--- a/src/load-config-meta.ts
+++ b/src/load-config-meta.ts
@@ -1,8 +1,7 @@
// @ts-ignore - position of type import changed in Stencil 5
import { loadConfig, OutputTargetWww } from '@stencil/core/compiler';
import { findUp } from 'find-up';
-import { existsSync } from 'fs';
-import { dirname, isAbsolute, join, relative } from 'path';
+import { isAbsolute, join, relative } from 'path';
/**
* Common shape for output targets with dir and buildDir properties.
@@ -39,9 +38,13 @@ export const loadConfigMeta = async (cwd?: string) => {
// This allows for the Playwright config to exist in a different directory than the Stencil config.
const stencilConfigPath = await findUp(['stencil.config.ts', 'stencil.config.js'], { cwd });
- // Only load the Stencil config if the user has created one
- if (stencilConfigPath && existsSync(stencilConfigPath)) {
- const { devServer, fsNamespace, outputTargets } = (await loadConfig({ configPath: stencilConfigPath })).config;
+ // Always attempt to load a config, even if no stencil.config.ts/.js was found - a v5 "zero-config"
+ // project has neither, and Stencil's own loadConfig() already knows how to resolve sensible
+ // defaults for that case (namespace from package.json, "loader-bundle" output target, etc).
+ const results = await loadConfig({ configPath: stencilConfigPath ?? undefined });
+
+ if (results.config && !results.diagnostics.some((d) => d.level === 'error')) {
+ const { devServer, fsNamespace, outputTargets, rootDir: resolvedRootDir } = results.config;
// Grab a suitable output target for script injection.
// Priority: www > dist (v4) > loader-bundle (v5)
@@ -52,9 +55,8 @@ export const loadConfigMeta = async (cwd?: string) => {
const loaderBundleTarget = outputTargets.find((o) => (o as OutputTargetWithDir).type === 'loader-bundle') as
OutputTargetWithDir | undefined;
- // Use stencil config directory as fallback if devServer.root is not usable
- const configDir = dirname(stencilConfigPath);
- const rootDir = devServer.root && devServer.root !== '/' ? devServer.root : configDir;
+ // Use the resolved project rootDir as fallback if devServer.root is not usable
+ const rootDir = devServer.root && devServer.root !== '/' ? devServer.root : resolvedRootDir;
if (wwwTarget) {
// Get path from dev-server root to www
@@ -104,8 +106,8 @@ export const loadConfigMeta = async (cwd?: string) => {
stencilNamespace = fsNamespace;
} else {
const msg = stencilConfigPath
- ? `Unable to find your project's Stencil configuration file, starting from '${stencilConfigPath}'. Falling back to defaults.`
- : `No Stencil config file was found matching the glob 'stencil.config.{ts,js}' in the current or parent directories. Falling back to defaults.`;
+ ? `Unable to load your project's Stencil configuration file at '${stencilConfigPath}'. Falling back to defaults.`
+ : `Unable to resolve a Stencil configuration for this project. Falling back to defaults.`;
console.warn(msg);
}
diff --git a/src/test/load-config-meta.spec.ts b/src/test/load-config-meta.spec.ts
index f4cba4d..bb9e40b 100644
--- a/src/test/load-config-meta.spec.ts
+++ b/src/test/load-config-meta.spec.ts
@@ -2,12 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { loadConfigMeta } from '../load-config-meta';
-const existsSyncMock = vi.fn();
-vi.mock('fs', () => ({
- existsSync: () => existsSyncMock(),
-}));
-
const stencilConfig: {
+ rootDir: string;
fsNamespace: string;
devServer: {
protocol: string;
@@ -18,6 +14,7 @@ const stencilConfig: {
};
outputTargets: Array<{ type: string; dir: string; buildDir?: string }>;
} = {
+ rootDir: '/mock-path',
fsNamespace: 'mock-namespace',
devServer: {
protocol: 'http',
@@ -39,25 +36,30 @@ vi.mock('find-up', () => ({
findUp: () => findUpMock(),
}));
+const loadConfigMock = vi.fn();
vi.mock('@stencil/core/compiler', () => ({
- loadConfig: () => ({
- config: stencilConfig,
- }),
+ loadConfig: (...args: unknown[]) => loadConfigMock(...args),
}));
describe('loadConfigMeta', () => {
beforeEach(() => {
vi.resetAllMocks();
+ // Most tests just want a successfully-resolved config - override per-test for failure cases.
+ loadConfigMock.mockResolvedValue({ config: stencilConfig, diagnostics: [] });
});
- it('should return defaults if a config does not exist', async () => {
+ it('should fall back to defaults when the resolved config has an error diagnostic', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
findUpMock.mockResolvedValueOnce('/mock-path/stencil.config.ts');
+ loadConfigMock.mockResolvedValueOnce({
+ config: null,
+ diagnostics: [{ level: 'error', messageText: 'boom' }],
+ });
const configMeta = await loadConfigMeta();
expect(consoleWarnSpy).toHaveBeenCalledWith(
- "Unable to find your project's Stencil configuration file, starting from '/mock-path/stencil.config.ts'. Falling back to defaults.",
+ "Unable to load your project's Stencil configuration file at '/mock-path/stencil.config.ts'. Falling back to defaults.",
);
expect(configMeta).toEqual({
baseURL: 'http://localhost:3333',
@@ -68,7 +70,6 @@ describe('loadConfigMeta', () => {
});
it('should use the validated Stencil config values', async () => {
- existsSyncMock.mockReturnValueOnce(true);
findUpMock.mockResolvedValueOnce('/mock-path/stencil.config.ts');
const configMeta = await loadConfigMeta();
@@ -81,9 +82,41 @@ describe('loadConfigMeta', () => {
});
});
+ it('should resolve "zero-config" (v5) defaults when no stencil.config.ts/.js is found', async () => {
+ // No config file on disk - findUp comes back empty, but loadConfig() still resolves
+ // (namespace from package.json, default "loader-bundle" output target, etc).
+ findUpMock.mockResolvedValueOnce(undefined);
+
+ const configMeta = await loadConfigMeta();
+
+ expect(configMeta).toEqual({
+ baseURL: 'http://localhost:4444',
+ stencilEntryPath: './www/build/mock-namespace',
+ stencilNamespace: 'mock-namespace',
+ webServerUrl: 'http://localhost:4444/status',
+ });
+ });
+
+ it('should log a warning if no Stencil configuration could be resolved at all', async () => {
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ findUpMock.mockResolvedValueOnce(undefined);
+ loadConfigMock.mockResolvedValueOnce({ config: null, diagnostics: [] });
+
+ const configMeta = await loadConfigMeta();
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ 'Unable to resolve a Stencil configuration for this project. Falling back to defaults.',
+ );
+ expect(configMeta).toEqual({
+ baseURL: 'http://localhost:3333',
+ stencilEntryPath: './build/app',
+ stencilNamespace: 'app',
+ webServerUrl: 'http://localhost:3333/ping',
+ });
+ });
+
it('should log a warning if no supported output target is found', async () => {
stencilConfig.outputTargets = [];
- existsSyncMock.mockReturnValueOnce(true);
findUpMock.mockResolvedValueOnce('/mock-path/stencil.config.ts');
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
@@ -108,7 +141,6 @@ describe('loadConfigMeta', () => {
buildDir: 'custom-build',
},
];
- existsSyncMock.mockReturnValueOnce(true);
findUpMock.mockResolvedValueOnce('/mock-path/stencil.config.ts');
const configMeta = await loadConfigMeta();
@@ -128,7 +160,6 @@ describe('loadConfigMeta', () => {
dir: '/mock-path/dist',
},
];
- existsSyncMock.mockReturnValueOnce(true);
findUpMock.mockResolvedValueOnce('/mock-path/stencil.config.ts');
const configMeta = await loadConfigMeta();
@@ -148,7 +179,6 @@ describe('loadConfigMeta', () => {
dir: '/mock-path/dist/loader-bundle',
},
];
- existsSyncMock.mockReturnValueOnce(true);
findUpMock.mockResolvedValueOnce('/mock-path/stencil.config.ts');
const configMeta = await loadConfigMeta();
@@ -176,7 +206,6 @@ describe('loadConfigMeta', () => {
dir: '/mock-path/dist/loader-bundle',
},
];
- existsSyncMock.mockReturnValueOnce(true);
findUpMock.mockResolvedValueOnce('/mock-path/stencil.config.ts');
const configMeta = await loadConfigMeta();
@@ -200,7 +229,6 @@ describe('loadConfigMeta', () => {
dir: '/mock-path/dist',
},
];
- existsSyncMock.mockReturnValueOnce(true);
findUpMock.mockResolvedValueOnce('/mock-path/stencil.config.ts');
const configMeta = await loadConfigMeta();
@@ -220,7 +248,6 @@ describe('loadConfigMeta', () => {
dir: 'dist/loader-bundle', // relative path, not absolute
},
];
- existsSyncMock.mockReturnValueOnce(true);
findUpMock.mockResolvedValueOnce('/mock-path/stencil.config.ts');
const configMeta = await loadConfigMeta();
@@ -232,20 +259,4 @@ describe('loadConfigMeta', () => {
webServerUrl: 'http://localhost:4444/status',
});
});
-
- it('should log a warning if no Stencil config path was found', async () => {
- const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
-
- const configMeta = await loadConfigMeta();
-
- expect(consoleWarnSpy).toHaveBeenCalledWith(
- `No Stencil config file was found matching the glob 'stencil.config.{ts,js}' in the current or parent directories. Falling back to defaults.`,
- );
- expect(configMeta).toEqual({
- baseURL: 'http://localhost:3333',
- stencilEntryPath: './build/app',
- stencilNamespace: 'app',
- webServerUrl: 'http://localhost:3333/ping',
- });
- });
});
diff --git a/src/test/wizard.spec.ts b/src/test/wizard.spec.ts
new file mode 100644
index 0000000..4b031e9
--- /dev/null
+++ b/src/test/wizard.spec.ts
@@ -0,0 +1,266 @@
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { wizard } from '../wizard';
+
+function makeTmpDir() {
+ return mkdtempSync(join(tmpdir(), 'wizard-test-'));
+}
+
+const noCancel = () => false;
+
+function makeSpinner() {
+ return { start: vi.fn(), stop: vi.fn() };
+}
+
+function makeInitCtx(
+ rootDir: string,
+ outputTargets: Array<{ type: string; copy?: Array<{ src?: string }> }> = [],
+ isNewProject = false,
+) {
+ return {
+ config: { rootDir, fsNamespace: 'my-lib', outputTargets },
+ isNewProject,
+ nypm: { addDependency: vi.fn().mockResolvedValue(undefined) },
+ openStencilConfig: vi.fn(),
+ prompts: {
+ intro: vi.fn(),
+ outro: vi.fn(),
+ confirm: vi.fn(),
+ isCancel: noCancel,
+ cancel: vi.fn(),
+ spinner: vi.fn().mockReturnValue(makeSpinner()),
+ log: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
+ },
+ };
+}
+
+describe('wizard.generate.fileTemplates', () => {
+ it('offers a single e2e.ts template, selected by default', () => {
+ const templates = [...(wizard.generate!.fileTemplates as any)];
+
+ expect(templates).toHaveLength(1);
+ expect(templates[0].extension).toBe('e2e.ts');
+ expect(templates[0].selectedByDefault).toBe(true);
+ });
+
+ it('generated template uses setContent and the tag name', () => {
+ const templates = [...(wizard.generate!.fileTemplates as any)];
+ const content = templates[0].template('my-button');
+
+ expect(content).toContain("describe('my-button'");
+ expect(content).toContain("page.setContent('')");
+ expect(content).toContain("page.locator('my-button')");
+ });
+});
+
+describe('wizard.init.run', () => {
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = makeTmpDir();
+ writeFileSync(join(tmpDir, 'package.json'), JSON.stringify({ name: 'test-lib', scripts: {} }, null, 2) + '\n');
+ });
+
+ afterEach(() => {
+ rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it('cancels when existing config exists and user declines overwrite', async () => {
+ writeFileSync(join(tmpDir, 'playwright.config.ts'), 'export default {};\n');
+
+ const ctx = makeInitCtx(tmpDir);
+ ctx.prompts.confirm.mockResolvedValueOnce(false); // decline overwrite
+
+ await wizard.init!.run(ctx as any);
+
+ expect(ctx.prompts.cancel).toHaveBeenCalledWith('Skipping Playwright setup - existing config kept.');
+ expect(readFileSync(join(tmpDir, 'playwright.config.ts'), 'utf8')).toBe('export default {};\n');
+ expect(ctx.nypm.addDependency).not.toHaveBeenCalled();
+ });
+
+ it('proceeds and overwrites when user accepts overwrite', async () => {
+ writeFileSync(join(tmpDir, 'playwright.config.ts'), 'export default {};\n');
+
+ const ctx = makeInitCtx(tmpDir, [{ type: 'www', copy: [{ src: '**/*.html' }, { src: '**/*.css' }] }]);
+ ctx.prompts.confirm.mockResolvedValueOnce(true); // accept overwrite
+
+ await wizard.init!.run(ctx as any);
+
+ expect(ctx.prompts.cancel).not.toHaveBeenCalled();
+ const config = readFileSync(join(tmpDir, 'playwright.config.ts'), 'utf8');
+ expect(config).toContain('createConfig');
+ });
+
+ it('installs @playwright/test as a dev dependency', async () => {
+ const ctx = makeInitCtx(tmpDir, [{ type: 'www', copy: [{ src: '**/*.html' }, { src: '**/*.css' }] }]);
+
+ await wizard.init!.run(ctx as any);
+
+ expect(ctx.nypm.addDependency).toHaveBeenCalledWith(['@playwright/test'], { cwd: tmpDir, dev: true });
+ });
+
+ it('writes playwright.config.ts with the expected boilerplate', async () => {
+ const ctx = makeInitCtx(tmpDir, [{ type: 'www', copy: [{ src: '**/*.html' }, { src: '**/*.css' }] }]);
+
+ await wizard.init!.run(ctx as any);
+
+ const config = readFileSync(join(tmpDir, 'playwright.config.ts'), 'utf8');
+ expect(config).toContain("import { matchers, createConfig } from '@stencil/playwright'");
+ expect(config).toContain('expect.extend(matchers)');
+ expect(config).toContain('export default createConfig(');
+ });
+
+ it('adds ESNext.Disposable to an existing tsconfig.json lib array', async () => {
+ writeFileSync(
+ join(tmpDir, 'tsconfig.json'),
+ JSON.stringify({ compilerOptions: { lib: ['ES2022', 'DOM'] } }, null, 2) + '\n',
+ );
+ const ctx = makeInitCtx(tmpDir, [{ type: 'www', copy: [{ src: '**/*.html' }, { src: '**/*.css' }] }]);
+
+ await wizard.init!.run(ctx as any);
+
+ const tsconfig = JSON.parse(readFileSync(join(tmpDir, 'tsconfig.json'), 'utf8'));
+ expect(tsconfig.compilerOptions.lib).toEqual(['ES2022', 'DOM', 'ESNext.Disposable']);
+ });
+
+ it('does not duplicate ESNext.Disposable if already present', async () => {
+ writeFileSync(
+ join(tmpDir, 'tsconfig.json'),
+ JSON.stringify({ compilerOptions: { lib: ['ES2022', 'ESNext.Disposable'] } }, null, 2) + '\n',
+ );
+ const ctx = makeInitCtx(tmpDir, [{ type: 'www', copy: [{ src: '**/*.html' }, { src: '**/*.css' }] }]);
+
+ await wizard.init!.run(ctx as any);
+
+ const tsconfig = JSON.parse(readFileSync(join(tmpDir, 'tsconfig.json'), 'utf8'));
+ expect(tsconfig.compilerOptions.lib).toEqual(['ES2022', 'ESNext.Disposable']);
+ });
+
+ it('skips tsconfig editing when no tsconfig.json exists', async () => {
+ const ctx = makeInitCtx(tmpDir, [{ type: 'www', copy: [{ src: '**/*.html' }, { src: '**/*.css' }] }]);
+
+ await wizard.init!.run(ctx as any);
+
+ expect(existsSync(join(tmpDir, 'tsconfig.json'))).toBe(false);
+ });
+
+ it('does not warn when no output target is configured and no stencil.config.ts exists (zero-config v5)', async () => {
+ // No stencil.config.ts on disk - the compiler defaults to "loader-bundle" automatically,
+ // so there's nothing to warn about or offer to fix.
+ const ctx = makeInitCtx(tmpDir, []);
+
+ await wizard.init!.run(ctx as any);
+
+ expect(ctx.prompts.log.warn).not.toHaveBeenCalled();
+ expect(ctx.openStencilConfig).not.toHaveBeenCalled();
+ });
+
+ it('offers to add a www 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) };
+ ctx.openStencilConfig.mockResolvedValue(editor);
+ ctx.prompts.confirm.mockResolvedValueOnce(true); // accept adding output target
+
+ await wizard.init!.run(ctx as any);
+
+ expect(editor.addOutputTarget).toHaveBeenCalledWith(expect.stringContaining("type: 'www'"));
+ expect(editor.save).toHaveBeenCalled();
+ });
+
+ it('warns when stencil.config.ts exists but has no usable output target and the user declines', async () => {
+ writeFileSync(join(tmpDir, 'stencil.config.ts'), 'export const config = {};\n');
+ const ctx = makeInitCtx(tmpDir, []);
+ ctx.prompts.confirm.mockResolvedValueOnce(false); // decline adding output target
+
+ await wizard.init!.run(ctx as any);
+
+ expect(ctx.openStencilConfig).not.toHaveBeenCalled();
+ expect(ctx.prompts.log.warn).toHaveBeenCalledWith(
+ expect.stringContaining('No "www" or "loader-bundle" output target configured'),
+ );
+ });
+
+ it('warns about a missing copy config on an existing www target', async () => {
+ const ctx = makeInitCtx(tmpDir, [{ type: 'www' }]);
+
+ await wizard.init!.run(ctx as any);
+
+ expect(ctx.prompts.log.warn).toHaveBeenCalledWith(expect.stringContaining('no "copy" config'));
+ });
+
+ it('does not warn when the www target already has a suitable copy config', async () => {
+ const ctx = makeInitCtx(tmpDir, [{ type: 'www', copy: [{ src: '**/*.html' }, { src: '**/*.css' }] }]);
+
+ await wizard.init!.run(ctx as any);
+
+ expect(ctx.prompts.log.warn).not.toHaveBeenCalled();
+ });
+
+ it('writes a test:e2e script without touching an existing test script', async () => {
+ writeFileSync(
+ join(tmpDir, 'package.json'),
+ JSON.stringify({ name: 'test-lib', scripts: { test: 'my-custom-test' } }, null, 2) + '\n',
+ );
+ const ctx = makeInitCtx(tmpDir, [{ type: 'www', copy: [{ src: '**/*.html' }, { src: '**/*.css' }] }]);
+
+ await wizard.init!.run(ctx as any);
+
+ const pkg = JSON.parse(readFileSync(join(tmpDir, 'package.json'), 'utf8'));
+ expect(pkg.scripts.test).toBe('my-custom-test');
+ expect(pkg.scripts['test:e2e']).toBe('playwright test');
+ });
+
+ it('does not add a bare "test" script', async () => {
+ const ctx = makeInitCtx(tmpDir, [{ type: 'www', copy: [{ src: '**/*.html' }, { src: '**/*.css' }] }]);
+
+ await wizard.init!.run(ctx as any);
+
+ const pkg = JSON.parse(readFileSync(join(tmpDir, 'package.json'), 'utf8'));
+ expect(pkg.scripts.test).toBeUndefined();
+ expect(pkg.scripts['test:e2e']).toBe('playwright test');
+ });
+
+ it('generates example e2e spec files for components when isNewProject is true', async () => {
+ const componentDir = join(tmpDir, 'src', 'components', 'my-button');
+ mkdirSync(componentDir, { recursive: true });
+ writeFileSync(
+ join(componentDir, 'my-button.tsx'),
+ `import { Component, h } from '@stencil/core';
+@Component({ tag: 'my-button', shadow: true })
+export class MyButton { render() { return ; } }
+`,
+ );
+
+ const ctx = makeInitCtx(tmpDir, [{ type: 'www', copy: [{ src: '**/*.html' }, { src: '**/*.css' }] }], true);
+
+ await wizard.init!.run(ctx as any);
+
+ const specFile = join(componentDir, 'my-button.e2e.ts');
+ expect(existsSync(specFile)).toBe(true);
+ const spec = readFileSync(specFile, 'utf8');
+ expect(spec).toContain("describe('my-button'");
+ });
+
+ it('skips example test generation when isNewProject is false', async () => {
+ const componentDir = join(tmpDir, 'src', 'components', 'my-button');
+ mkdirSync(componentDir, { recursive: true });
+ writeFileSync(
+ join(componentDir, 'my-button.tsx'),
+ `import { Component, h } from '@stencil/core';
+@Component({ tag: 'my-button', shadow: true })
+export class MyButton { render() { return ; } }
+`,
+ );
+
+ const ctx = makeInitCtx(tmpDir, [{ type: 'www', copy: [{ src: '**/*.html' }, { src: '**/*.css' }] }], false);
+
+ await wizard.init!.run(ctx as any);
+
+ expect(existsSync(join(componentDir, 'my-button.e2e.ts'))).toBe(false);
+ });
+});
diff --git a/src/wizard.ts b/src/wizard.ts
new file mode 100644
index 0000000..f8aa423
--- /dev/null
+++ b/src/wizard.ts
@@ -0,0 +1,230 @@
+import { access, readdir, readFile, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+
+import type { ProjectConfig, StencilWizardPlugin, WizardContext } from '@stencil/cli';
+
+interface OutputTargetWithCopy {
+ type: string;
+ copy?: ReadonlyArray<{ src?: string }>;
+}
+
+async function fileExists(path: string): Promise {
+ try {
+ await access(path);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Looks for a "www" or "loader-bundle" output target.
+ * @param config The resolved Stencil project config.
+ * @returns The matching "www" and/or "loader-bundle" output targets, if present.
+ */
+function detectOutputTargets(config: ProjectConfig) {
+ const outputs = config.outputTargets as ReadonlyArray;
+ const www = outputs.find((o) => o.type === 'www');
+ const loaderBundle = outputs.find((o) => o.type === 'loader-bundle');
+ return { www, loaderBundle };
+}
+
+/**
+ * Checks a "www" output target's `copy` config for HTML/CSS globs, needed for the `page.goto()` pattern.
+ * @param www The "www" output target to check.
+ * @returns `true` if the target's `copy` config includes both an HTML and a CSS glob.
+ */
+function hasHtmlCssCopy(www: OutputTargetWithCopy): boolean {
+ const copy = www.copy ?? [];
+ return copy.some((c) => c.src?.endsWith('.html')) && copy.some((c) => c.src?.endsWith('.css'));
+}
+
+const PLAYWRIGHT_CONFIG_TEMPLATE = `import { expect } from '@playwright/test';
+import { matchers, createConfig } from '@stencil/playwright';
+
+// Add custom Stencil matchers to Playwright assertions
+expect.extend(matchers);
+
+export default createConfig({
+ // Overwrite Playwright config options here
+});
+`;
+
+function e2eSpecTemplate(tagName: string): string {
+ return `import { expect } from '@playwright/test';
+import { test } from '@stencil/playwright';
+
+test.describe('${tagName}', () => {
+ test('renders', async ({ page }) => {
+ await page.setContent('<${tagName}>${tagName}>');
+
+ const el = page.locator('${tagName}');
+ await expect(el).toBeAttached();
+ });
+});
+`;
+}
+
+/**
+ * Adds "ESNext.Disposable" to tsconfig.json's `lib` array, required for the dev server build.
+ * @param rootDir Absolute path to the project root.
+ */
+async function ensureDisposableLib(rootDir: string): Promise {
+ const tsconfigPath = join(rootDir, 'tsconfig.json');
+ if (!(await fileExists(tsconfigPath))) return;
+
+ try {
+ const tsconfig = JSON.parse(await readFile(tsconfigPath, 'utf8')) as {
+ compilerOptions?: { lib?: string[] };
+ };
+ const lib = tsconfig.compilerOptions?.lib ?? [];
+ if (lib.some((l) => l.toLowerCase() === 'esnext.disposable')) return;
+
+ tsconfig.compilerOptions ??= {};
+ tsconfig.compilerOptions.lib = [...lib, 'ESNext.Disposable'];
+ await writeFile(tsconfigPath, JSON.stringify(tsconfig, null, 2) + '\n', 'utf8');
+ } catch {
+ // Malformed / unreadable tsconfig.json - leave it for the user to update manually.
+ }
+}
+
+/**
+ * Warns or offers to add a suitable output target, since Playwright tests run against compiled output.
+ * @param context The wizard context for the current `stencil init` run.
+ */
+async function ensureOutputTarget(context: WizardContext): Promise {
+ const { config, prompts } = context;
+ const { www, loaderBundle } = detectOutputTargets(config);
+
+ if (www) {
+ if (!hasHtmlCssCopy(www)) {
+ prompts.log.warn(
+ 'The "www" output target has no "copy" config for HTML/CSS files.\n' +
+ "Add copy: [{ src: '**/*.html' }, { src: '**/*.css' }] to use the page.goto() testing pattern.",
+ );
+ }
+ return;
+ }
+
+ if (loaderBundle) return;
+
+ const stencilConfigPath = join(config.rootDir, 'stencil.config.ts');
+ if (!(await fileExists(stencilConfigPath))) {
+ // Zero-config (v5): no stencil.config.ts means the compiler falls back to a
+ // "loader-bundle" output target automatically - nothing to configure here.
+ return;
+ }
+
+ const addWww = await prompts.confirm({
+ message: 'No "www" or "loader-bundle" output target found. Add a "www" output target now?',
+ initialValue: true,
+ });
+ if (!prompts.isCancel(addWww) && addWww) {
+ const editor = await context.openStencilConfig();
+ editor.addOutputTarget("{ type: 'www', serviceWorker: null, copy: [{ src: '**/*.html' }, { src: '**/*.css' }] }");
+ await editor.save();
+ return;
+ }
+
+ prompts.log.warn(
+ 'No "www" or "loader-bundle" output target configured - Playwright tests may not have anything to run against.',
+ );
+}
+
+/**
+ * Adds a "test:e2e" script to package.json.
+ * @param rootDir Absolute path to the project root.
+ */
+async function updatePackageJsonScripts(rootDir: string): Promise {
+ const pkgPath = join(rootDir, 'package.json');
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8')) as Record;
+
+ pkg.scripts ??= {};
+ pkg.scripts['test:e2e'] ??= 'playwright test';
+
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
+}
+
+/**
+ * Writes a starter `.e2e.ts` spec alongside each existing component, for new projects.
+ * @param rootDir Absolute path to the project root.
+ */
+async function generateExampleTests(rootDir: string): Promise {
+ const componentsDir = join(rootDir, 'src', 'components');
+ const entries = await readdir(componentsDir, { withFileTypes: true }).catch(() => []);
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ const componentFile = join(componentsDir, entry.name, `${entry.name}.tsx`);
+ if (!(await fileExists(componentFile))) continue;
+
+ const source = await readFile(componentFile, 'utf8');
+ if (!source.includes('@Component')) continue;
+
+ const tagMatch = source.match(/tag:\s*['"]([^'"]+)['"]/);
+ const tagName = tagMatch?.[1] ?? entry.name;
+
+ const specFile = join(componentsDir, entry.name, `${tagName}.e2e.ts`);
+ if (await fileExists(specFile)) continue;
+
+ await writeFile(specFile, e2eSpecTemplate(tagName), 'utf8');
+ }
+}
+
+export const wizard: StencilWizardPlugin = {
+ init: {
+ id: '@stencil/playwright',
+ displayName: 'Playwright',
+ description: 'E2E testing',
+
+ async run(context: WizardContext): Promise {
+ const { config, isNewProject, prompts, nypm } = context;
+ const { intro, outro, confirm, isCancel, cancel, spinner } = prompts;
+ const rootDir = config.rootDir;
+
+ intro('Playwright - E2E testing for Stencil');
+
+ const playwrightConfigPath = join(rootDir, 'playwright.config.ts');
+
+ if (!isNewProject && (await fileExists(playwrightConfigPath))) {
+ const overwrite = await confirm({
+ message: 'playwright.config.ts already exists. Overwrite it?',
+ initialValue: false,
+ });
+ if (isCancel(overwrite) || !overwrite) {
+ cancel('Skipping Playwright setup - existing config kept.');
+ return;
+ }
+ }
+
+ const s = spinner();
+ s.start('Installing dependencies');
+ await nypm.addDependency(['@playwright/test'], { cwd: rootDir, dev: true });
+ s.stop('Dependencies installed');
+
+ await writeFile(playwrightConfigPath, PLAYWRIGHT_CONFIG_TEMPLATE, 'utf8');
+ await ensureDisposableLib(rootDir);
+ await ensureOutputTarget(context);
+ await updatePackageJsonScripts(rootDir);
+
+ if (isNewProject) {
+ await generateExampleTests(rootDir);
+ }
+
+ prompts.log.info('Run "npx playwright install" to download the browser binaries before running tests.');
+
+ outro('Playwright configured');
+ },
+ },
+
+ generate: {
+ fileTemplates: [
+ {
+ label: 'E2E test (.e2e.ts)',
+ extension: 'e2e.ts',
+ selectedByDefault: true,
+ template: (tagName: string) => e2eSpecTemplate(tagName),
+ },
+ ],
+ },
+};
diff --git a/test-project/package-lock.json b/test-project/package-lock.json
deleted file mode 100644
index c629133..0000000
--- a/test-project/package-lock.json
+++ /dev/null
@@ -1,149 +0,0 @@
-{
- "name": "test-stencil-project",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "test-stencil-project",
- "devDependencies": {
- "@stencil/core": "^4.0.0"
- }
- },
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.44.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz",
- "integrity": "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.44.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.0.tgz",
- "integrity": "sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.44.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.0.tgz",
- "integrity": "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.44.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.0.tgz",
- "integrity": "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.44.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.0.tgz",
- "integrity": "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.44.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.0.tgz",
- "integrity": "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.44.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.0.tgz",
- "integrity": "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.44.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.0.tgz",
- "integrity": "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@stencil/core": {
- "version": "4.43.4",
- "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.43.4.tgz",
- "integrity": "sha512-QWawMM1XIpSz4k+k+VyHZMr2YSxlCNAPWO/jTdJ+2kdgdN7ZQVEFZpc4WBm3E3mrDPTZ79lLcnIPa399bg4XOg==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "stencil": "bin/stencil"
- },
- "engines": {
- "node": ">=16.0.0",
- "npm": ">=7.10.0"
- },
- "optionalDependencies": {
- "@rollup/rollup-darwin-arm64": "4.44.0",
- "@rollup/rollup-darwin-x64": "4.44.0",
- "@rollup/rollup-linux-arm64-gnu": "4.44.0",
- "@rollup/rollup-linux-arm64-musl": "4.44.0",
- "@rollup/rollup-linux-x64-gnu": "4.44.0",
- "@rollup/rollup-linux-x64-musl": "4.44.0",
- "@rollup/rollup-win32-arm64-msvc": "4.44.0",
- "@rollup/rollup-win32-x64-msvc": "4.44.0"
- }
- }
- }
-}
diff --git a/test-project/package.json b/test-project/package.json
deleted file mode 100644
index 827bccb..0000000
--- a/test-project/package.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "name": "test-stencil-project",
- "private": true,
- "type": "module",
- "devDependencies": {
- "@stencil/core": "^4.0.0"
- }
-}
diff --git a/test-project/stencil.config.ts b/test-project/stencil.config.ts
deleted file mode 100644
index e9194c7..0000000
--- a/test-project/stencil.config.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { Config } from '@stencil/core';
-
-export const config: Config = {
- namespace: 'TestAssetsGlobalStyle',
- outputTargets: [
- {
- type: 'dist',
- dir: 'dist/loader-bundle',
- },
- ],
-};
diff --git a/test-project/test-config.mjs b/test-project/test-config.mjs
deleted file mode 100644
index 3778c8f..0000000
--- a/test-project/test-config.mjs
+++ /dev/null
@@ -1,9 +0,0 @@
-import { createConfig } from '../dist/index.js';
-
-try {
- const config = await createConfig();
- console.log('stencilEntryPath:', process.env.STENCIL_ENTRY_PATH);
- console.log('Config created successfully');
-} catch (e) {
- console.error('Error:', e.message);
-}
diff --git a/test-projects/wizard-playground/package.json b/test-projects/wizard-playground/package.json
new file mode 100644
index 0000000..f5b99d0
--- /dev/null
+++ b/test-projects/wizard-playground/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "wizard-playground",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "init": "STENCIL_WIZARD_DEV=../../dist/wizard.js stencil init",
+ "add": "STENCIL_WIZARD_DEV=../../dist/wizard.js stencil add",
+ "generate": "STENCIL_WIZARD_DEV=../../dist/wizard.js stencil generate"
+ },
+ "devDependencies": {
+ "@stencil/cli": "^5.0.0 || >=5.0.0-alpha.19 <5.0.0-next.0",
+ "@stencil/core": "^5.0.0 || >=5.0.0-alpha.19 <5.0.0-next.0",
+ "@stencil/playwright": "workspace:*"
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
index 25bdec7..432be24 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -21,5 +21,5 @@
"target": "es2022",
"types": ["node"]
},
- "files": ["src/index.ts"]
+ "files": ["src/index.ts", "src/wizard.ts"]
}