import { readJson, readProjectConfiguration, Tree, updateJson, writeJson, } from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import generator, { ensurePackageExports, vueComponentsImportPath, } from './vue-components'; jest.mock('@nx/devkit', () => ({ ...jest.requireActual('@nx/devkit'), formatFiles: jest.fn().mockResolvedValue(undefined), })); describe('Vue Components Generator', () => { let host: Tree; beforeEach(() => { host = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); }); it('creates the shared wrapper library with all wrappers and a barrel', async () => { // Simulate a legacy-ESLint workspace so useFlatConfig() returns false and // the generator creates .eslintrc.json rather than eslint.config.mjs. host.write('.eslintrc.json', '{}'); await generator(host); const config = readProjectConfiguration(host, 'vue-components'); expect(config.root).toBe('libs/vue-components'); const primitives = 'libs/vue-components/src/lib/primitives'; for (const name of [ 'GoabInput', 'GoabTextarea', 'GoabDropdown', 'GoabCheckbox', 'GoabRadioGroup', 'GoabButton', 'GoabModal', ]) { expect(host.exists(`${primitives}/${name}.vue`)).toBeTruthy(); } // Permanent app-shell pattern components live alongside the interim wrappers. const patterns = 'libs/vue-components/src/lib/patterns'; for (const name of [ 'AppLayout', 'AppHeader', 'AppFooter', 'AppSideMenu', 'SessionExpiredBanner', 'RecordDetailShell', 'WorkspaceTable', 'Stepper', 'StepErrorSummary', ]) { expect(host.exists(`${patterns}/${name}.vue`)).toBeTruthy(); } const index = host.read('libs/vue-components/src/index.ts').toString(); expect(index).toContain('export { default as GoabInput }'); expect(index).toContain('export { default as AppLayout }'); expect(index).toContain('@abgov/vue-components'); // interim marker expect(index).toContain("from './lib/formatters'"); expect(index).toContain('export { default as GoabDatePicker }'); expect(index).toContain('export { default as FilterBar }'); // GoabDatePicker is the one wrapper whose _change detail.value is a Date // rather than a string -- verified against the installed web-components, // where the dispatch reads `value: j.date, valueStr: r`. const datePicker = host .read('libs/vue-components/src/lib/primitives/GoabDatePicker.vue') .toString(); expect(datePicker).toContain('defineModel()'); expect(datePicker).toContain('CustomEvent<{ value: Date }>'); // FilterBar stays presentational: it emits a query-ready values object and // never touches the page, the router, or the network. const filterBar = host .read('libs/vue-components/src/lib/patterns/FilterBar.vue') .toString(); // goa-pagination registers all-lowercase prop names with no attribute: // alias, so kebab-case bindings silently never arrive and the control // renders "Page — of NaN". const table = host .read('libs/vue-components/src/lib/patterns/WorkspaceTable.vue') .toString(); // Sorting is the native element's job: goa-table-sort-header inside a // goa-table with sort-mode="single". The hand-rolled button + manual // aria-sort is gone, and the comment that claimed no native component // existed is corrected. expect(table).toContain(''); // Catalogues the presentational goa-* elements that need no wrapper. Its // absence is what drove a real app to hand-roll 254 inline styles on raw // HTML standing in for elements that already shipped. expect(agents).toContain('most need no wrapper'); for (const element of [ 'goa-container', 'goa-block', 'goa-grid', 'goa-text', 'goa-table', 'goa-tabs', ]) { expect(agents).toContain(element); } expect(agents).toContain("Don't wrap a presentational element"); }, 30000); it('AppSideMenu exposes an optional #topbar slot for header-action-style content', async () => { await generator(host); const sideMenu = host .read('libs/vue-components/src/lib/patterns/AppSideMenu.vue') .toString(); // Named slot, only rendered when actually given content -- no empty bar // shows by default, matching the "unused by default" doc claim. expect(sideMenu).toContain(''); expect(sideMenu).toContain('v-if="slots.topbar"'); expect(sideMenu).toContain('useSlots'); }, 30000); it('disables vue/no-deprecated-slot-attribute in flat config too, not just .eslintrc.json', async () => { // useFlatConfig() (from @nx/eslint) treats a root flat-config file's // presence as authoritative, regardless of the installed ESLint version — // this is what create-nx-workspace's current default actually looks like. host.write('eslint.config.mjs', 'export default [];\n'); await generator(host); expect(host.exists('libs/vue-components/eslint.config.mjs')).toBeTruthy(); expect(host.exists('libs/vue-components/.eslintrc.json')).toBeFalsy(); const flatConfig = host .read('libs/vue-components/eslint.config.mjs') .toString(); expect(flatConfig).toContain('"vue/no-deprecated-slot-attribute": "off"'); }, 30000); it('is idempotent — a second run does not throw and keeps the wrappers', async () => { await generator(host); await expect(generator(host)).resolves.not.toThrow(); expect( host.exists('libs/vue-components/src/lib/primitives/GoabInput.vue'), ).toBeTruthy(); }, 30000); it('does not duplicate the ESLint override on a second run (legacy or flat)', async () => { host.write('eslint.config.mjs', 'export default [];\n'); await generator(host); await generator(host); const flatConfig = host .read('libs/vue-components/eslint.config.mjs') .toString(); expect( flatConfig.split('vue/no-deprecated-slot-attribute').length - 1, ).toBe(1); }, 30000); it('derives the import path from the workspace scope', () => { expect(vueComponentsImportPath(host)).toMatch(/\/vue-components$/); }); describe('ensurePackageExports (TS-solution resolution fix)', () => { it('backfills exports/main/types when a lib package.json exists', () => { host.write( 'libs/vue-components/package.json', JSON.stringify({ name: '@proj/vue-components' }), ); ensurePackageExports(host, 'libs/vue-components'); const pkg = JSON.parse( host.read('libs/vue-components/package.json').toString(), ); expect(pkg.main).toBe('./src/index.ts'); expect(pkg.types).toBe('./src/index.ts'); expect(pkg.exports['.'].import).toBe('./src/index.ts'); }); it('does not clobber exports @nx/vue already wrote', () => { host.write( 'libs/vue-components/package.json', JSON.stringify({ name: '@proj/vue-components', exports: { '.': './dist/index.js' }, }), ); ensurePackageExports(host, 'libs/vue-components'); const pkg = JSON.parse( host.read('libs/vue-components/package.json').toString(), ); expect(pkg.exports['.']).toBe('./dist/index.js'); }); it('is a no-op for a legacy lib with no package.json', () => { ensurePackageExports(host, 'libs/vue-components'); expect(host.exists('libs/vue-components/package.json')).toBeFalsy(); }); }); it('exports the display helpers from the lib barrel, not just the module', async () => { await generator(host); const index = host.read('libs/vue-components/src/index.ts').toString(); expect(index).toContain('optionLabel'); expect(index).toContain('badgeType'); expect(index).toContain('DisplayOption'); expect(index).toContain('BadgeType'); }, 30000); it('falls back to the raw value for an unmapped code rather than hiding it', async () => { await generator(host); const formatters = host .read('libs/vue-components/src/lib/formatters.ts') .toString(); // An unmapped code is a data/config problem; showing it is debuggable, // blanking it is not. expect(formatters).toContain('return match ? match.label : String(value)'); // And an unmapped badge value stays neutral rather than being asserted good // or bad. expect(formatters).toContain("fallback: BadgeType = 'information'"); }, 30000); // Regression: @nx/vue writes `moduleResolution: "bundler"` into the lib's // tsconfig but leaves `module` inherited. Against a create-nx-workspace base // (`module: "nodenext"`) that pair is invalid -- TS5095 and TS5109 -- so // `nx build` failed on completely unmodified generator output. it('gives the lib a module setting compatible with its bundler moduleResolution', async () => { writeJson(host, 'tsconfig.base.json', { compilerOptions: { module: 'nodenext', moduleResolution: 'nodenext', target: 'es2022', }, }); await generator(host); const compilerOptions = readJson( host, 'libs/vue-components/tsconfig.json', ).compilerOptions; expect(compilerOptions.moduleResolution).toBe('bundler'); expect(compilerOptions.module).toBe('esnext'); }, 30000); it('leaves a tsconfig that already declares module alone', async () => { await generator(host); // Pin something deliberate, then re-run: the backfill must not overwrite it. updateJson(host, 'libs/vue-components/tsconfig.json', (tsconfig) => { tsconfig.compilerOptions.module = 'preserve'; return tsconfig; }); await generator(host); expect( readJson(host, 'libs/vue-components/tsconfig.json').compilerOptions .module, ).toBe('preserve'); }, 30000); // Regression: goa-work-side-menu's profile button takes its accessible name // only from `user-name` (no aria-label prop exists on the element), and the // shell rendered the account slot -- which is what creates that button -- // without ever supplying one. Measured on pristine generator output, that was // an unnamed critical `button-name` violation plus an unlabelled `role-img-alt` // icon on every route of an internal-layout app. it('never leaves the side menu profile button without an accessible name', async () => { await generator(host); const sideMenu = host .read('libs/vue-components/src/lib/patterns/AppSideMenu.vue') .toString(); // Resolved, not passed straight through: an empty string is the realistic // input (a signed-out user), and withDefaults only covers `undefined`. expect(sideMenu).toContain( "const profileName = computed(() => props.userName?.trim() || 'Account')", ); expect(sideMenu).toContain(':user-name="profileName"'); expect(sideMenu).not.toContain(':user-name="userName"'); }, 30000); });