Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<string>;

@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
Expand Down
173 changes: 173 additions & 0 deletions src/__tests__/resolve-var.spec.ts
Original file line number Diff line number Diff line change
@@ -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")`);
});
});
9 changes: 8 additions & 1 deletion src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
Expand Down Expand Up @@ -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.
Expand Down
Loading