From aa2d2c09b556cfe141ddb34914e9c0dbf039aa90 Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Mon, 8 Jun 2026 18:19:18 -0500 Subject: [PATCH 1/2] fix(cli): handle pnpm build approvals Signed-off-by: Cory Rylan --- projects/cli/README.md | 11 ++ .../internals/tools/src/api/utils.test.ts | 89 ++++++++++ .../tools/src/distill/examples.test.ts | 22 +++ .../internals/tools/src/internal/node.test.ts | 63 +++++++ .../tools/src/internal/utils.test.ts | 33 +++- .../tools/src/packages/utils.test.ts | 45 +++++ .../tools/src/project/setup-agent.test.ts | 3 + .../tools/src/project/setup-agent.ts | 2 +- .../tools/src/project/starters.test.ts | 67 +++++++ .../internals/tools/src/project/starters.ts | 49 ++++-- .../tools/src/project/update.test.ts | 165 +++++++++++++++++- .../internals/tools/src/project/update.ts | 22 ++- projects/internals/tools/src/skills/doctor.md | 1 + projects/site/src/docs/mcp/index.md | 3 +- 14 files changed, 550 insertions(+), 25 deletions(-) diff --git a/projects/cli/README.md b/projects/cli/README.md index c7e676f02..f590e89db 100644 --- a/projects/cli/README.md +++ b/projects/cli/README.md @@ -115,6 +115,17 @@ Install to Cursor with the MCP configuration below. } ``` +### Codex + +Install to Codex with the MCP configuration below. + +```toml +[mcp_servers.elements] +description = "NVIDIA Elements UI Design System (nve-*), custom element schemas, APIs and examples" +command = "nve" +args = ["mcp"] +``` + ### Prompts | Prompt | Description | Example Prompt | diff --git a/projects/internals/tools/src/api/utils.test.ts b/projects/internals/tools/src/api/utils.test.ts index dbd4b5d5b..e38708f8a 100644 --- a/projects/internals/tools/src/api/utils.test.ts +++ b/projects/internals/tools/src/api/utils.test.ts @@ -8,9 +8,14 @@ import { getContextAPIs, getContextTokens, getPublishedPackageNames, + searchContextAPIs, type PartialAPIResult } from './utils.js'; +vi.mock('@internals/metadata', () => ({ + ApiService: { search: vi.fn() } +})); + describe('getPublishedPackageNames', () => { const projects = [ { @@ -613,4 +618,88 @@ describe('attributeMetadataToMarkdown', () => { expect(markdown.includes('| `disabled` | `string` |`true` |')).toBe(true); }); + + it('should use the built-in example for nve-layout', () => { + const attribute: Attribute = { + name: 'nve-layout', + description: 'Layout utility attribute', + example: '', + markdown: '', + values: [{ name: 'row' }, { name: 'column' }] + }; + + const markdown = attributeMetadataToMarkdown(attribute); + + expect(markdown).toContain('## nve-layout'); + expect(markdown).toContain('nve-layout="row gap:sm"'); + expect(markdown).toContain('nve-layout="grid gap:sm span-items:6"'); + }); + + it('should use the built-in example for nve-text', () => { + const attribute: Attribute = { + name: 'nve-text', + description: 'Typography utility attribute', + example: '', + markdown: '', + values: [{ name: 'heading' }, { name: 'body' }] + }; + + const markdown = attributeMetadataToMarkdown(attribute); + + expect(markdown).toContain('## nve-text'); + expect(markdown).toContain('nve-text="heading"'); + expect(markdown).toContain('nve-text="monospace"'); + }); +}); + +describe('searchContextAPIs', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should attach markdown to attribute results that have values', async () => { + const { ApiService } = await import('@internals/metadata'); + vi.mocked(ApiService.search).mockResolvedValue([ + { name: 'nve-layout', description: 'Layout utility', values: [{ name: 'row' }], markdown: '' } + ] as never); + + const results = (await searchContextAPIs('layout')) as Attribute[]; + + expect(results).toHaveLength(1); + expect(results[0].markdown).toContain('## nve-layout'); + }); + + it('should leave element results without a markdown field untouched', async () => { + const { ApiService } = await import('@internals/metadata'); + vi.mocked(ApiService.search).mockResolvedValue([ + { name: 'nve-button', manifest: { metadata: { markdown: 'x' } } } + ] as never); + + const results = (await searchContextAPIs('button')) as Element[]; + + expect(results).toHaveLength(1); + expect((results[0] as Attribute).markdown).toBeUndefined(); + }); + + it('should limit results to the configured limit', async () => { + const { ApiService } = await import('@internals/metadata'); + vi.mocked(ApiService.search).mockResolvedValue( + Array.from({ length: 5 }, (_, index) => ({ name: `nve-item-${index}` })) as never + ); + + const results = await searchContextAPIs('item', { limit: 2 }); + + expect(results).toHaveLength(2); + }); + + it('should return every result when no limit is provided', async () => { + const { ApiService } = await import('@internals/metadata'); + vi.mocked(ApiService.search).mockResolvedValue( + Array.from({ length: 5 }, (_, index) => ({ name: `nve-item-${index}` })) as never + ); + + const results = await searchContextAPIs('item', {}); + + expect(results).toHaveLength(5); + }); }); diff --git a/projects/internals/tools/src/distill/examples.test.ts b/projects/internals/tools/src/distill/examples.test.ts index b9725d0d5..d344098d1 100644 --- a/projects/internals/tools/src/distill/examples.test.ts +++ b/projects/internals/tools/src/distill/examples.test.ts @@ -128,6 +128,10 @@ describe('isContextExample', () => { }) ).toBe(false); }); + + it('should treat an example with no id, tags, or element as a default', () => { + expect(isContextExample({})).toBe(true); + }); }); describe('rankExample', () => { @@ -147,6 +151,10 @@ describe('rankExample', () => { expect(rankExample({ id: 'button-default' })).toBe(3); }); + it('should default to the lowest rank when the id is missing', () => { + expect(rankExample({})).toBe(3); + }); + it('should strip elements- prefix before ranking', () => { expect(rankExample({ id: 'elements-template-foo' })).toBe(0); expect(rankExample({ id: 'elements-pattern-form' })).toBe(1); @@ -267,4 +275,18 @@ describe('distillExamples', () => { expect(result).toHaveLength(1); expect(result[0].summary).toBe('Has summary'); }); + + it('should default every shaped field when examples omit them', () => { + const result = distillExamples([{}, {}]); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ id: '', name: '', summary: '', element: '', template: '' }); + }); + + it('should fall back to the description when the summary is missing', () => { + const result = distillExamples([{ id: 'widget', element: 'nve-widget', description: 'Reusable widget' }]); + + expect(result).toHaveLength(1); + expect(result[0].summary).toBe('Reusable widget'); + }); }); diff --git a/projects/internals/tools/src/internal/node.test.ts b/projects/internals/tools/src/internal/node.test.ts index 58b89b7d6..d8c3eb433 100644 --- a/projects/internals/tools/src/internal/node.test.ts +++ b/projects/internals/tools/src/internal/node.test.ts @@ -162,5 +162,68 @@ describe('internal/node', () => { expect(result).toEqual([]); }); + + it('should ignore PATH entries that are not regular files', async () => { + const { existsSync, statSync } = await import('node:fs'); + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(statSync).mockReturnValue({ isFile: () => false } as ReturnType); + + const { findExecutablesOnPath } = await import('./node.js'); + const result = findExecutablesOnPath('nve', { envPath: '/a' }); + + expect(result).toEqual([]); + }); + + it('should expand the command with explicit PATHEXT extensions on win32', async () => { + const { existsSync, statSync, realpathSync } = await import('node:fs'); + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(statSync).mockReturnValue({ isFile: () => true } as ReturnType); + vi.mocked(realpathSync).mockImplementation(path => path.toString()); + + const { findExecutablesOnPath } = await import('./node.js'); + const result = findExecutablesOnPath('nve', { envPath: 'C:/bin', platform: 'win32', pathExt: '.EXE;.CMD' }); + + expect(result.some(commandPath => commandPath.toLowerCase().endsWith('nve.exe'))).toBe(true); + expect(result.some(commandPath => commandPath.toLowerCase().endsWith('nve.cmd'))).toBe(true); + }); + + it('should fall back to the PATHEXT environment variable on win32', async () => { + const { existsSync, statSync, realpathSync } = await import('node:fs'); + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(statSync).mockReturnValue({ isFile: () => true } as ReturnType); + vi.mocked(realpathSync).mockImplementation(path => path.toString()); + vi.stubEnv('PATHEXT', '.BAT'); + + const { findExecutablesOnPath } = await import('./node.js'); + const result = findExecutablesOnPath('nve', { envPath: 'C:/bin', platform: 'win32' }); + + expect(result.some(commandPath => commandPath.toLowerCase().endsWith('nve.bat'))).toBe(true); + vi.unstubAllEnvs(); + }); + + it('should treat any matching file as executable on win32 without an access check', async () => { + const { accessSync, existsSync, statSync, realpathSync } = await import('node:fs'); + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(statSync).mockReturnValue({ isFile: () => true } as ReturnType); + vi.mocked(realpathSync).mockImplementation(path => path.toString()); + + const { findExecutablesOnPath } = await import('./node.js'); + const result = findExecutablesOnPath('nve', { envPath: 'C:/bin', platform: 'win32', pathExt: '.EXE' }); + + expect(result.length).toBeGreaterThan(0); + expect(accessSync).not.toHaveBeenCalled(); + }); + + it('should default to process PATH and platform when options are omitted', async () => { + const { existsSync } = await import('node:fs'); + vi.mocked(existsSync).mockReturnValue(false); + vi.stubEnv('PATH', '/usr/bin:/bin'); + + const { findExecutablesOnPath } = await import('./node.js'); + const result = findExecutablesOnPath('definitely-not-a-real-command'); + + expect(result).toEqual([]); + vi.unstubAllEnvs(); + }); }); }); diff --git a/projects/internals/tools/src/internal/utils.test.ts b/projects/internals/tools/src/internal/utils.test.ts index 7693360cf..e9bc25a10 100644 --- a/projects/internals/tools/src/internal/utils.test.ts +++ b/projects/internals/tools/src/internal/utils.test.ts @@ -1,9 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ProjectElement } from '@internals/metadata'; -import { getElementImports, getAvailableElementTags, wrapText } from './utils.js'; +import { getElementImports, getAvailableElementTags, isDebug, wrapText } from './utils.js'; describe('getElementImports', () => { const elements: ProjectElement[] = [ @@ -124,4 +124,33 @@ describe('wrapText', () => { const result = wrapText(text, 10); expect(result).toContain('superlongwordthatexceedswidth'); }); + + it('should handle a single word longer than width with nothing trailing', () => { + const text = 'superlongwordthatexceedswidth'; + expect(wrapText(text, 10)).toBe('superlongwordthatexceedswidth'); + }); +}); + +describe('isDebug', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should return true when debug is enabled outside of the mcp environment', () => { + vi.stubEnv('ELEMENTS_DEBUG', 'true'); + vi.stubEnv('ELEMENTS_ENV', 'cli'); + expect(isDebug()).toBe(true); + }); + + it('should return false when debug is not enabled', () => { + vi.stubEnv('ELEMENTS_DEBUG', 'false'); + vi.stubEnv('ELEMENTS_ENV', 'cli'); + expect(isDebug()).toBe(false); + }); + + it('should return false in the mcp environment even when debug is enabled', () => { + vi.stubEnv('ELEMENTS_DEBUG', 'true'); + vi.stubEnv('ELEMENTS_ENV', 'mcp'); + expect(isDebug()).toBe(false); + }); }); diff --git a/projects/internals/tools/src/packages/utils.test.ts b/projects/internals/tools/src/packages/utils.test.ts index 5b134e42b..111508f70 100644 --- a/projects/internals/tools/src/packages/utils.test.ts +++ b/projects/internals/tools/src/packages/utils.test.ts @@ -4,7 +4,9 @@ import { describe, expect, it, vi } from 'vitest'; import { findPublicAPIChangelog, + getAvailablePackages, getPackage, + getVersions, hasChangelogEntries, limitChangelogVersions, scopeOrder @@ -198,4 +200,47 @@ describe('getPackage', () => { expect(result).toContain('# @nvidia-elements/core v1.0.0'); expect(result).toContain('No readme pkg'); }); + + it('should throw with no available packages when metadata is missing', async () => { + const { ProjectsService } = await import('@internals/metadata'); + vi.mocked(ProjectsService.getData).mockResolvedValueOnce(undefined as never); + + await expect(getPackage('@nvidia-elements/core')).rejects.toThrow('No package found for "@nvidia-elements/core"'); + }); +}); + +describe('getVersions', () => { + it('should resolve the latest published versions for available packages', async () => { + const result = await getVersions(); + expect(result).toEqual({ + '@nvidia-elements/core': '2.0.0', + '@nvidia-elements/themes': '1.5.0' + }); + }); + + it('should default to an empty package list when metadata is missing', async () => { + const { ProjectsService } = await import('@internals/metadata'); + vi.mocked(ProjectsService.getData).mockResolvedValueOnce(undefined as never); + + const result = await getVersions(); + expect(result).toBeDefined(); + }); +}); + +describe('getAvailablePackages', () => { + it('should format available packages as markdown sections', async () => { + const result = await getAvailablePackages(); + expect(result).toContain('## @nvidia-elements/core v2.0.0'); + expect(result).toContain('Core elements'); + expect(result).toContain('## @nvidia-elements/themes v1.5.0'); + expect(result).toContain('Theme tokens'); + }); + + it('should return an empty string when metadata is missing', async () => { + const { ProjectsService } = await import('@internals/metadata'); + vi.mocked(ProjectsService.getData).mockResolvedValueOnce(undefined as never); + + const result = await getAvailablePackages(); + expect(result).toBe(''); + }); }); diff --git a/projects/internals/tools/src/project/setup-agent.test.ts b/projects/internals/tools/src/project/setup-agent.test.ts index 7eee24553..71c51b538 100644 --- a/projects/internals/tools/src/project/setup-agent.test.ts +++ b/projects/internals/tools/src/project/setup-agent.test.ts @@ -171,6 +171,9 @@ describe('setup-mcp', () => { const written = vi.mocked(writeFileSync).mock.calls[0][1] as string; expect(written).toContain('[mcp_servers.elements]'); + expect(written).toContain( + 'description = "NVIDIA Elements UI Design System (nve-*), custom element schemas, APIs and examples"' + ); expect(written).toContain('command = "nve"'); expect(written).toContain('args = ["mcp"]'); }); diff --git a/projects/internals/tools/src/project/setup-agent.ts b/projects/internals/tools/src/project/setup-agent.ts index 587ed4593..92ac9f6a6 100644 --- a/projects/internals/tools/src/project/setup-agent.ts +++ b/projects/internals/tools/src/project/setup-agent.ts @@ -65,7 +65,7 @@ export function writeMcpTomlConfig(configPath: string): string { const sectionRegex = new RegExp(`\\[mcp_servers\\.elements\\][\\s\\S]*?(?=\\n\\[|$)`); const content = existing.replace(sectionRegex, '').trimEnd(); - const block = `\n\n[mcp_servers.elements]\ncommand = "nve"\nargs = ["mcp"]\n`; + const block = `\n\n[mcp_servers.elements]\ndescription = "${DESCRIPTION}"\ncommand = "nve"\nargs = ["mcp"]\n`; const updated = (content + block).trimStart(); const dir = configPath.substring(0, configPath.lastIndexOf('/')); diff --git a/projects/internals/tools/src/project/starters.test.ts b/projects/internals/tools/src/project/starters.test.ts index 01beb98e3..c15eca7d8 100644 --- a/projects/internals/tools/src/project/starters.test.ts +++ b/projects/internals/tools/src/project/starters.test.ts @@ -7,7 +7,9 @@ import { startersData, createGitInitProcess, createStarterPaths, + execPackageManager, getDependencyInstallFailureMessage, + getRequiredNPMClient, removeWireitScripts, startStarter } from './starters.js'; @@ -253,3 +255,68 @@ describe('createGitInitProcess', () => { expect(execFile).toHaveBeenCalledWith('git', ['init', extractedPath]); }); }); + +describe('execPackageManager', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should invoke pnpm without shell interpretation', async () => { + const { execFile } = await import('node:child_process'); + + execPackageManager('pnpm', ['install'], '/tmp/starter'); + + expect(execFile).toHaveBeenCalledWith('pnpm', ['install'], { cwd: '/tmp/starter' }); + }); + + it('should invoke npm without shell interpretation', async () => { + const { execFile } = await import('node:child_process'); + + execPackageManager('npm', ['install'], '/tmp/starter'); + + expect(execFile).toHaveBeenCalledWith('npm', ['install'], { cwd: '/tmp/starter' }); + }); +}); + +describe('getRequiredNPMClient', () => { + it('should return the detected package manager when one is available', async () => { + expect(await getRequiredNPMClient()).toBe('pnpm'); + }); + + it('should throw when no supported package manager is found', async () => { + vi.resetModules(); + vi.doMock('../internal/node.js', () => ({ + getNPMClient: vi.fn().mockResolvedValue(null), + isCommandAvailable: vi.fn(), + getPackageJson: vi.fn() + })); + + const { getRequiredNPMClient: getRequiredNPMClientMocked } = await import('./starters.js'); + await expect(getRequiredNPMClientMocked()).rejects.toThrow('No supported package manager found.'); + + vi.doUnmock('../internal/node.js'); + vi.resetModules(); + }); +}); + +describe('startStarter with npm', () => { + it('should start the dev server using npm when pnpm is unavailable', async () => { + vi.resetModules(); + vi.doMock('../internal/node.js', () => ({ + getNPMClient: vi.fn().mockResolvedValue('npm'), + isCommandAvailable: vi.fn(), + getPackageJson: vi.fn() + })); + + const { execFileSync } = await import('node:child_process'); + const { startStarter: startStarterMocked } = await import('./starters.js'); + const extractedPath = '/tmp/npm-starter'; + + await startStarterMocked(extractedPath); + + expect(execFileSync).toHaveBeenCalledWith('npm', ['run', 'dev'], { cwd: extractedPath, stdio: 'inherit' }); + + vi.doUnmock('../internal/node.js'); + vi.resetModules(); + }); +}); diff --git a/projects/internals/tools/src/project/starters.ts b/projects/internals/tools/src/project/starters.ts index ff40b241d..cabcaa90d 100644 --- a/projects/internals/tools/src/project/starters.ts +++ b/projects/internals/tools/src/project/starters.ts @@ -277,26 +277,39 @@ function isGitRepository(directoryPath: string) { } /* istanbul ignore next -- @preserve */ -async function setupStarterNPM(extractedDir: string) { - try { - await installFromRegistry(extractedDir); - } catch (e) { - const npmClient = await getNPMClient(); - const stderr = (e as { stderr?: Buffer })?.stderr?.toString?.().trim(); - console.error(stderr || e); - console.error(getDependencyInstallFailureMessage(extractedDir, npmClient)); +async function setupStarterNPM(cwd: string) { + console.log('📦 Installing dependencies...'); + const npmClient = await getRequiredNPMClient(); + const { code, stdout, stderr } = await runPackageManagerInstall(npmClient, cwd); + + if (code === 0) { + return; + } + + const output = `${stdout}\n${stderr}`; + if (output.includes('ERR_PNPM_IGNORED_BUILDS')) { + console.log('⚠️ Some dependency build scripts were skipped. Run "pnpm approve-builds" if needed.'); + return; } + + const message = output.trim() || `${npmClient} install exited with code ${code}`; + throw new Error(message); } /* istanbul ignore next -- @preserve */ -async function installFromRegistry(extractedDir: string) { - const npmClient = await getRequiredNPMClient(); - console.log('📦 Installing dependencies...'); - await new Promise((resolve, reject) => { - const child = execPackageManager(npmClient, ['install'], extractedDir); - child.on('close', code => - code === 0 ? resolve() : reject(new Error(`${npmClient} install exited with code ${code}`)) - ); +function runPackageManagerInstall(npmClient: NPMClient, cwd: string) { + return new Promise<{ code: number; stdout: string; stderr: string }>((resolve, reject) => { + const child = execPackageManager(npmClient, ['install'], cwd); + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + child.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + child.on('close', code => resolve({ code: code ?? 1, stdout, stderr })); child.on('error', reject); }); } @@ -319,13 +332,13 @@ export async function startStarter(extractedPath: string) { } } -async function getRequiredNPMClient() { +export async function getRequiredNPMClient() { const npmClient = await getNPMClient(); if (npmClient === 'npm' || npmClient === 'pnpm') return npmClient; throw new Error('No supported package manager found.'); } -function execPackageManager(npmClient: NPMClient, args: string[], cwd: string) { +export function execPackageManager(npmClient: NPMClient, args: string[], cwd: string) { return npmClient === 'pnpm' ? execFile('pnpm', args, { cwd }) : execFile('npm', args, { cwd }); } diff --git a/projects/internals/tools/src/project/update.test.ts b/projects/internals/tools/src/project/update.test.ts index f83e3b7b4..debf0b74d 100644 --- a/projects/internals/tools/src/project/update.test.ts +++ b/projects/internals/tools/src/project/update.test.ts @@ -399,7 +399,170 @@ describe('updateProject', () => { expect(result.dependencies.status).toBe('success'); expect(writeFileSync).toHaveBeenCalledWith(`${cwd}/package.json`, expect.stringContaining('"2.0.0"')); - expect(execFileSync).toHaveBeenCalledWith('pnpm', ['update', '@nvidia-elements/*', '@nvidia-elements/*'], { cwd }); + expect(execFileSync).toHaveBeenCalledWith('pnpm', ['update', '@nvidia-elements/*'], { cwd }); + }); + + it('should treat pnpm ignored builds as non-fatal', async () => { + const { execFileSync } = await import('node:child_process'); + const { ProjectsService } = await import('@internals/metadata'); + const { getLatestPublishedVersions } = await import('../api/utils.js'); + const { getNPMClient, getPackageJson } = await import('../internal/node.js'); + + vi.mocked(getPackageJson).mockReturnValue({ + dependencies: { '@nvidia-elements/core': '1.0.0' }, + devDependencies: {}, + peerDependencies: {} + }); + vi.mocked(ProjectsService.getData).mockResolvedValue({ data: [{ changelog: 'core' }] }); + vi.mocked(getLatestPublishedVersions).mockResolvedValue( + createMockElementVersions({ '@nvidia-elements/core': '2.0.0' }) + ); + vi.mocked(getNPMClient).mockResolvedValue('pnpm'); + vi.mocked(execFileSync).mockImplementation(() => { + const error = new Error('Command failed') as Error & { stderr: Buffer }; + error.stderr = Buffer.from('ERR_PNPM_IGNORED_BUILDS Ignored build scripts: esbuild'); + throw error; + }); + + const result = await updateProject('/tmp/project'); + + expect(result.dependencies.status).toBe('success'); + expect(result.dependencies.message).toContain('@nvidia-elements/core: 1.0.0 → 2.0.0'); + }); + + it('should report success when all packages are already up to date', async () => { + const { writeFileSync } = await import('node:fs'); + const { execFileSync } = await import('node:child_process'); + const { ProjectsService } = await import('@internals/metadata'); + const { getLatestPublishedVersions } = await import('../api/utils.js'); + const { getNPMClient, getPackageJson } = await import('../internal/node.js'); + + vi.mocked(getPackageJson).mockReturnValue({ + dependencies: { '@nvidia-elements/core': '2.0.0' }, + devDependencies: {}, + peerDependencies: {} + }); + vi.mocked(ProjectsService.getData).mockResolvedValue({ data: [{ changelog: 'core' }] }); + vi.mocked(getLatestPublishedVersions).mockResolvedValue( + createMockElementVersions({ '@nvidia-elements/core': '2.0.0' }) + ); + vi.mocked(getNPMClient).mockResolvedValue('pnpm'); + + const result = await updateProject('/tmp/project'); + + expect(result.dependencies.status).toBe('success'); + expect(result.dependencies.message).toContain('All packages are already up to date'); + expect(writeFileSync).not.toHaveBeenCalled(); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + it('should report danger with command output when the install fails', async () => { + const { execFileSync } = await import('node:child_process'); + const { ProjectsService } = await import('@internals/metadata'); + const { getLatestPublishedVersions } = await import('../api/utils.js'); + const { getNPMClient, getPackageJson } = await import('../internal/node.js'); + + vi.mocked(getPackageJson).mockReturnValue({ + dependencies: { '@nvidia-elements/core': '1.0.0' }, + devDependencies: {}, + peerDependencies: {} + }); + vi.mocked(ProjectsService.getData).mockResolvedValue({ data: [{ changelog: 'core' }] }); + vi.mocked(getLatestPublishedVersions).mockResolvedValue( + createMockElementVersions({ '@nvidia-elements/core': '2.0.0' }) + ); + vi.mocked(getNPMClient).mockResolvedValue('pnpm'); + vi.mocked(execFileSync).mockImplementation(() => { + const error = new Error('Command failed') as Error & { stderr: Buffer }; + error.stderr = Buffer.from('ENOTFOUND registry.npmjs.org'); + throw error; + }); + + const result = await updateProject('/tmp/project'); + + expect(result.dependencies.status).toBe('danger'); + expect(result.dependencies.message).toContain('Failed to update to the latest version'); + expect(result.dependencies.message).toContain('ENOTFOUND registry.npmjs.org'); + }); + + it('should stringify non-object errors from the install command', async () => { + const { execFileSync } = await import('node:child_process'); + const { ProjectsService } = await import('@internals/metadata'); + const { getLatestPublishedVersions } = await import('../api/utils.js'); + const { getNPMClient, getPackageJson } = await import('../internal/node.js'); + + vi.mocked(getPackageJson).mockReturnValue({ + dependencies: { '@nvidia-elements/core': '1.0.0' }, + devDependencies: {}, + peerDependencies: {} + }); + vi.mocked(ProjectsService.getData).mockResolvedValue({ data: [{ changelog: 'core' }] }); + vi.mocked(getLatestPublishedVersions).mockResolvedValue( + createMockElementVersions({ '@nvidia-elements/core': '2.0.0' }) + ); + vi.mocked(getNPMClient).mockResolvedValue('pnpm'); + vi.mocked(execFileSync).mockImplementation(() => { + const failure: unknown = 'string failure'; + throw failure; + }); + + const result = await updateProject('/tmp/project'); + + expect(result.dependencies.status).toBe('danger'); + expect(result.dependencies.message).toContain('string failure'); + }); + + it('should fall back to the error message when no command output is present', async () => { + const { execFileSync } = await import('node:child_process'); + const { ProjectsService } = await import('@internals/metadata'); + const { getLatestPublishedVersions } = await import('../api/utils.js'); + const { getNPMClient, getPackageJson } = await import('../internal/node.js'); + + vi.mocked(getPackageJson).mockReturnValue({ + dependencies: { '@nvidia-elements/core': '1.0.0' }, + devDependencies: {}, + peerDependencies: {} + }); + vi.mocked(ProjectsService.getData).mockResolvedValue({ data: [{ changelog: 'core' }] }); + vi.mocked(getLatestPublishedVersions).mockResolvedValue( + createMockElementVersions({ '@nvidia-elements/core': '2.0.0' }) + ); + vi.mocked(getNPMClient).mockResolvedValue('pnpm'); + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('npm exploded'); + }); + + const result = await updateProject('/tmp/project'); + + expect(result.dependencies.status).toBe('danger'); + expect(result.dependencies.message).toContain('npm exploded'); + }); + + it('should fall back to a generic message when the error has no details', async () => { + const { execFileSync } = await import('node:child_process'); + const { ProjectsService } = await import('@internals/metadata'); + const { getLatestPublishedVersions } = await import('../api/utils.js'); + const { getNPMClient, getPackageJson } = await import('../internal/node.js'); + + vi.mocked(getPackageJson).mockReturnValue({ + dependencies: { '@nvidia-elements/core': '1.0.0' }, + devDependencies: {}, + peerDependencies: {} + }); + vi.mocked(ProjectsService.getData).mockResolvedValue({ data: [{ changelog: 'core' }] }); + vi.mocked(getLatestPublishedVersions).mockResolvedValue( + createMockElementVersions({ '@nvidia-elements/core': '2.0.0' }) + ); + vi.mocked(getNPMClient).mockResolvedValue('pnpm'); + vi.mocked(execFileSync).mockImplementation(() => { + const failure: unknown = {}; + throw failure; + }); + + const result = await updateProject('/tmp/project'); + + expect(result.dependencies.status).toBe('danger'); + expect(result.dependencies.message).toContain('Command failed'); }); it('should not write package.json when no package manager exists', async () => { diff --git a/projects/internals/tools/src/project/update.ts b/projects/internals/tools/src/project/update.ts index 6edd5ad68..d1d606878 100644 --- a/projects/internals/tools/src/project/update.ts +++ b/projects/internals/tools/src/project/update.ts @@ -89,11 +89,16 @@ function updateProjectDependencies(options: ProjectDependencyUpdateOptions): Rep try { writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); - execFileSync(packageManager, ['update', '@nvidia-elements/*', '@nvidia-elements/*'], { cwd }); + execFileSync(packageManager, ['update', '@nvidia-elements/*'], { cwd }); } catch (e) { + const output = getExecFileSyncOutput(e); + if (packageManager === 'pnpm' && output.includes('ERR_PNPM_IGNORED_BUILDS')) { + return null; + } + return { dependencies: { - message: `Failed to update to the latest version. \n${e}`, + message: `Failed to update to the latest version. \n${output}`, status: 'danger' } }; @@ -102,6 +107,19 @@ function updateProjectDependencies(options: ProjectDependencyUpdateOptions): Rep return null; } +function getExecFileSyncOutput(error: unknown) { + if (typeof error !== 'object' || error === null) { + return `${error}`; + } + + const execError = error as { stdout?: Buffer | string; stderr?: Buffer | string; message?: string }; + const stdout = execError.stdout?.toString() ?? ''; + const stderr = execError.stderr?.toString() ?? ''; + const output = `${stdout}\n${stderr}`.trim(); + + return output || execError.message || 'Command failed'; +} + function createMissingPackageJsonReport(packageJsonPath: string): Report { return { dependencies: { diff --git a/projects/internals/tools/src/skills/doctor.md b/projects/internals/tools/src/skills/doctor.md index ad73766ba..84839ed05 100644 --- a/projects/internals/tools/src/skills/doctor.md +++ b/projects/internals/tools/src/skills/doctor.md @@ -47,6 +47,7 @@ Ensure the MCP is properly configured and working as expected. ```toml [mcp_servers.elements] +description = "NVIDIA Elements UI Design System (nve-*), custom element schemas, APIs and examples" command = "nve" args = ["mcp"] ``` diff --git a/projects/site/src/docs/mcp/index.md b/projects/site/src/docs/mcp/index.md index c99fb1f1e..5675365ac 100644 --- a/projects/site/src/docs/mcp/index.md +++ b/projects/site/src/docs/mcp/index.md @@ -81,9 +81,10 @@ After adding the configuration in the root of your project, restart Codex for th
-```shell +```toml # .codex/config.toml [mcp_servers.elements] +description = "NVIDIA Elements UI Design System (nve-*), custom element schemas, APIs and examples" command = "nve" args = ["mcp"] ``` From b01a0dc8e34f478ce2a1746aa18c82f6f654c302 Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Mon, 8 Jun 2026 19:19:28 -0500 Subject: [PATCH 2/2] fix(core): select multiple pointer event Signed-off-by: Cory Rylan --- projects/core/src/combobox/combobox.test.ts | 3 +-- projects/core/src/combobox/combobox.ts | 18 +++++++++--------- projects/core/src/select/select.global.css | 6 ++++++ projects/core/src/select/select.ts | 11 +++++++++-- 4 files changed, 25 insertions(+), 13 deletions(-) create mode 100644 projects/core/src/select/select.global.css diff --git a/projects/core/src/combobox/combobox.test.ts b/projects/core/src/combobox/combobox.test.ts index 9ada6a1a8..6cb291374 100644 --- a/projects/core/src/combobox/combobox.test.ts +++ b/projects/core/src/combobox/combobox.test.ts @@ -132,11 +132,10 @@ describe(Combobox.metadata.tag, () => { expect(dropdown.matches(':popover-open')).toBe(true); }); - it('should assign trigger and anchor to inner input container', async () => { + it('should assign anchor to inner input container', async () => { const dropdown = element.shadowRoot.querySelector(Dropdown.metadata.tag); const inputContainer = element.shadowRoot.querySelector('[input]'); expect(dropdown.anchor).toBe(inputContainer); - expect(dropdown.trigger).toBe(inputContainer); }); it('should hide options on escape keypress', async () => { diff --git a/projects/core/src/combobox/combobox.ts b/projects/core/src/combobox/combobox.ts index 45ef52cf9..d034cdd81 100644 --- a/projects/core/src/combobox/combobox.ts +++ b/projects/core/src/combobox/combobox.ts @@ -201,7 +201,7 @@ export class Combobox extends Control implements ContainerElement { const hasNoResults = visibleOptions.filter(o => !o.disabled).length === 0; const showCreateItem = this.#showCreateItem; return html` -