import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import test from 'node:test' import { generateNextSprite, generateReactSprite } from '../dist/index.js' const GENERATED_FILES = [ 'icon-data.d.ts', 'icon-data.js', 'index.d.ts', 'index.js', 'react', 'sprite.svg', 'svg-sprite.manifest.d.ts', 'svg-sprite.manifest.js', ] function createReactFixture(config = {}) { const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-')) const rootDir = path.join(temporaryDir, 'src', 'widgets', 'file-manager', 'svg-sprite') const iconsDir = path.join(rootDir, 'icons') const configPath = path.join(rootDir, 'svg-sprite.config.ts') fs.mkdirSync(iconsDir, { recursive: true }) fs.writeFileSync(configPath, `export default ${JSON.stringify({ name: 'file-manager', description: 'File manager icons', ...config, }, null, 2)}\n`) fs.writeFileSync( path.join(iconsDir, 'check.svg'), '', ) fs.writeFileSync( path.join(iconsDir, 'folder.svg'), '', ) fs.writeFileSync( path.join(iconsDir, 'folder open.svg'), '', ) return { rootDir, iconsDir, configPath } } test('generates the current isolated React Vite contract', async () => { const { rootDir, configPath } = createReactFixture() const result = await generateReactSprite(configPath, 'vite') const generatedDir = path.join(rootDir, '.svg-sprite') assert.equal(result.name, 'file-manager') assert.equal(result.iconCount, 3) assert.equal(result.target, 'vite') assert.equal(result.manifestPath, path.join(generatedDir, 'svg-sprite.manifest.js')) assert.deepEqual(fs.readdirSync(generatedDir).sort(), GENERATED_FILES) const gitignore = fs.readFileSync(path.join(rootDir, '.gitignore'), 'utf8') assert.equal(gitignore, '# @generated by @gromlab/svg-sprites. Do not edit.\n/.svg-sprite/\n') const sprite = fs.readFileSync(result.spritePath, 'utf8') assert.match(sprite, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/) assert.match(sprite, /]+id="check"/) assert.match(sprite, /]+id="folder"/) assert.doesNotMatch(sprite, /]+id="(icon-[a-f0-9]{16})"/)?.[1] assert.ok(unsafeId) const component = fs.readFileSync(path.join(generatedDir, 'react', 'react-component.js'), 'utf8') const componentTypes = fs.readFileSync(path.join(generatedDir, 'react', 'react-component.d.ts'), 'utf8') const iconData = fs.readFileSync(path.join(generatedDir, 'icon-data.js'), 'utf8') const manifest = fs.readFileSync(result.manifestPath, 'utf8') const manifestTypes = fs.readFileSync(path.join(generatedDir, 'svg-sprite.manifest.d.ts'), 'utf8') assert.match(component, /export const FileManagerIcon/) assert.match(component, /sprite\.svg\?no-inline/) assert.match(componentTypes, /FileManagerIconStyle/) assert.match(componentTypes, /--icon-color-/) assert.ok(iconData.includes(`"folder open": "${unsafeId}"`)) assert.match(manifest, /"componentName": "FileManagerIcon"/) assert.match(manifest, /"mode": "react@vite"/) assert.match(manifest, /"target": "vite"/) assert.match(manifest, /"iconCount": 3/) assert.match(manifestTypes, /export type SpriteManifest/) assert.match(manifestTypes, /mode: 'react@vite'/) assert.doesNotMatch(manifestTypes, /@gromlab\/svg-sprites\/react/) }) test('supports Webpack target and a custom icons directory', async () => { const { rootDir, configPath } = createReactFixture({ name: 'documents', input: './assets', generatedNotice: false, }) fs.renameSync(path.join(rootDir, 'icons'), path.join(rootDir, 'assets')) fs.mkdirSync(path.join(rootDir, 'assets', 'nested')) fs.writeFileSync( path.join(rootDir, 'assets', 'nested', 'ignored.svg'), '', ) const result = await generateReactSprite(configPath, 'webpack') const component = fs.readFileSync(path.join(result.generatedDir, 'react', 'react-component.js'), 'utf8') const manifest = fs.readFileSync(result.manifestPath, 'utf8') const manifestTypes = fs.readFileSync(path.join(result.generatedDir, 'svg-sprite.manifest.d.ts'), 'utf8') assert.equal(result.name, 'documents') assert.equal(result.iconCount, 3) assert.equal(result.target, 'webpack') assert.match(component, /@generated by @gromlab\/svg-sprites/) assert.doesNotMatch(component, /АВТОМАТИЧЕСКИ/) assert.match(component, /DocumentsIcon/) assert.match(component, /new URL\('\.\.\/sprite\.svg', import\.meta\.url\)\.href/) assert.match(manifest, /"target": "webpack"/) assert.match(manifestTypes, /mode: 'react@webpack'/) assert.doesNotMatch(manifestTypes, /@gromlab\/svg-sprites\/react/) }) test('combines input paths and glob patterns and deduplicates identical files', async () => { const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-inputs-')) const rootDir = path.join(temporaryDir, 'svg-sprite') const iconsDir = path.join(rootDir, 'icons') const sharedIcon = path.join(temporaryDir, 'shared.svg') fs.mkdirSync(iconsDir, { recursive: true }) fs.writeFileSync(path.join(iconsDir, 'local.svg'), '') fs.writeFileSync(sharedIcon, '') const configPath = path.join(rootDir, 'config.ts') fs.writeFileSync(configPath, `export default { name: 'mixed-inputs', input: ['./icons/*.svg', ${JSON.stringify(path.relative(rootDir, sharedIcon))}, ${JSON.stringify(path.relative(rootDir, sharedIcon))}] }`) const result = await generateReactSprite(configPath, 'vite') const sprite = fs.readFileSync(result.spritePath, 'utf8') assert.equal(result.name, 'mixed-inputs') assert.equal(result.iconCount, 2) assert.match(sprite, /id="local"/) assert.match(sprite, /id="shared"/) }) test('supports one input file without the default icons directory', async () => { const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-files-')) const rootDir = path.join(temporaryDir, 'svg-sprite') const sharedIcon = path.join(temporaryDir, 'shared', 'only.svg') fs.mkdirSync(rootDir, { recursive: true }) fs.mkdirSync(path.dirname(sharedIcon), { recursive: true }) fs.writeFileSync(sharedIcon, '') const configPath = path.join(rootDir, 'config.ts') fs.writeFileSync(configPath, `export default { input: ${JSON.stringify(path.relative(rootDir, sharedIcon))} }`) const result = await generateReactSprite(configPath, 'webpack') assert.equal(result.iconCount, 1) assert.match(fs.readFileSync(result.spritePath, 'utf8'), /id="only"/) }) test('supports recursive input globs and exclusions', async () => { const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-globs-')) const rootDir = path.join(temporaryDir, 'svg-sprite') const nestedDir = path.join(rootDir, 'icons', 'nested') fs.mkdirSync(nestedDir, { recursive: true }) fs.writeFileSync(path.join(rootDir, 'icons', 'local.svg'), '') fs.writeFileSync(path.join(nestedDir, 'included.svg'), '') fs.writeFileSync(path.join(nestedDir, 'draft.svg'), '') const configPath = path.join(rootDir, 'config.ts') fs.writeFileSync(configPath, "export default { input: ['icons/**/*.svg', '!icons/**/draft.svg'] }") const result = await generateReactSprite(configPath, 'vite') const sprite = fs.readFileSync(result.spritePath, 'utf8') assert.equal(result.iconCount, 2) assert.match(sprite, /id="local"/) assert.match(sprite, /id="included"/) assert.doesNotMatch(sprite, /id="draft"/) }) test('treats an existing excluded path as a literal', async () => { const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-literal-exclusion-')) const rootDir = path.join(temporaryDir, 'svg-sprite') const iconsDir = path.join(rootDir, 'icons') fs.mkdirSync(iconsDir, { recursive: true }) fs.writeFileSync(path.join(iconsDir, 'icon[1].svg'), '') fs.writeFileSync(path.join(iconsDir, 'icon1.svg'), '') const configPath = path.join(rootDir, 'config.ts') fs.writeFileSync(configPath, "export default { input: ['icons/*.svg', '!icons/icon[1].svg'] }") const result = await generateReactSprite(configPath, 'vite') const sprite = fs.readFileSync(result.spritePath, 'utf8') assert.equal(result.iconCount, 1) assert.match(sprite, /id="icon1"/) }) test('rejects invalid, missing, and conflicting inputs', async () => { const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-input-errors-')) const rootDir = path.join(temporaryDir, 'svg-sprite') const first = path.join(temporaryDir, 'one', 'shared.svg') const second = path.join(temporaryDir, 'two', 'shared.svg') fs.mkdirSync(rootDir, { recursive: true }) fs.mkdirSync(path.dirname(first), { recursive: true }) fs.mkdirSync(path.dirname(second), { recursive: true }) fs.writeFileSync(first, '') fs.writeFileSync(second, '') const duplicateConfig = path.join(rootDir, 'duplicate.ts') fs.writeFileSync(duplicateConfig, `export default { input: [${JSON.stringify(path.relative(rootDir, first))}, ${JSON.stringify(path.relative(rootDir, second))}] }`) await assert.rejects(generateReactSprite(duplicateConfig, 'vite'), /produce the same SVG id "shared"/) const missingConfig = path.join(rootDir, 'missing.ts') fs.writeFileSync(missingConfig, "export default { input: './missing' }") await assert.rejects(generateReactSprite(missingConfig, 'vite'), /Input path does not exist/) const unmatchedConfig = path.join(rootDir, 'unmatched.ts') fs.writeFileSync(unmatchedConfig, "export default { input: './missing/*.svg' }") await assert.rejects(generateReactSprite(unmatchedConfig, 'vite'), /Input matched no SVG files/) const emptyConfig = path.join(rootDir, 'empty.ts') fs.writeFileSync(emptyConfig, 'export default { input: [] }') await assert.rejects(generateReactSprite(emptyConfig, 'vite'), /"input" must be a non-empty string or an array/) const legacyConfig = path.join(rootDir, 'legacy.ts') fs.writeFileSync(legacyConfig, "export default { inputFolder: './icons' }") await assert.rejects(generateReactSprite(legacyConfig, 'vite'), /"inputFolder" and "inputFiles" are no longer supported/) }) test('applies transform options to SVG and React styles', async () => { const { rootDir, configPath } = createReactFixture({ transform: { removeSize: false, replaceColors: false, addTransition: false }, }) fs.writeFileSync( path.join(rootDir, 'icons', 'plain.svg'), '', ) const result = await generateReactSprite(configPath, 'vite') const sprite = fs.readFileSync(result.spritePath, 'utf8') const styles = fs.readFileSync(path.join(result.generatedDir, 'react', 'react-component.module.css'), 'utf8') assert.match(sprite, /width="24"/) assert.match(sprite, /fill="#(?:123456|123)"/) assert.doesNotMatch(sprite, /--icon-color-/) assert.doesNotMatch(styles, /transition-/) }) test('generates every Next.js exact mode without a client boundary', async () => { const targets = [ { router: 'app', bundler: 'turbopack' }, { router: 'app', bundler: 'webpack' }, { router: 'pages', bundler: 'turbopack' }, { router: 'pages', bundler: 'webpack' }, ] for (const options of targets) { const { configPath } = createReactFixture() const result = await generateNextSprite(configPath, options) const component = fs.readFileSync(path.join(result.generatedDir, 'react', 'react-component.js'), 'utf8') const manifest = fs.readFileSync(result.manifestPath, 'utf8') const manifestTypes = fs.readFileSync(path.join(result.generatedDir, 'svg-sprite.manifest.d.ts'), 'utf8') const mode = `next@${options.router}/${options.bundler}` assert.equal(result.target, mode) assert.equal(result.router, options.router) assert.equal(result.bundler, options.bundler) assert.match(component, /new URL\('\.\.\/sprite\.svg', import\.meta\.url\)\.href/) assert.doesNotMatch(component, /['"]use client['"]/) assert.match(manifest, new RegExp(`"target": ${JSON.stringify(mode)}`)) assert.match(manifestTypes, new RegExp(`mode: ${JSON.stringify(mode).replaceAll('"', "'")}`)) assert.doesNotMatch(manifestTypes, /@gromlab\/svg-sprites\/react/) } }) test('writer replaces the generated directory and preserves root user files', async () => { const { rootDir, configPath } = createReactFixture() await generateReactSprite(configPath, 'vite') fs.writeFileSync(path.join(rootDir, '.svg-sprite', 'obsolete.js'), '/* @generated by @gromlab/svg-sprites. */\n') fs.writeFileSync(path.join(rootDir, '.svg-sprite', 'notes.md'), 'Remove me\n') fs.writeFileSync(path.join(rootDir, 'index.ts'), 'export const userCode = true\n') await generateReactSprite(configPath, 'vite') assert.equal(fs.existsSync(path.join(rootDir, '.svg-sprite', 'obsolete.js')), false) assert.equal(fs.existsSync(path.join(rootDir, '.svg-sprite', 'notes.md')), false) assert.equal(fs.readFileSync(path.join(rootDir, 'index.ts'), 'utf8'), 'export const userCode = true\n') }) test('writer replaces a reserved output directory and refuses symbolic links', async () => { const first = createReactFixture() fs.mkdirSync(path.join(first.rootDir, '.svg-sprite')) fs.writeFileSync(path.join(first.rootDir, '.svg-sprite', 'index.js'), 'export const userCode = true\n') await generateReactSprite(first.configPath, 'vite') assert.doesNotMatch( fs.readFileSync(path.join(first.rootDir, '.svg-sprite', 'index.js'), 'utf8'), /userCode/, ) const second = createReactFixture() const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-outside-')) fs.symlinkSync(outsideDir, path.join(second.rootDir, '.svg-sprite'), 'dir') await assert.rejects(generateReactSprite(second.configPath, 'vite'), /Symbolic links are not allowed/) assert.deepEqual(fs.readdirSync(outsideDir), []) }) test('writer recovers an interrupted directory replacement', async () => { const { rootDir, configPath } = createReactFixture() await generateReactSprite(configPath, 'vite') const generatedDir = path.join(rootDir, '.svg-sprite') const backupDir = path.join(rootDir, '.svg-sprite.interrupted.old') const stagingDir = path.join(rootDir, '.svg-sprite.interrupted.tmp') fs.renameSync(generatedDir, backupDir) fs.mkdirSync(stagingDir) fs.writeFileSync(path.join(stagingDir, 'partial.js'), 'partial\n') await generateReactSprite(configPath, 'vite') assert.equal(fs.existsSync(generatedDir), true) assert.equal(fs.existsSync(backupDir), false) assert.equal(fs.existsSync(stagingDir), false) assert.equal(fs.existsSync(path.join(generatedDir, 'partial.js')), false) }) test('rejects colliding SVG shape IDs', async () => { const { iconsDir, configPath } = createReactFixture() const id = `icon-${createHash('sha256').update('folder open').digest('hex').slice(0, 16)}` fs.writeFileSync(path.join(iconsDir, `${id}.svg`), '') await assert.rejects(generateReactSprite(configPath, 'vite'), /produce the same SVG id/) })