import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
import { create } from '@custom-elements-manifest/analyzer/src/create.js'
import { wcbStaticProps } from '../src/cem-plugin.js'
import { getKebabCase } from '../src/utils/index.js'
// The analyzer resolves its own `typescript`, which may be a different version
// than ours. `SyntaxKind` values shift between releases, so a source file built
// by a different instance is invisible to the analyzer's node checks — build it
// with the exact instance the analyzer uses.
const require = createRequire(import.meta.url)
const ts = require(
createRequire(
require.resolve('@custom-elements-manifest/analyzer/src/create.js')
).resolve('typescript')
)
/**
* Runs the real analyzer over a source string with the plugin installed and
* returns the resulting classDoc — the same path `cem analyze` takes.
* @param {string} source component source to analyze
* @param {string} [className] class to return the doc for
* @returns {any} the classDoc from the generated manifest
*/
function analyze(source, className = 'CozyButton') {
const manifest = create({
modules: [
ts.createSourceFile(
'my-element.js',
source,
ts.ScriptTarget.ES2015,
true
),
],
plugins: [wcbStaticProps()],
context: { dev: false, thirdPartyCEMs: [] },
})
return manifest.modules[0].declarations.find((d) => d.name === className)
}
const COZY_BUTTON = `
import { WebComponent, html } from 'web-component-base'
export class CozyButton extends WebComponent {
static props = {
variant: 'primary',
disabled: false,
maxCount: 3,
config: { size: 'md' },
items: [],
}
static shadowRootInit = { mode: 'open' }
static styles = ':host { display: block }'
static strictProps = true
get template() {
return html\`\`
}
}
customElements.define('cozy-button', CozyButton)
`
describe('cem-plugin: wcbStaticProps', () => {
const doc = analyze(COZY_BUTTON)
const attrNamed = (name) => doc.attributes.find((a) => a.name === name)
const fieldNamed = (name) => doc.members.find((m) => m.name === name)
it('emits one attribute per declared prop', () => {
expect(doc.attributes.map((a) => a.name).sort()).toEqual([
'config',
'disabled',
'items',
'max-count',
'variant',
])
})
it('infers the type from the default literal', () => {
expect(attrNamed('variant').type).toEqual({ text: 'string' })
expect(attrNamed('disabled').type).toEqual({ text: 'boolean' })
expect(attrNamed('max-count').type).toEqual({ text: 'number' })
expect(attrNamed('config').type).toEqual({ text: 'object' })
expect(attrNamed('items').type).toEqual({ text: 'object' })
})
it('records the default and the camelCase field it maps to', () => {
expect(attrNamed('variant')).toMatchObject({
fieldName: 'variant',
default: "'primary'",
})
expect(attrNamed('max-count').fieldName).toBe('maxCount')
})
it('names attributes with wcb getKebabCase, matching observedAttributes', () => {
for (const attribute of doc.attributes)
expect(attribute.name).toBe(getKebabCase(attribute.fieldName))
})
it('emits a matching public field per prop', () => {
expect(fieldNamed('variant')).toMatchObject({
kind: 'field',
privacy: 'public',
type: { text: 'string' },
attribute: 'variant',
})
expect(fieldNamed('maxCount').attribute).toBe('max-count')
})
it('strips wcb internals from the public surface', () => {
const names = doc.members.map((m) => m.name)
for (const internal of [
'props',
'shadowRootInit',
'styles',
'strictProps',
'observedAttributes',
'template',
])
expect(names, internal).not.toContain(internal)
})
it('keeps the component authors own members', () => {
const withMethod = analyze(`
import { WebComponent } from 'web-component-base'
export class CozyButton extends WebComponent {
static props = { variant: 'primary' }
focusFirst() {}
}
`)
expect(withMethod.members.map((m) => m.name)).toContain('focusFirst')
})
it('strips internals from a wcb component that declares no props', () => {
const doc = analyze(`
import { WebComponent } from 'web-component-base'
export class CozyButton extends WebComponent {
static styles = ':host{}'
get template() { return '' }
}
`)
// the analyzer drops arrays it finds empty, so stripping every member
// leaves no `members` key at all
expect((doc.members ?? []).map((m) => m.name)).not.toContain('styles')
expect(doc.attributes ?? []).toEqual([])
})
// The typed-props pattern hoists the defaults into a const so the class can
// write `extends WebComponent` — a class can't reference its
// own static in its heritage clause. `static props` is then an identifier,
// not an object literal.
it('resolves static props hoisted into a module-level const', () => {
const doc = analyze(`
import { WebComponent } from 'web-component-base'
const buttonProps = { variant: 'primary', maxCount: 2 }
export class CozyButton extends WebComponent {
static props = buttonProps
}
`)
expect(doc.attributes.map((a) => a.name).sort()).toEqual([
'max-count',
'variant',
])
expect(doc.attributes.find((a) => a.name === 'max-count').type).toEqual({
text: 'number',
})
})
it('resolves static props declared with `as const`', () => {
const doc = analyze(`
import { WebComponent } from 'web-component-base'
const buttonProps = { disabled: false } as const
export class CozyButton extends WebComponent {
static props = buttonProps
}
`)
expect(doc.attributes.map((a) => a.name)).toEqual(['disabled'])
})
it('leaves non-wcb classes untouched', () => {
const doc = analyze(
`export class Plain extends HTMLElement { static props = 1 }`,
'Plain'
)
expect(doc.attributes).toBeUndefined()
expect(doc.members.map((m) => m.name)).toContain('props')
})
})