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