From 20eb61b65374ee3af68dd9dc93abab08f4af2886 Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Mon, 8 Jun 2026 19:58:09 -0500 Subject: [PATCH] chore(themes): add additional tests for css variable completions Signed-off-by: Cory Rylan --- pnpm-lock.yaml | 3 + projects/themes/build/css-var-completions.js | 152 +++++++++++------- .../themes/build/css-var-completions.test.js | 54 +++++++ .../themes/build/style-dictionary.config.js | 70 ++++---- .../build/style-dictionary.config.test.js | 66 ++++++++ projects/themes/package.json | 18 +++ projects/themes/src/index.test.ts | 44 +++++ projects/themes/vitest.config.ts | 12 ++ 8 files changed, 326 insertions(+), 93 deletions(-) create mode 100644 projects/themes/build/css-var-completions.test.js create mode 100644 projects/themes/build/style-dictionary.config.test.js create mode 100644 projects/themes/src/index.test.ts create mode 100644 projects/themes/vitest.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc579f00f..e1312bfe6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1959,6 +1959,9 @@ importers: '@internals/vite': specifier: workspace:* version: link:../internals/vite + '@vitest/coverage-istanbul': + specifier: 'catalog:' + version: 4.1.7(vitest@4.1.7) cssnano: specifier: 7.1.7 version: 7.1.7(postcss@8.5.15) diff --git a/projects/themes/build/css-var-completions.js b/projects/themes/build/css-var-completions.js index d820ec822..182665879 100644 --- a/projects/themes/build/css-var-completions.js +++ b/projects/themes/build/css-var-completions.js @@ -1,6 +1,7 @@ -import fs from 'fs'; -import path from 'path'; +import fs from 'node:fs'; +import path from 'node:path'; import process from 'process'; +import { fileURLToPath } from 'node:url'; const buildPath = 'dist/'; const sourcePath = 'src/'; @@ -11,7 +12,7 @@ function resolve(relativePath) { // --- -function readJSONFile(jsonFilePath) { +export function readJSONFile(jsonFilePath) { try { const fileContents = fs.readFileSync(jsonFilePath, 'utf-8'); return JSON.parse(fileContents); @@ -20,7 +21,7 @@ function readJSONFile(jsonFilePath) { } } -function writeJSONFile(jsonFilePath, data) { +export function writeJSONFile(jsonFilePath, data) { try { fs.writeFileSync(jsonFilePath, JSON.stringify(data, null, 2)); } catch (error) { @@ -30,13 +31,13 @@ function writeJSONFile(jsonFilePath, data) { // --- -function isObject(value) { +export function isObject(value) { return typeof value === 'object' && value !== null; } // --- -function visitTokenTree(tokens, visitor, prefix = '') { +export function visitTokenTree(tokens, visitor, prefix = '') { for (const [key, value] of Object.entries(tokens)) { const path = key !== '@' ? `${prefix}${key}` : prefix.slice(0, -1); if (isObject(value)) { @@ -49,7 +50,7 @@ function visitTokenTree(tokens, visitor, prefix = '') { } } -function loadTokenDictionary(tokenJsonFilePath) { +export function loadTokenDictionary(tokenJsonFilePath) { const tokensByPath = {}; const tokensJson = readJSONFile(resolve(tokenJsonFilePath)); visitTokenTree(tokensJson, (path, token) => { @@ -64,31 +65,25 @@ function loadTokenDictionary(tokenJsonFilePath) { const REFERENCE_PATTERN = /\{([^}]*)\}/g; // Resolve all of the token value's path references (including resolution of their resolved values' path references). -function resolveTokenValue(value, tokenDictionary) { - while (value.match(REFERENCE_PATTERN)) { - value = value.replaceAll(REFERENCE_PATTERN, (_, referencedPath) => { - const referencedValue = tokenDictionary[referencedPath]?.value; - if (referencedValue === undefined) { - throw new Error(`Unable to resolve a referenced token for path: "${referencedPath}"`); - } - return referencedValue; - }); - } - return value; -} +export function resolveTokenValue(value, tokenDictionary, referencePath = []) { + return value.replaceAll(REFERENCE_PATTERN, (_, referencedPath) => { + if (referencePath.includes(referencedPath)) { + throw new Error(`Cyclic token reference: ${[...referencePath, referencedPath].join(' -> ')}`); + } -// --- + const referencedValue = tokenDictionary[referencedPath]?.value; + if (referencedValue === undefined) { + throw new Error(`Unable to resolve a referenced token for path: "${referencedPath}"`); + } -const baseTokenDictionary = loadTokenDictionary(`${sourcePath}/index.json`); -const compactThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/compact.json`); -const darkThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/dark.json`); -const highContrastThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/high-contrast.json`); -const reducedMotionThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/reduced-motion.json`); + return resolveTokenValue(referencedValue, tokenDictionary, [...referencePath, referencedPath]); + }); +} -const categorizedTokens = {}; +// --- // Collect a categorized token value for the specified category and path. -function collectCategorizedToken(path, details, category, value) { +function collectCategorizedToken(categorizedTokens, path, details, category, value) { if (categorizedTokens[path] === undefined) { categorizedTokens[path] = { ...details, values: { [category]: value } }; } else { @@ -97,22 +92,16 @@ function collectCategorizedToken(path, details, category, value) { } // Collect categorized token values relative to the specified token dictionaries (last definition wins). -function collectCategorizedTokens(category, ...tokenDictionaries) { +function collectCategorizedTokens(categorizedTokens, category, ...tokenDictionaries) { const mergedTokenDictionary = Object.assign({}, ...tokenDictionaries); for (const [path, token] of Object.entries(mergedTokenDictionary)) { const { value, ...details } = token; - const resolvedValue = resolveTokenValue(value, mergedTokenDictionary); - collectCategorizedToken(path, details, category, resolvedValue); + const resolvedValue = resolveTokenValue(value, mergedTokenDictionary, [path]); + collectCategorizedToken(categorizedTokens, path, details, category, resolvedValue); } } -collectCategorizedTokens('light', baseTokenDictionary); -collectCategorizedTokens('dark', baseTokenDictionary, darkThemeTokenDictionary); -collectCategorizedTokens('high-contrast', baseTokenDictionary, highContrastThemeTokenDictionary); -collectCategorizedTokens('compact', baseTokenDictionary, compactThemeTokenDictionary); -collectCategorizedTokens('reduced-motion', baseTokenDictionary, reducedMotionThemeTokenDictionary); - -function valuesMatch(values) { +export function valuesMatch(values) { if (values.length === 0) { return true; } @@ -125,37 +114,78 @@ function valuesMatch(values) { return true; } -function categoryValuesMatch(values, categories) { +export function categoryValuesMatch(values, categories) { return valuesMatch(categories.map(category => values[category])); } -// Consolidate categorized token values that are the same. -for (const token of Object.values(categorizedTokens)) { - const values = token.values; - if (categoryValuesMatch(values, ['light', 'dark'])) { - values[''] = values['light']; - delete values['light']; - delete values['dark']; - } - if (categoryValuesMatch(values, ['', 'high-contrast']) || categoryValuesMatch(values, ['light', 'high-contrast'])) { - delete values['high-contrast']; - } - if (categoryValuesMatch(values, ['', 'compact']) || categoryValuesMatch(values, ['light', 'compact'])) { - delete values['compact']; +export function createCssVarCompletions({ + baseTokenDictionary, + compactThemeTokenDictionary, + darkThemeTokenDictionary, + highContrastThemeTokenDictionary, + reducedMotionThemeTokenDictionary +}) { + const categorizedTokens = {}; + + collectCategorizedTokens(categorizedTokens, 'light', baseTokenDictionary); + collectCategorizedTokens(categorizedTokens, 'dark', baseTokenDictionary, darkThemeTokenDictionary); + collectCategorizedTokens(categorizedTokens, 'high-contrast', baseTokenDictionary, highContrastThemeTokenDictionary); + collectCategorizedTokens(categorizedTokens, 'compact', baseTokenDictionary, compactThemeTokenDictionary); + collectCategorizedTokens(categorizedTokens, 'reduced-motion', baseTokenDictionary, reducedMotionThemeTokenDictionary); + + // Consolidate categorized token values that are the same. + for (const token of Object.values(categorizedTokens)) { + const values = token.values; + if (categoryValuesMatch(values, ['light', 'dark'])) { + values[''] = values['light']; + delete values['light']; + delete values['dark']; + } + if (categoryValuesMatch(values, ['', 'high-contrast']) || categoryValuesMatch(values, ['light', 'high-contrast'])) { + delete values['high-contrast']; + } + if (categoryValuesMatch(values, ['', 'compact']) || categoryValuesMatch(values, ['light', 'compact'])) { + delete values['compact']; + } + if ( + categoryValuesMatch(values, ['', 'reduced-motion']) || + categoryValuesMatch(values, ['light', 'reduced-motion']) + ) { + delete values['reduced-motion']; + } } - if (categoryValuesMatch(values, ['', 'reduced-motion']) || categoryValuesMatch(values, ['light', 'reduced-motion'])) { - delete values['reduced-motion']; + + // Collect all tokens with their paths transformed to css variable identifiers. + const cssVarCompletions = {}; + for (const [path, token] of Object.entries(categorizedTokens)) { + cssVarCompletions[`--nve-${path.replaceAll('.', '-')}`] = token; } -} -// Collect all tokens with their paths transformed to css variable identifiers. -const cssVarCompletions = {}; -for (const [path, token] of Object.entries(categorizedTokens)) { - cssVarCompletions[`--nve-${path.replaceAll('.', '-')}`] = token; + return cssVarCompletions; } -if (!fs.existsSync(`${buildPath}`)) { - fs.mkdirSync(`${buildPath}`); +export function buildCssVarCompletions() { + const baseTokenDictionary = loadTokenDictionary(`${sourcePath}/index.json`); + const compactThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/compact.json`); + const darkThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/dark.json`); + const highContrastThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/high-contrast.json`); + const reducedMotionThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/reduced-motion.json`); + + const cssVarCompletions = createCssVarCompletions({ + baseTokenDictionary, + compactThemeTokenDictionary, + darkThemeTokenDictionary, + highContrastThemeTokenDictionary, + reducedMotionThemeTokenDictionary + }); + + if (!fs.existsSync(`${buildPath}`)) { + fs.mkdirSync(`${buildPath}`); + } + + writeJSONFile('./dist/data.css-vars.json', cssVarCompletions); } -writeJSONFile('./dist/data.css-vars.json', cssVarCompletions); +if (process.argv[1] === fileURLToPath(import.meta.url)) { + buildCssVarCompletions(); +} diff --git a/projects/themes/build/css-var-completions.test.js b/projects/themes/build/css-var-completions.test.js new file mode 100644 index 000000000..5f8ea40c9 --- /dev/null +++ b/projects/themes/build/css-var-completions.test.js @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { createCssVarCompletions, resolveTokenValue } from './css-var-completions.js'; + +describe('css variable completions', () => { + it('should resolve references and collapse matching theme values', () => { + const completions = createCssVarCompletions({ + baseTokenDictionary: { + 'ref.color.base': { value: 'white', type: 'color' }, + 'ref.scale.space': { value: '1' }, + 'ref.space.sm': { value: '{ref.scale.space} * 8px', type: 'spacing' }, + 'sys.layer.canvas.background': { value: '{ref.color.base}', type: 'color' } + }, + darkThemeTokenDictionary: { + 'ref.color.base': { value: 'black', type: 'color' } + }, + highContrastThemeTokenDictionary: { + 'ref.color.base': { value: 'white', type: 'color' } + }, + compactThemeTokenDictionary: { + 'ref.scale.space': { value: '0.8' } + }, + reducedMotionThemeTokenDictionary: {} + }); + + expect(completions['--nve-sys-layer-canvas-background'].values).toEqual({ + light: 'white', + dark: 'black' + }); + expect(completions['--nve-ref-space-sm'].values).toEqual({ + '': '1 * 8px', + compact: '0.8 * 8px' + }); + }); + + it('should fail when a token reference cannot be resolved', () => { + expect(() => resolveTokenValue('{ref.color.missing}', {}, ['sys.color.text'])).toThrow( + 'Unable to resolve a referenced token for path: "ref.color.missing"' + ); + }); + + it('should fail when token references are cyclic', () => { + const tokenDictionary = { + 'ref.color.a': { value: '{ref.color.b}' }, + 'ref.color.b': { value: '{ref.color.a}' } + }; + + expect(() => resolveTokenValue('{ref.color.a}', tokenDictionary, ['sys.color.text'])).toThrow( + 'Cyclic token reference: sys.color.text -> ref.color.a -> ref.color.b -> ref.color.a' + ); + }); +}); diff --git a/projects/themes/build/style-dictionary.config.js b/projects/themes/build/style-dictionary.config.js index b4aaa2886..2326fbf5a 100644 --- a/projects/themes/build/style-dictionary.config.js +++ b/projects/themes/build/style-dictionary.config.js @@ -1,6 +1,8 @@ import StyleDictionary from 'style-dictionary'; import { formattedVariables } from 'style-dictionary/utils'; import { globSync } from 'glob'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; const buildPath = 'dist/'; const sourcePath = 'src/'; @@ -68,38 +70,40 @@ StyleDictionary.registerTransform({ name: 'custom/validate', type: 'value', transitive: true, - transform: obj => { - const { value, type, name, original, filePath } = obj; - const isHighContrast = filePath.includes('high-contrast'); - const isReferenceToken = name.includes('nve-ref'); - const isVisualizationToken = name?.includes('nve-sys-visualization'); - const isColorToken = type === 'color'; - const isRawValue = !original.value.startsWith('{'); - const isPxValue = original.value.endsWith('px'); - const isSizeToken = name?.includes('nve-ref-size'); - const isSpaceToken = name?.includes('nve-ref-space'); - const isBorderToken = name?.includes('nve-ref-border'); - const isOutlineToken = name?.includes('nve-ref-outline'); - - if (isColorToken && isRawValue && !isReferenceToken && !isVisualizationToken && !isHighContrast) { - console.error( - '\x1b[31m', - `Token ${name} is a invalid color. Color must implement a reference to a {ref.*} token to prevent cross theme color divergence` - ); - throw new Error(); - } + transform: validateTokenValue +}); - if (isPxValue && isRawValue && !isSizeToken && !isSpaceToken && !isBorderToken && !isOutlineToken) { - console.error( - '\x1b[31m', - `Token ${name} is a invalid size/space value. Value must implement a reference to a {ref.space-*} or {ref.size-*} token to prevent cross theme layout divergence` - ); - throw new Error(); - } +export function validateTokenValue(obj) { + const { value, type, name, original, filePath } = obj; + const isHighContrast = filePath.includes('high-contrast'); + const isReferenceToken = name.includes('nve-ref'); + const isVisualizationToken = name?.includes('nve-sys-visualization'); + const isColorToken = type === 'color'; + const isRawValue = !original.value.startsWith('{'); + const isPxValue = original.value.endsWith('px'); + const isSizeToken = name?.includes('nve-ref-size'); + const isSpaceToken = name?.includes('nve-ref-space'); + const isBorderToken = name?.includes('nve-ref-border'); + const isOutlineToken = name?.includes('nve-ref-outline'); + + if (isColorToken && isRawValue && !isReferenceToken && !isVisualizationToken && !isHighContrast) { + throw new Error( + `Token ${name} is an invalid color. Color must use a {ref.*} token reference to prevent cross-theme color divergence` + ); + } - return value; + if (isPxValue && isRawValue && !isSizeToken && !isSpaceToken && !isBorderToken && !isOutlineToken) { + throw new Error( + `Token ${name} is an invalid size or space value. Value must use a {ref.space-*} or {ref.size-*} token reference to prevent cross-theme layout divergence` + ); } -}); + + return value; +} + +export function getThemeSelector(theme) { + return theme !== 'index' ? `[nve-theme~='${theme}']` : `:root, [nve-theme~='light']`; +} StyleDictionary.registerFormat({ name: 'custom/css', @@ -107,7 +111,7 @@ StyleDictionary.registerFormat({ const experimental = dictionary.allTokens.find(t => t.name.includes('experimental')) ? '/*!\n * @experimental\n */' : ''; - const selector = options.theme !== 'index' ? `[nve-theme*='${options.theme}']` : `:root, [nve-theme~='light']`; + const selector = getThemeSelector(options.theme); const config = dictionary.allTokens.filter(t => t.name.includes('config') && !t.name.includes('experimental')); const configString = `:root{${config.map(t => `--${t.name}: ${t.value}`).join(';\n')}}`; const formatted = formattedVariables({ format: 'css', dictionary, outputReferences: options.outputReferences }) @@ -195,7 +199,7 @@ StyleDictionary.registerFormat({ } }); -async function buildTokens() { +export async function buildTokens() { const themes = globSync(`${sourcePath}*.json`).filter(path => !path.includes('index')); const sd = new StyleDictionary({ @@ -328,4 +332,6 @@ function getTheme(path) { return path.replace('dist/', '').replace(`src/`, '').split('.')[0]; } -buildTokens(); +if (process.argv[1] === fileURLToPath(import.meta.url)) { + await buildTokens(); +} diff --git a/projects/themes/build/style-dictionary.config.test.js b/projects/themes/build/style-dictionary.config.test.js new file mode 100644 index 000000000..bc1f40882 --- /dev/null +++ b/projects/themes/build/style-dictionary.config.test.js @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { getThemeSelector, validateTokenValue } from './style-dictionary.config.js'; + +function createToken({ filePath = 'src/index.json', name, type, value }) { + return { + filePath, + name, + original: { value }, + type, + value + }; +} + +describe('style dictionary config', () => { + it('should reject raw semantic color values outside high contrast themes', () => { + expect(() => + validateTokenValue(createToken({ name: 'nve-sys-text-color', type: 'color', value: 'oklch(100% 0 0)' })) + ).toThrow('invalid color'); + }); + + it('should allow reference, visualization, and high contrast color exceptions', () => { + expect( + validateTokenValue(createToken({ name: 'nve-sys-text-color', type: 'color', value: '{ref.color.white}' })) + ).toBe('{ref.color.white}'); + expect( + validateTokenValue( + createToken({ name: 'nve-sys-visualization-blue', type: 'color', value: 'oklch(66% 0.18 250)' }) + ) + ).toBe('oklch(66% 0.18 250)'); + expect( + validateTokenValue( + createToken({ + filePath: 'src/high-contrast.json', + name: 'nve-sys-text-color', + type: 'color', + value: 'CanvasText' + }) + ) + ).toBe('CanvasText'); + }); + + it('should reject raw px values outside reference size, space, border, or outline tokens', () => { + expect(() => + validateTokenValue(createToken({ name: 'nve-sys-layout-gap', type: 'sizing', value: '12px' })) + ).toThrow('invalid size or space value'); + }); + + it('should allow raw px values for reference size, space, border, and outline tokens', () => { + expect(validateTokenValue(createToken({ name: 'nve-ref-size-100', type: 'sizing', value: '4px' }))).toBe('4px'); + expect(validateTokenValue(createToken({ name: 'nve-ref-space-sm', type: 'spacing', value: '12px' }))).toBe('12px'); + expect(validateTokenValue(createToken({ name: 'nve-ref-border-width', type: 'borderWidth', value: '1px' }))).toBe( + '1px' + ); + expect(validateTokenValue(createToken({ name: 'nve-ref-outline-width', type: 'sizing', value: '2px' }))).toBe( + '2px' + ); + }); + + it('should match theme tokens instead of substrings', () => { + expect(getThemeSelector('index')).toBe(":root, [nve-theme~='light']"); + expect(getThemeSelector('dark')).toBe("[nve-theme~='dark']"); + }); +}); diff --git a/projects/themes/package.json b/projects/themes/package.json index 9883d342a..3584701c7 100644 --- a/projects/themes/package.json +++ b/projects/themes/package.json @@ -33,6 +33,7 @@ "build": "wireit", "dev": "wireit", "lint": "wireit", + "test": "wireit", "test:lighthouse": "wireit", "test:visual": "wireit" }, @@ -45,6 +46,7 @@ ], "devDependencies": { "@internals/vite": "workspace:*", + "@vitest/coverage-istanbul": "catalog:", "stylelint": "catalog:", "stylelint-config-standard": "catalog:", "cssnano": "7.1.7", @@ -79,6 +81,7 @@ "dependencies": [ "build", "publint", + "test", "test:visual" ] }, @@ -114,6 +117,7 @@ "!src/**/*.test.visual.ts", "build/style-dictionary.config.js", "build/style-dictionary.minify.js", + "build/fonts.js", "package.json", "vite.config.ts" ], @@ -142,6 +146,20 @@ "lint:style" ] }, + "test": { + "command": "vitest run --config=vitest.config.ts", + "files": [ + "build/**/*.js", + "dist/**/*.css", + "src/**/*.test.ts", + "vitest.config.ts" + ], + "output": [], + "dependencies": [ + "build", + "../internals/vite:ci" + ] + }, "lint:style": { "command": "stylelint 'src/**/*.css' --config=../../stylelint.config.mjs", "files": [ diff --git a/projects/themes/src/index.test.ts b/projects/themes/src/index.test.ts new file mode 100644 index 000000000..dd3083a4b --- /dev/null +++ b/projects/themes/src/index.test.ts @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFile } from 'node:fs/promises'; +import { describe, expect, it } from 'vitest'; + +async function readThemeCss(theme: string) { + return readFile(new URL(`../dist/${theme}.css`, import.meta.url), 'utf8'); +} + +describe('@nvidia-elements/themes', () => { + it('should generate compact theme scale overrides', async () => { + const css = await readThemeCss('compact'); + + expect(css).toContain('[nve-theme~=compact]'); + expect(css).toContain('--nve-ref-scale-size:0.95'); + expect(css).toContain('--nve-ref-scale-space:0.8'); + }); + + it('should generate high contrast system color overrides', async () => { + const css = await readThemeCss('high-contrast'); + + expect(css).toContain('[nve-theme~=high-contrast]'); + expect(css).toContain('--nve-sys-text-color:CanvasText'); + expect(css).toContain('--nve-sys-layer-canvas-background:Canvas'); + expect(css).toContain('--nve-ref-border-color:CanvasText'); + }); + + it('should generate reduced motion duration overrides', async () => { + const css = await readThemeCss('reduced-motion'); + + expect(css).toContain('[nve-theme~=reduced-motion]'); + expect(css).toContain('--nve-ref-animation-duration-100:0'); + expect(css).toContain('--nve-ref-animation-duration-400:1000ms'); + }); + + it('should generate debug theme outline tokens', async () => { + const css = await readThemeCss('debug'); + + expect(css).toContain('[nve-theme~=debug]'); + expect(css).toContain('--nve-debug-layout-outline:var(--nve-debug-outline-width) solid'); + expect(css).toContain('--nve-debug-outline-width:var(--nve-ref-size-50)'); + }); +}); diff --git a/projects/themes/vitest.config.ts b/projects/themes/vitest.config.ts new file mode 100644 index 000000000..7f0ef6a93 --- /dev/null +++ b/projects/themes/vitest.config.ts @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { mergeConfig } from 'vitest/config'; +import { libraryNodeTestConfig } from '@internals/vite/configs/test.node.js'; + +export default mergeConfig(libraryNodeTestConfig, { + root: import.meta.dirname, + test: { + include: ['build/**/*.test.js', 'src/**/*.test.ts'] + } +});