diff --git a/README.md b/README.md index 80e134d..fc1d062 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ First-class testing utilities for Stencil components, powered by Vitest. - [Stencil Vitest Plugin (Experimental)](#stencil-vitest-plugin) - [Setup](#setup) - [Mocking component dependencies](#mocking-component-dependencies) + - [`resolveVar()` with imported constants](#resolvevar-with-imported-constants) - [Limitations](#limitations) - [Snapshots](#snapshots) - [Screenshot Testing](#screenshot-testing) @@ -554,6 +555,28 @@ it('renders using the mocked utility', async () => { }); ``` +### `resolveVar()` with imported constants + +Stencil's `resolveVar()` lets `@Event` and `@Listen` read their event names from a constant instead of a hardcoded string. Under `stencil build` that constant can live anywhere - including a shared package - because the whole project is type-checked together. The `transpile()` call this plugin relies on only sees one file at a time, so it cannot follow an import on its own. + +The plugin closes that gap: before handing a component to Stencil it resolves any `resolveVar()` argument that is rooted at an `import` (honouring your `tsconfig.json` `paths` / `moduleResolution`) and inlines the string literal. Same-file constants are left for Stencil to handle as usual. + +```tsx +import { Component, Event, EventEmitter, Listen, resolveVar } from '@stencil/core'; +import { EVENTS } from '@my-org/design-tokens'; + +@Component({ tag: 'my-emitter', shadow: true }) +export class MyEmitter { + // ✅ works under both `stencil build` and the plugin + @Event({ eventName: resolveVar(EVENTS.MY_EMITTER.PING_EVENT) }) ping: EventEmitter; + + @Listen(resolveVar(EVENTS.MY_EMITTER.PONG_EVENT)) + onPong() {} +} +``` + +The argument must still resolve to a string literal (`as const` catalogs, or a plain `'literal'` initializer). Anything else is passed through unchanged and Stencil reports its usual `resolveVar()` diagnostic. + ### Limitations #### Class inheritance diff --git a/src/__tests__/resolve-var.spec.ts b/src/__tests__/resolve-var.spec.ts new file mode 100644 index 0000000..f3f1c70 --- /dev/null +++ b/src/__tests__/resolve-var.spec.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { inlineImportedResolveVars } from '../resolve-var.js'; + +let root: string; + +function write(relPath: string, contents: string): string { + const absPath = join(root, relPath); + mkdirSync(join(absPath, '..'), { recursive: true }); + writeFileSync(absPath, contents); + return absPath; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'resolve-var-test-')); + write( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { + experimentalDecorators: true, + jsx: 'react', + jsxFactory: 'h', + module: 'esnext', + moduleResolution: 'bundler', + target: 'es2017', + baseUrl: '.', + paths: { '@catalog': ['src/catalog/index.ts'] }, + }, + include: ['src'], + }), + ); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe('inlineImportedResolveVars', () => { + it('leaves source untouched when there is no resolveVar() call', () => { + const code = `import { Component } from '@stencil/core';\n@Component({ tag: 'my-cmp' }) export class Cmp {}`; + const file = write('src/cmp.tsx', code); + expect(inlineImportedResolveVars(code, file)).toBe(code); + }); + + it('leaves same-file constants to the Stencil compiler', () => { + const code = [ + `import { Component, Event, Listen, resolveVar } from '@stencil/core';`, + `const MY_EVENT = 'myEvent';`, + `const EVENTS = { OTHER: 'otherEvent' } as const;`, + `@Component({ tag: 'my-cmp' })`, + `export class Cmp {`, + ` @Event({ eventName: resolveVar(MY_EVENT) }) myEvent;`, + ` @Listen(resolveVar(EVENTS.OTHER)) onOther() {}`, + `}`, + ].join('\n'); + const file = write('src/cmp.tsx', code); + expect(inlineImportedResolveVars(code, file)).toBe(code); + }); + + it('inlines a nested property of an imported `as const` catalog', () => { + write( + 'src/events.ts', + `export const EVENTS = { LOCATION: { CHANGE_EVENT: 'locationChange', CLEAR_EVENT: 'locationClear' } } as const;`, + ); + const code = [ + `import { Component, Event, Listen, resolveVar } from '@stencil/core';`, + `import { EVENTS } from './events';`, + `@Component({ tag: 'my-cmp' })`, + `export class Cmp {`, + ` @Event({ eventName: resolveVar(EVENTS.LOCATION.CHANGE_EVENT), bubbles: true }) change;`, + ` @Listen(resolveVar(EVENTS.LOCATION.CLEAR_EVENT))`, + ` onClear() {}`, + `}`, + ].join('\n'); + const file = write('src/cmp.tsx', code); + + const out = inlineImportedResolveVars(code, file); + + expect(out).toContain(`@Event({ eventName: "locationChange", bubbles: true }) change;`); + expect(out).toContain(`@Listen("locationClear")`); + expect(out).not.toContain('resolveVar('); + // The import itself is preserved for any runtime usage of the catalog. + expect(out).toContain(`import { EVENTS } from './events';`); + }); + + it('inlines a plain imported string const', () => { + write('src/events.ts', `export const MY_EVENT = 'myEvent';`); + const code = [ + `import { Component, Listen, resolveVar } from '@stencil/core';`, + `import { MY_EVENT } from './events';`, + `@Component({ tag: 'my-cmp' })`, + `export class Cmp { @Listen(resolveVar(MY_EVENT)) onIt() {} }`, + ].join('\n'); + const file = write('src/cmp.tsx', code); + + expect(inlineImportedResolveVars(code, file)).toContain(`@Listen("myEvent")`); + }); + + it('resolves through a tsconfig `paths` alias and a re-exporting barrel', () => { + write( + 'src/catalog/base.events.ts', + `export const BASE_EVENTS = { BUTTON: { CLICK_EVENT: 'buttonClick' } } as const;`, + ); + write( + 'src/catalog/index.ts', + [`import { BASE_EVENTS } from './base.events';`, `export const EVENTS = { ...BASE_EVENTS } as const;`].join('\n'), + ); + const code = [ + `import { Component, Event, resolveVar } from '@stencil/core';`, + `import { EVENTS } from '@catalog';`, + `@Component({ tag: 'my-cmp' })`, + `export class Cmp { @Event({ eventName: resolveVar(EVENTS.BUTTON.CLICK_EVENT) }) click; }`, + ].join('\n'); + const file = write('src/cmp.tsx', code); + + expect(inlineImportedResolveVars(code, file)).toContain(`@Event({ eventName: "buttonClick" }) click;`); + }); + + it('falls back to the declaration initializer when the catalog is not `as const`', () => { + write('src/events.ts', `export const EVENTS = { MY_EVENT: 'myEvent' };`); + const code = [ + `import { Component, Listen, resolveVar } from '@stencil/core';`, + `import { EVENTS } from './events';`, + `@Component({ tag: 'my-cmp' })`, + `export class Cmp { @Listen(resolveVar(EVENTS.MY_EVENT)) onIt() {} }`, + ].join('\n'); + const file = write('src/cmp.tsx', code); + + expect(inlineImportedResolveVars(code, file)).toContain(`@Listen("myEvent")`); + }); + + it('supports namespace imports', () => { + write('src/events.ts', `export const EVENTS = { MY_EVENT: 'myEvent' } as const;`); + const code = [ + `import { Component, Listen, resolveVar } from '@stencil/core';`, + `import * as catalog from './events';`, + `@Component({ tag: 'my-cmp' })`, + `export class Cmp { @Listen(resolveVar(catalog.EVENTS.MY_EVENT)) onIt() {} }`, + ].join('\n'); + const file = write('src/cmp.tsx', code); + + expect(inlineImportedResolveVars(code, file)).toContain(`@Listen("myEvent")`); + }); + + it('leaves calls that do not resolve to a string literal for Stencil to report', () => { + write('src/events.ts', `export const EVENTS: { MY_EVENT: string } = { MY_EVENT: Math.random().toString() };`); + const code = [ + `import { Component, Listen, resolveVar } from '@stencil/core';`, + `import { EVENTS } from './events';`, + `@Component({ tag: 'my-cmp' })`, + `export class Cmp { @Listen(resolveVar(EVENTS.MY_EVENT)) onIt() {} }`, + ].join('\n'); + const file = write('src/cmp.tsx', code); + + expect(inlineImportedResolveVars(code, file)).toBe(code); + }); + + it('uses the in-memory source rather than what is on disk', () => { + write('src/events.ts', `export const EVENTS = { A: 'a', B: 'b' } as const;`); + const onDisk = [ + `import { Component, Listen, resolveVar } from '@stencil/core';`, + `import { EVENTS } from './events';`, + `@Component({ tag: 'my-cmp' })`, + `export class Cmp { @Listen(resolveVar(EVENTS.A)) onIt() {} }`, + ].join('\n'); + const file = write('src/cmp.tsx', onDisk); + const inMemory = onDisk.replace('EVENTS.A', 'EVENTS.B'); + + expect(inlineImportedResolveVars(inMemory, file)).toContain(`@Listen("b")`); + }); +}); diff --git a/src/plugin.ts b/src/plugin.ts index 2431461..74594aa 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -2,6 +2,7 @@ import { transpile } from '@stencil/core/compiler'; import { existsSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import type { Plugin } from 'vitest/config'; +import { inlineImportedResolveVars } from './resolve-var.js'; // TypeScript JsxEmit enum values — stable across versions const JSX_EMIT: Record = { @@ -133,8 +134,14 @@ export function stencilVitestPlugin(opts: { css?: boolean } = {}): Plugin { } try { + // `transpile()` type-checks this one file in isolation, so `resolveVar()` cannot see + // constants imported from other modules. Inline those first so `@Event` / `@Listen` + // decorators that read event names from a shared catalog compile the same way they + // do under `stencil build`. + const source = inlineImportedResolveVars(code, id); + const jsxOpts = readJsxOptsFromTsConfig(process.cwd()); - const result = await transpile(code, { + const result = await transpile(source, { file: id, // 'customelement' appends a customElements.define() call so the component // self-registers the moment this module is imported - no loader needed. diff --git a/src/resolve-var.ts b/src/resolve-var.ts new file mode 100644 index 0000000..d9d1668 --- /dev/null +++ b/src/resolve-var.ts @@ -0,0 +1,244 @@ +import * as stencilCompiler from '@stencil/core/compiler'; +import { dirname } from 'node:path'; +import type TS from 'typescript'; + +type TypeScript = typeof TS; + +/** + * `@stencil/core/compiler` ships the TypeScript instance the Stencil compiler is built + * against. Reusing it keeps type resolution identical to `transpile()` and avoids adding + * a `typescript` dependency to this package. It is exported at runtime but not typed. + */ +const ts = (stencilCompiler as unknown as { ts: TypeScript }).ts; + +interface ResolveVarCall { + arg: TS.Identifier | TS.PropertyAccessExpression; + call: TS.CallExpression; +} + +interface TextEdit { + end: number; + start: number; + text: string; +} + +let cachedProgram: TS.Program | undefined; +let cachedOptionsKey: string | undefined; + +/** Parsed source files for everything except the file being transformed, keyed by path. */ +const sourceFileCache = new Map(); + +function normalize(fileName: string): string { + return fileName.replace(/\\/g, '/'); +} + +function isResolveVarCallee(expression: TS.Expression): boolean { + if (ts.isIdentifier(expression)) { + return expression.text === 'resolveVar'; + } + return ( + ts.isPropertyAccessExpression(expression) && + ts.isIdentifier(expression.name) && + expression.name.text === 'resolveVar' + ); +} + +function rootIdentifier(node: TS.Expression): TS.Identifier | undefined { + let current: TS.Expression = node; + while (ts.isPropertyAccessExpression(current)) { + current = current.expression; + } + return ts.isIdentifier(current) ? current : undefined; +} + +/** + * Names bound by value `import` declarations in the file. `resolveVar()` arguments rooted + * at one of these cannot be resolved by Stencil's single-file `transpile()`. + */ +function collectImportedBindings(sourceFile: TS.SourceFile): Set { + const names = new Set(); + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || !statement.importClause) continue; + const { importClause } = statement; + if (importClause.isTypeOnly) continue; + if (importClause.name) names.add(importClause.name.text); + const bindings = importClause.namedBindings; + if (!bindings) continue; + if (ts.isNamespaceImport(bindings)) { + names.add(bindings.name.text); + } else { + for (const element of bindings.elements) { + if (!element.isTypeOnly) names.add(element.name.text); + } + } + } + return names; +} + +function findImportedResolveVarCalls(sourceFile: TS.SourceFile): ResolveVarCall[] { + const imported = collectImportedBindings(sourceFile); + if (imported.size === 0) return []; + + const calls: ResolveVarCall[] = []; + const visit = (node: TS.Node): void => { + if (ts.isCallExpression(node) && node.arguments.length === 1 && isResolveVarCallee(node.expression)) { + const arg = node.arguments[0]; + if (ts.isIdentifier(arg) || ts.isPropertyAccessExpression(arg)) { + const root = rootIdentifier(arg); + if (root && imported.has(root.text)) { + calls.push({ arg, call: node }); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return calls; +} + +/** + * Compiler options for the type-check-only program. Starts from the nearest `tsconfig.json` + * so `paths`, `baseUrl` and `moduleResolution` match the project, then forces the settings + * that make module resolution work and emit irrelevant. + */ +function loadCompilerOptions(fileName: string): TS.CompilerOptions { + let projectOptions: TS.CompilerOptions = {}; + const configPath = ts.findConfigFile(dirname(fileName), ts.sys.fileExists); + if (configPath) { + const { config } = ts.readConfigFile(configPath, ts.sys.readFile); + if (config) { + projectOptions = ts.parseJsonConfigFileContent(config, ts.sys, dirname(configPath)).options; + } + } + + return { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ESNext, + ...projectOptions, + // The program exists only to answer type queries; never emit or build-info. + composite: undefined, + declaration: false, + declarationMap: false, + incremental: undefined, + inlineSourceMap: false, + isolatedModules: false, + noEmit: true, + noResolve: false, + skipLibCheck: true, + sourceMap: false, + tsBuildInfoFile: undefined, + jsx: projectOptions.jsx ?? ts.JsxEmit.Preserve, + }; +} + +function createProgram(fileName: string, code: string, options: TS.CompilerOptions): TS.Program { + const target = normalize(fileName); + const host = ts.createCompilerHost(options, true); + const getSourceFile = host.getSourceFile.bind(host); + + host.getSourceFile = (name, languageVersion, onError, shouldCreateNewSourceFile) => { + if (normalize(name) === target) { + // Use the in-memory code Vite handed us, not what is on disk. + return ts.createSourceFile(name, code, languageVersion, true, ts.ScriptKind.TSX); + } + + const mtime = ts.sys.getModifiedTime?.(name)?.getTime() ?? 0; + const cached = sourceFileCache.get(name); + if (cached && cached.mtime === mtime) { + return cached.sourceFile; + } + const sourceFile = getSourceFile(name, languageVersion, onError, shouldCreateNewSourceFile); + if (sourceFile) { + sourceFileCache.set(name, { mtime, sourceFile }); + } + return sourceFile; + }; + + const optionsKey = JSON.stringify(options); + const oldProgram = cachedOptionsKey === optionsKey ? cachedProgram : undefined; + const program = ts.createProgram({ rootNames: [fileName], options, host, oldProgram }); + cachedProgram = program; + cachedOptionsKey = optionsKey; + return program; +} + +/** + * Mirrors the compiler's fallback: when the type is widened to `string` (object declared + * without `as const`), read the string literal initializer off the declaration instead. + */ +function literalFromDeclaration(checker: TS.TypeChecker, node: TS.Node): string | undefined { + let symbol = checker.getSymbolAtLocation(node); + if (symbol && symbol.flags & ts.SymbolFlags.Alias) { + symbol = checker.getAliasedSymbol(symbol); + } + const declaration = symbol?.valueDeclaration; + if (!declaration) return undefined; + + const initializer = + ts.isPropertyAssignment(declaration) || ts.isVariableDeclaration(declaration) ? declaration.initializer : undefined; + if (initializer && (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer))) { + return initializer.text; + } + return undefined; +} + +function resolveStringValue(checker: TS.TypeChecker, arg: TS.Expression): string | undefined { + const type = checker.getTypeAtLocation(arg); + if (type.isStringLiteral()) { + return type.value; + } + return literalFromDeclaration(checker, arg); +} + +/** + * Replaces `resolveVar()` calls with the string literal they resolve to. + * + * Stencil's `transpile()` type-checks a single file with module resolution disabled, so + * `resolveVar()` can only see `const` declarations in that same file. Components that import + * their event-name catalog (`import { EVENTS } from '@my/pkg'`) compile fine under + * `stencil build` but fail in the per-file transform this plugin performs. + * + * This runs a lightweight type-check-only program with real module resolution (honouring the + * project's `tsconfig.json`) and inlines the resolved literal before handing the source to + * Stencil. Same-file constants are left untouched - Stencil already handles those - and + * anything that does not resolve to a string literal is left in place so the Stencil compiler + * reports its usual diagnostic. + * + * @param code the component source + * @param fileName the absolute path of the component source + * @returns the source with imported `resolveVar()` calls inlined, or `code` unchanged + */ +export function inlineImportedResolveVars(code: string, fileName: string): string { + if (!code.includes('resolveVar(')) return code; + + const probe = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + if (findImportedResolveVarCalls(probe).length === 0) return code; + + const edits: TextEdit[] = []; + try { + const options = loadCompilerOptions(fileName); + const program = createProgram(fileName, code, options); + const sourceFile = program.getSourceFile(fileName); + if (!sourceFile) return code; + + const checker = program.getTypeChecker(); + for (const { arg, call } of findImportedResolveVarCalls(sourceFile)) { + const value = resolveStringValue(checker, arg); + if (value === undefined) continue; + edits.push({ end: call.getEnd(), start: call.getStart(sourceFile), text: JSON.stringify(value) }); + } + } catch { + // Fall through with the original source; Stencil's transpile() reports its own diagnostic. + return code; + } + if (edits.length === 0) return code; + + // Apply from the end so earlier offsets stay valid. + edits.sort((a, b) => b.start - a.start); + let output = code; + for (const edit of edits) { + output = output.slice(0, edit.start) + edit.text + output.slice(edit.end); + } + return output; +} diff --git a/test/project/src/components.d.ts b/test/project/src/components.d.ts index 54795e3..9abf081 100644 --- a/test/project/src/components.d.ts +++ b/test/project/src/components.d.ts @@ -66,6 +66,15 @@ export namespace Components { */ interactive: boolean; } + /** + * Emits and listens to events whose names come from an imported catalog via `resolveVar()`. + * Used to verify the stencilVitestPlugin resolves imported constants that Stencil's + * single-file `transpile()` cannot see. + */ + interface MyEmitter { + emitPing: (detail: string) => Promise; + emitPong: () => Promise; + } /** * A label component that formats its value using the capitalize utility. * Used to demonstrate vi.mock() working with the stencilVitestPlugin. @@ -89,6 +98,10 @@ export interface MyButtonCustomEvent extends CustomEvent { detail: T; target: HTMLMyButtonElement; } +export interface MyEmitterCustomEvent extends CustomEvent { + detail: T; + target: HTMLMyEmitterElement; +} declare global { /** * A component that throws in componentWillLoad when a required prop is missing. @@ -168,6 +181,61 @@ declare global { prototype: HTMLMyCardElement; new (): HTMLMyCardElement; }; + interface HTMLMyEmitterElementEventMap { + myEmitterPing: string; + myEmitterPong: void; + } + /** + * Emits and listens to events whose names come from an imported catalog via `resolveVar()`. + * Used to verify the stencilVitestPlugin resolves imported constants that Stencil's + * single-file `transpile()` cannot see. + */ + interface HTMLMyEmitterElement extends Components.MyEmitter, HTMLStencilElement { + addEventListener( + type: K, + listener: (this: HTMLMyEmitterElement, ev: MyEmitterCustomEvent) => any, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: K, + listener: (this: Document, ev: DocumentEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: K, + listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void; + removeEventListener( + type: K, + listener: (this: HTMLMyEmitterElement, ev: MyEmitterCustomEvent) => any, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: K, + listener: (this: Document, ev: DocumentEventMap[K]) => any, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: K, + listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; + } + var HTMLMyEmitterElement: { + prototype: HTMLMyEmitterElement; + new (): HTMLMyEmitterElement; + }; /** * A label component that formats its value using the capitalize utility. * Used to demonstrate vi.mock() working with the stencilVitestPlugin. @@ -193,6 +261,7 @@ declare global { 'my-badge': HTMLMyBadgeElement; 'my-button': HTMLMyButtonElement; 'my-card': HTMLMyCardElement; + 'my-emitter': HTMLMyEmitterElement; 'my-label': HTMLMyLabelElement; 'non-shadow-component': HTMLNonShadowComponentElement; } @@ -262,6 +331,15 @@ declare namespace LocalJSX { */ interactive?: boolean; } + /** + * Emits and listens to events whose names come from an imported catalog via `resolveVar()`. + * Used to verify the stencilVitestPlugin resolves imported constants that Stencil's + * single-file `transpile()` cannot see. + */ + interface MyEmitter { + onMyEmitterPing?: (event: MyEmitterCustomEvent) => void; + onMyEmitterPong?: (event: MyEmitterCustomEvent) => void; + } /** * A label component that formats its value using the capitalize utility. * Used to demonstrate vi.mock() working with the stencilVitestPlugin. @@ -320,6 +398,7 @@ declare namespace LocalJSX { 'my-card': Omit & { [K in keyof MyCard & keyof MyCardAttributes]?: MyCard[K] } & { [K in keyof MyCard & keyof MyCardAttributes as `attr:${K}`]?: MyCardAttributes[K]; } & { [K in keyof MyCard & keyof MyCardAttributes as `prop:${K}`]?: MyCard[K] }; + 'my-emitter': MyEmitter; 'my-label': Omit & { [K in keyof MyLabel & keyof MyLabelAttributes]?: MyLabel[K]; } & { [K in keyof MyLabel & keyof MyLabelAttributes as `attr:${K}`]?: MyLabelAttributes[K] } & { @@ -351,6 +430,12 @@ declare module '@stencil/core' { * A card component with header, content, and footer slots */ 'my-card': LocalJSX.IntrinsicElements['my-card'] & JSXBase.HTMLAttributes; + /** + * Emits and listens to events whose names come from an imported catalog via `resolveVar()`. + * Used to verify the stencilVitestPlugin resolves imported constants that Stencil's + * single-file `transpile()` cannot see. + */ + 'my-emitter': LocalJSX.IntrinsicElements['my-emitter'] & JSXBase.HTMLAttributes; /** * A label component that formats its value using the capitalize utility. * Used to demonstrate vi.mock() working with the stencilVitestPlugin. diff --git a/test/project/src/components/my-emitter/my-emitter.plugin.spec.tsx b/test/project/src/components/my-emitter/my-emitter.plugin.spec.tsx new file mode 100644 index 0000000..9add02f --- /dev/null +++ b/test/project/src/components/my-emitter/my-emitter.plugin.spec.tsx @@ -0,0 +1,37 @@ +/** + * Verifies the stencilVitestPlugin resolves `resolveVar()` arguments that are imported + * from another module. + * + * Stencil's `transpile()` type-checks a single file with module resolution disabled, so + * on its own it cannot see `EVENTS.MY_EMITTER.PING_EVENT` coming from `../../utils/events`. + * The plugin inlines the literal before compiling, so this component loads and its + * `@Event` / `@Listen` decorators carry the catalog's event names. + */ +import { describe, it, expect } from 'vitest'; +import { render } from '@stencil/vitest'; +import { h } from '@stencil/core'; +import { EVENTS } from '../../utils/events.js'; + +// Importing the source triggers the on-the-fly compile + customElements.define(). +import './my-emitter.tsx'; + +describe('my-emitter - resolveVar() with an imported event catalog', () => { + it('emits under the event name declared in the imported catalog', async () => { + const { root, spyOnEvent } = await render(); + const spy = spyOnEvent(EVENTS.MY_EMITTER.PING_EVENT); + + await root.emitPing('hello'); + + expect(spy).toHaveReceivedEvent(); + expect(spy).toHaveReceivedEventDetail('hello'); + }); + + it('listens under the event name declared in the imported catalog', async () => { + const { root, waitForChanges } = await render(); + + await root.emitPong(); + await waitForChanges(); + + expect(root.shadowRoot!.querySelector('.pong-count')?.textContent).toBe('1'); + }); +}); diff --git a/test/project/src/components/my-emitter/my-emitter.tsx b/test/project/src/components/my-emitter/my-emitter.tsx new file mode 100644 index 0000000..c509285 --- /dev/null +++ b/test/project/src/components/my-emitter/my-emitter.tsx @@ -0,0 +1,37 @@ +import { Component, Event, EventEmitter, Listen, Method, State, h, resolveVar } from '@stencil/core'; +import { EVENTS } from '../../utils/events.js'; + +/** + * Emits and listens to events whose names come from an imported catalog via `resolveVar()`. + * Used to verify the stencilVitestPlugin resolves imported constants that Stencil's + * single-file `transpile()` cannot see. + */ +@Component({ + tag: 'my-emitter', + shadow: true, +}) +export class MyEmitter { + @State() pongCount = 0; + + @Event({ eventName: resolveVar(EVENTS.MY_EMITTER.PING_EVENT) }) ping: EventEmitter; + @Event({ eventName: resolveVar(EVENTS.MY_EMITTER.PONG_EVENT) }) pong: EventEmitter; + + @Listen(resolveVar(EVENTS.MY_EMITTER.PONG_EVENT)) + onPong() { + this.pongCount++; + } + + @Method() + async emitPing(detail: string) { + this.ping.emit(detail); + } + + @Method() + async emitPong() { + this.pong.emit(); + } + + render() { + return
{this.pongCount}
; + } +} diff --git a/test/project/src/utils/events.ts b/test/project/src/utils/events.ts new file mode 100644 index 0000000..2a790fe --- /dev/null +++ b/test/project/src/utils/events.ts @@ -0,0 +1,14 @@ +/** + * Shared event-name catalog, the way design systems typically centralise custom event + * names so emitters and listeners never drift apart. + * + * Imported by `my-emitter.tsx` and read through `resolveVar()` in its decorators. Stencil's + * single-file `transpile()` cannot follow this import on its own; the plugin inlines the + * literals before compiling. + */ +export const EVENTS = { + MY_EMITTER: { + PING_EVENT: 'myEmitterPing', + PONG_EVENT: 'myEmitterPong', + }, +} as const;