import { readFileSync } from 'node:fs' import { ESLint, type Linter } from 'eslint' import { parser } from 'typescript-eslint' import { describe, expect, it } from 'vitest' import estiva, { APP_RULE_IDS, countGates, PACKAGE_RULE_IDS, PLUGIN_KEY } from './index' /** * The plugin as an app uses it: a flat config with `configs.recommended`, * linting text the way Peek's editor hook does (`lintText`). The rules' own * cases are in no-raw-element.test.ts and no-rebuilt-behaviour.test.ts. */ const tsx: Linter.Config = { files: ['**/*.tsx'], languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } }, } async function lint(code: string, extra: Linter.Config[] = []) { const eslint = new ESLint({ cwd: process.cwd(), overrideConfigFile: true, overrideConfig: [tsx, estiva.configs.recommended, ...extra], }) return eslint.lintText(code, { filePath: 'src/Probe.tsx' }) } const component = (body: string) => `export function Probe() {\n return (\n${body}\n )\n}\n` const countMode: Linter.Config = { settings: { estiva: { reportEscapes: true } } } describe('the plugin object', () => { it('names itself and carries the package version', () => { const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) expect(estiva.meta).toEqual({ name: '@estiva-app/ui/eslint', version: pkg.version }) }) it('carries every rule, the app ones and the inward ones', () => { expect(Object.keys(estiva.rules)).toEqual([ 'no-raw-element', 'no-rebuilt-behaviour', 'no-restyled-part', 'no-handmade-header', 'no-handmade-empty-state', 'no-native-title', 'no-copied-look', 'no-field-outside-a-form', 'raw-element-outside-a-wrapper', 'no-hand-rolled-behaviour', 'component-has-a-page', 'component-has-a-story', ]) }) /** * The apps spread `recommended`. A rule added for the package (UIG-5) must not * arrive in Peek or Ship with the next version bump: an app's components are * not primitives, so "buried inside a component" means nothing there * (`no-raw-element` is the app's version, UIG-7), and an app has no `.mdx` * pages at all. This is * the test that holds that line — if you add an app-facing rule on purpose, * change it deliberately, here. */ it('gives an app only the app rules, as errors, under estiva/', () => { const blocking = { 'estiva/no-raw-element': 'error', 'estiva/no-rebuilt-behaviour': 'error', 'estiva/no-restyled-part': 'error', 'estiva/no-handmade-header': 'error', 'estiva/no-handmade-empty-state': 'error', 'estiva/no-native-title': 'error', 'estiva/no-field-outside-a-form': 'error' } for (const config of [estiva.configs.recommended, estiva.configs.strict]) expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva) // The copied look warns in recommended (Katerina, 13 September), and strict makes it an error. expect(estiva.configs.recommended.rules).toEqual({ ...blocking, 'estiva/no-copied-look': 'warn' }) expect(estiva.configs.strict.rules).toEqual({ ...blocking, 'estiva/no-copied-look': 'error' }) expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title', 'estiva/no-copied-look', 'estiva/no-field-outside-a-form']) }) /** * `no-restyled-part` (UIG-9), `no-handmade-header` (UIG-22) and * `no-handmade-empty-state` (UIG-23) and `no-native-title` (UIG-24) are in both sets: the package restyles * none of its own parts either (Katerina, 17 September), and draws no header * bar or empty state by hand ("my review isn't enough", 23 September). Every * other inward rule still reaches no app. */ it('gives this package its own set, as errors, and only the shared rules reach an app config', () => { expect(estiva.configs.package.plugins?.[PLUGIN_KEY]).toBe(estiva) expect(estiva.configs.package.rules).toEqual({ 'estiva/raw-element-outside-a-wrapper': 'error', 'estiva/no-hand-rolled-behaviour': 'error', 'estiva/component-has-a-page': 'error', 'estiva/component-has-a-story': 'error', 'estiva/no-restyled-part': 'error', 'estiva/no-handmade-header': 'error', 'estiva/no-handmade-empty-state': 'error', 'estiva/no-native-title': 'error', 'estiva/no-copied-look': 'warn', }) expect(PACKAGE_RULE_IDS).toEqual(Object.keys(estiva.configs.package.rules ?? {})) for (const id of PACKAGE_RULE_IDS.filter((id) => !['estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title', 'estiva/no-copied-look'].includes(id))) { expect(estiva.configs.recommended.rules?.[id]).toBeUndefined() expect(estiva.configs.strict.rules?.[id]).toBeUndefined() } }) }) describe('an app lint with configs.recommended', () => { it('reports a raw ')) expect(result.messages.map((m) => [m.ruleId, m.severity, m.message])).toEqual([ ['estiva/no-raw-element', 2, 'Use `Button` from @estiva-app/ui instead of a raw ')) expect(result.messages).toEqual([]) }) }) describe('countGates', () => { it('counts an error, and an escape only when the lint reports escapes', async () => { const code = component('
\n \n {/* @estiva-escape(no-raw-element): a preview drawn from its own palette */}\n \n
') const none = { errors: 0, warnings: 0, escapes: 0 } expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none, 'estiva/no-copied-look': none, 'estiva/no-field-outside-a-form': none }) expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none, 'estiva/no-copied-look': none, 'estiva/no-field-outside-a-form': none }) }) it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => { const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-element\n '), [countMode]) const count = countGates(results) expect(count.rules['estiva/no-raw-element']).toEqual({ errors: 0, warnings: 0, escapes: 0 }) expect(count.disabled).toEqual([{ filePath: results[0].filePath, line: 4, ruleId: 'estiva/no-raw-element' }]) }) it('counts a marker inside that directive as an error of the rule', async () => { const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-element -- @estiva-escape: a preview drawn from its own palette\n '), [countMode]) const count = countGates(results) expect(count.rules['estiva/no-raw-element']).toEqual({ errors: 1, warnings: 0, escapes: 0 }) expect(count.disabled).toHaveLength(1) }) it('lists every rule of the plugin, even with nothing found', () => { expect(countGates([])).toEqual({ rules: { 'estiva/no-raw-element': { errors: 0, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': { errors: 0, warnings: 0, escapes: 0 }, 'estiva/no-restyled-part': { errors: 0, warnings: 0, escapes: 0 }, 'estiva/no-handmade-header': { errors: 0, warnings: 0, escapes: 0 }, 'estiva/no-handmade-empty-state': { errors: 0, warnings: 0, escapes: 0 }, 'estiva/no-native-title': { errors: 0, warnings: 0, escapes: 0 }, 'estiva/no-copied-look': { errors: 0, warnings: 0, escapes: 0 }, 'estiva/no-field-outside-a-form': { errors: 0, warnings: 0, escapes: 0 }, }, disabled: [], }) }) })