Files
svg-sprites/test/react-mode.test.mjs

591 lines
21 KiB
JavaScript
Raw Permalink Normal View History

import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
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 ts from 'typescript'
import { generateNextSprite, generateReactSprite } from '../dist/index.js'
const GENERATED_FILES = [
'.svg-sprites.manifest.json',
'react-component.tsx',
'sprite.svg',
'styles.module.css',
'types.ts',
]
function createReactFixture() {
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')
fs.mkdirSync(iconsDir, { recursive: true })
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default { description: 'File manager icons' }\n`,
)
fs.writeFileSync(
path.join(iconsDir, 'check.svg'),
'<svg viewBox="0 0 16 16"><path fill="none" stroke="#123456" d="M1 8l4 4L15 2" /></svg>',
)
fs.writeFileSync(
path.join(iconsDir, 'folder.svg'),
'<svg viewBox="0 0 16 16"><path fill="#000" d="M1 2h6l2 2h6v10H1z" /></svg>',
)
fs.writeFileSync(
path.join(iconsDir, 'folder open.svg'),
'<svg viewBox="0 0 16 16"><path fill="#000" d="M1 3h14v11H1z" /></svg>',
)
return { rootDir, iconsDir }
}
test('generates an isolated React sprite module', async () => {
const { rootDir } = createReactFixture()
const result = await generateReactSprite(rootDir, 'vite')
const generatedDir = path.join(rootDir, 'generated')
assert.equal(result.name, 'file-manager')
assert.equal(result.iconCount, 3)
assert.equal(result.target, 'vite')
assert.equal(result.manifestPath, path.join(rootDir, 'manifest.ts'))
assert.deepEqual(fs.readdirSync(generatedDir).sort(), GENERATED_FILES)
const gitignore = fs.readFileSync(path.join(rootDir, '.gitignore'), 'utf-8')
assert.match(gitignore, /@generated by @gromlab\/svg-sprites/)
assert.match(gitignore, /^\/generated\/$/m)
assert.match(gitignore, /^\/index\.ts$/m)
assert.match(gitignore, /^\/manifest\.ts$/m)
const sprite = fs.readFileSync(path.join(generatedDir, 'sprite.svg'), 'utf-8')
assert.match(sprite, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
assert.match(sprite, /Генератор: @gromlab\/svg-sprites/)
assert.match(sprite, /<svg[^>]+id="check"/)
assert.match(sprite, /<svg[^>]+id="folder"/)
assert.match(sprite, /<svg[^>]*>\n <style>:root>svg/)
assert.match(sprite, /\n <path[^>]+\/>\n <\/svg>/)
assert.doesNotMatch(sprite, /<symbol/)
assert.ok(sprite.endsWith('\n'))
const unsafeId = sprite.match(/<svg[^>]+id="(icon-[a-f0-9]{16})"/)?.[1]
assert.ok(unsafeId)
const component = fs.readFileSync(
path.join(generatedDir, 'react-component.tsx'),
'utf-8',
)
assert.match(component, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
assert.match(component, /export const FileManagerIcon/)
assert.match(component, /export type FileManagerIconStyle = CSSProperties/)
assert.match(component, /Record<`--icon-color-\$\{number\}`, string \| number>/)
assert.match(component, /Omit<SVGAttributes<SVGSVGElement>, 'style'>/)
assert.match(component, /import spriteUrl from '\.\/sprite\.svg\?no-inline'/)
assert.doesNotMatch(component, /SvgSpriteProvider/)
assert.match(component, /iconIds\[icon\]/)
const transpiled = ts.transpileModule(component, {
compilerOptions: {
jsx: ts.JsxEmit.ReactJSX,
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
reportDiagnostics: true,
})
assert.deepEqual(transpiled.diagnostics, [])
const metadata = fs.readFileSync(
path.join(generatedDir, 'types.ts'),
'utf-8',
)
assert.match(metadata, /export type FileManagerIconName/)
assert.match(metadata, /File manager icons/)
assert.match(metadata, /"check"/)
assert.match(metadata, /"folder"/)
assert.ok(component.includes(`"folder open": "${unsafeId}"`))
const spriteManifest = fs.readFileSync(path.join(rootDir, 'manifest.ts'), 'utf-8')
assert.match(spriteManifest, /import spriteUrl from '\.\/generated\/sprite\.svg\?no-inline'/)
assert.match(spriteManifest, /export const spriteManifest/)
assert.match(spriteManifest, /componentName: "FileManagerIcon"/)
assert.match(spriteManifest, /target: "vite"/)
assert.match(spriteManifest, /format: "stack"/)
assert.match(spriteManifest, /iconCount: 3/)
assert.match(spriteManifest, /name: "check"/)
assert.match(spriteManifest, /viewBox: "0 0 16 16"/)
assert.match(spriteManifest, /variable: "--icon-color-1"/)
assert.match(spriteManifest, /fallback: "currentColor"/)
assert.ok(spriteManifest.includes(`id: "${unsafeId}"`))
const transpiledManifest = ts.transpileModule(spriteManifest, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
reportDiagnostics: true,
})
assert.deepEqual(transpiledManifest.diagnostics, [])
const rootIndex = fs.readFileSync(path.join(rootDir, 'index.ts'), 'utf-8')
assert.match(rootIndex, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
assert.match(rootIndex, /export \{ FileManagerIcon \} from '\.\/generated\/react-component'/)
assert.match(rootIndex, /FileManagerIconProps, FileManagerIconStyle/)
assert.match(rootIndex, /export \{ fileManagerIconNames \} from '\.\/generated\/types'/)
const manifest = JSON.parse(
fs.readFileSync(path.join(generatedDir, '.svg-sprites.manifest.json'), 'utf-8'),
)
assert.match(manifest.warning, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
})
test('supports Webpack target, explicit name and custom icons directory', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-config-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const iconsDir = path.join(rootDir, 'assets')
fs.mkdirSync(iconsDir, { recursive: true })
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default { name: 'documents', inputFolder: './assets', generatedNotice: false }\n`,
)
fs.writeFileSync(
path.join(iconsDir, 'file.svg'),
'<svg viewBox="0 0 16 16"><path d="M2 1h8l4 4v10H2z" /></svg>',
)
const result = await generateReactSprite(rootDir, 'webpack')
const componentPath = path.join(
rootDir,
'generated',
'react-component.tsx',
)
assert.equal(result.name, 'documents')
assert.equal(result.iconCount, 1)
assert.equal(result.target, 'webpack')
const component = fs.readFileSync(componentPath, 'utf-8')
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.doesNotMatch(component, /SvgSpriteProvider/)
const spriteManifest = fs.readFileSync(path.join(rootDir, 'manifest.ts'), 'utf-8')
assert.match(spriteManifest, /@generated by @gromlab\/svg-sprites/)
assert.match(spriteManifest, /new URL\('\.\/generated\/sprite\.svg', import\.meta\.url\)\.href/)
assert.match(spriteManifest, /target: "webpack"/)
assert.match(spriteManifest, /format: "stack"/)
assert.match(spriteManifest, /componentName: "DocumentsIcon"/)
const manifest = JSON.parse(
fs.readFileSync(path.join(rootDir, 'generated', '.svg-sprites.manifest.json'), 'utf-8'),
)
assert.equal(manifest.warning, undefined)
})
test('merges inputFolder and inputFiles and deduplicates identical paths', 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 sharedDir = path.join(temporaryDir, 'shared')
const localIcon = path.join(iconsDir, 'local.svg')
const sharedIcon = path.join(sharedDir, 'shared.svg')
fs.mkdirSync(iconsDir, { recursive: true })
fs.mkdirSync(sharedDir, { recursive: true })
fs.writeFileSync(localIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
fs.writeFileSync(sharedIcon, '<svg viewBox="0 0 16 16"><path d="M1 1h14v14H1z" /></svg>')
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default {
name: 'mixed-inputs',
description: 'Local and shared icons',
inputFiles: [
'./icons/local.svg',
${JSON.stringify(path.relative(rootDir, sharedIcon))},
],
}\n`,
)
const result = await generateReactSprite(rootDir, 'vite')
const sprite = fs.readFileSync(result.spritePath, 'utf-8')
const manifest = fs.readFileSync(result.manifestPath, 'utf-8')
assert.equal(result.name, 'mixed-inputs')
assert.equal(result.iconCount, 2)
assert.match(sprite, /id="local"/)
assert.match(sprite, /id="shared"/)
assert.match(manifest, /description: "Local and shared icons"/)
})
test('supports inputFiles 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, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default { inputFiles: [${JSON.stringify(path.relative(rootDir, sharedIcon))}] }\n`,
)
const result = await generateReactSprite(rootDir, 'webpack')
const sprite = fs.readFileSync(result.spritePath, 'utf-8')
assert.equal(result.iconCount, 1)
assert.match(sprite, /id="only"/)
})
test('rejects different input files with the same icon name', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-names-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const firstIcon = path.join(temporaryDir, 'first', 'shared.svg')
const secondIcon = path.join(temporaryDir, 'second', 'shared.svg')
fs.mkdirSync(rootDir, { recursive: true })
fs.mkdirSync(path.dirname(firstIcon), { recursive: true })
fs.mkdirSync(path.dirname(secondIcon), { recursive: true })
fs.writeFileSync(firstIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
fs.writeFileSync(secondIcon, '<svg viewBox="0 0 16 16"><path d="M1 1h14v14H1z" /></svg>')
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default {
inputFiles: [
${JSON.stringify(path.relative(rootDir, firstIcon))},
${JSON.stringify(path.relative(rootDir, secondIcon))},
],
}\n`,
)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/produce the same SVG id "shared"/,
)
})
test('rejects an explicitly configured missing inputFolder', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-folder-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const sharedIcon = path.join(temporaryDir, 'shared.svg')
fs.mkdirSync(rootDir, { recursive: true })
fs.writeFileSync(sharedIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default {
inputFolder: './missing',
inputFiles: [${JSON.stringify(path.relative(rootDir, sharedIcon))}],
}\n`,
)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/Input directory does not exist/,
)
})
test('applies React transform options', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-transform-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const iconsDir = path.join(rootDir, 'icons')
fs.mkdirSync(iconsDir, { recursive: true })
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default {
transform: {
removeSize: false,
replaceColors: false,
addTransition: false,
},
}\n`,
)
fs.writeFileSync(
path.join(iconsDir, 'plain.svg'),
'<svg width="24" height="24" viewBox="0 0 24 24"><path fill="#123456" d="M0 0h24v24H0z" /></svg>',
)
const result = await generateReactSprite(rootDir, 'vite')
const sprite = fs.readFileSync(result.spritePath, 'utf-8')
const styles = fs.readFileSync(path.join(result.generatedDir, 'styles.module.css'), 'utf-8')
assert.match(sprite, /width="24"/)
assert.match(sprite, /height="24"/)
assert.match(sprite, /fill="#(?:123456|123)"/)
assert.doesNotMatch(sprite, /--icon-color-/)
assert.doesNotMatch(sprite, /transition:/)
assert.doesNotMatch(styles, /transition-/)
})
test('generates App and Pages Router modules for Webpack and Turbopack', 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 { rootDir } = createReactFixture()
const result = await generateNextSprite(rootDir, options)
const component = fs.readFileSync(
path.join(result.generatedDir, 'react-component.tsx'),
'utf-8',
)
const manifest = fs.readFileSync(result.manifestPath, 'utf-8')
const target = `next@${options.router}/${options.bundler}`
assert.equal(result.target, target)
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(target)}`))
}
})
test('regeneration removes only files listed in the manifest', async () => {
const { rootDir } = createReactFixture()
await generateReactSprite(rootDir, 'vite')
const generatedDir = path.join(rootDir, 'generated')
const manifestPath = path.join(generatedDir, '.svg-sprites.manifest.json')
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
const obsoleteRelativePath = 'generated/obsolete.generated.ts'
manifest.files.push('generated\\obsolete.generated.ts')
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
fs.writeFileSync(
path.join(rootDir, obsoleteRelativePath),
'/** @generated by @gromlab/svg-sprites. Do not edit. */\n',
)
fs.writeFileSync(path.join(generatedDir, 'notes.md'), 'Keep me\n')
fs.writeFileSync(path.join(rootDir, 'README.md'), 'Keep me too\n')
await generateReactSprite(rootDir, 'vite')
assert.equal(fs.existsSync(path.join(rootDir, obsoleteRelativePath)), false)
assert.equal(fs.readFileSync(path.join(generatedDir, 'notes.md'), 'utf-8'), 'Keep me\n')
assert.equal(fs.readFileSync(path.join(rootDir, 'README.md'), 'utf-8'), 'Keep me too\n')
})
test('does not overwrite a user index.ts without a manifest', async () => {
const { rootDir } = createReactFixture()
const userIndex = 'export const userCode = true\n'
fs.writeFileSync(path.join(rootDir, 'index.ts'), userIndex)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/Refusing to overwrite a user file/,
)
assert.equal(fs.readFileSync(path.join(rootDir, 'index.ts'), 'utf-8'), userIndex)
})
test('does not overwrite a generated file replaced with user content', async () => {
const { rootDir } = createReactFixture()
await generateReactSprite(rootDir, 'vite')
const userIndex = 'export const userCode = true\n'
fs.writeFileSync(path.join(rootDir, 'index.ts'), userIndex)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/Refusing to overwrite a user file/,
)
assert.equal(fs.readFileSync(path.join(rootDir, 'index.ts'), 'utf-8'), userIndex)
})
test('rejects paths outside the generated area from the manifest', async () => {
const { rootDir } = createReactFixture()
await generateReactSprite(rootDir, 'vite')
const manifestPath = path.join(rootDir, 'generated', '.svg-sprites.manifest.json')
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
manifest.files.push('svg-sprite.config.ts')
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/Invalid generated file path/,
)
assert.equal(fs.existsSync(path.join(rootDir, 'svg-sprite.config.ts')), true)
})
test('rejects symbolic links in generated paths', async () => {
const { rootDir } = createReactFixture()
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-outside-'))
fs.symlinkSync(outsideDir, path.join(rootDir, 'generated'), 'dir')
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/Symbolic links are not allowed/,
)
assert.deepEqual(fs.readdirSync(outsideDir), [])
})
test('rejects colliding SVG shape IDs', async () => {
const { rootDir, iconsDir } = createReactFixture()
const collidingId = `icon-${createHash('sha256').update('folder open').digest('hex').slice(0, 16)}`
fs.writeFileSync(
path.join(iconsDir, `${collidingId}.svg`),
'<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>',
)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/produce the same SVG id/,
)
})
test('CLI requires an explicit mode', () => {
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(process.execPath, [cliPath], { encoding: 'utf-8' })
assert.equal(result.status, 1)
assert.match(result.stderr, /Missing required argument: --mode/)
})
test('CLI does not expose the future standalone mode', () => {
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'standalone', '.'],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 1)
assert.match(result.stderr, /Unknown mode: standalone/)
})
test('CLI requires a target for React mode', () => {
const { rootDir } = createReactFixture()
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'react', rootDir],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 1)
assert.match(result.stderr, /React mode requires a target/)
})
test('CLI rejects unsupported React target', () => {
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'react@next', '.'],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 1)
assert.match(result.stderr, /Unsupported React target: next/)
})
test('CLI requires a complete Next.js target', () => {
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'next@app', '.'],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 1)
assert.match(result.stderr, /Unsupported Next\.js target: next@app/)
})
test('CLI runs the React Vite mode', () => {
const { rootDir } = createReactFixture()
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'react@vite', rootDir],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 0, result.stderr)
const component = fs.readFileSync(
path.join(rootDir, 'generated', 'react-component.tsx'),
'utf-8',
)
assert.match(component, /sprite\.svg\?no-inline/)
assert.match(result.stdout, /✓ file-manager · 3 icons · react@vite/)
assert.match(result.stdout, /→ .*generated/)
assert.doesNotMatch(result.stdout, /Generating sprite|\[stack\]/)
})
test('CLI runs the React Webpack mode', () => {
const { rootDir } = createReactFixture()
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'react@webpack', rootDir],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 0, result.stderr)
const component = fs.readFileSync(
path.join(rootDir, 'generated', 'react-component.tsx'),
'utf-8',
)
assert.match(component, /new URL\('\.\/sprite\.svg', import\.meta\.url\)\.href/)
})
test('CLI runs all Next.js modes', () => {
const modes = [
'next@app/turbopack',
'next@app/webpack',
'next@pages/turbopack',
'next@pages/webpack',
]
const cliPath = path.resolve('dist/cli.js')
for (const mode of modes) {
const { rootDir } = createReactFixture()
const result = spawnSync(
process.execPath,
[cliPath, '--mode', mode, rootDir],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 0, result.stderr)
const manifest = fs.readFileSync(path.join(rootDir, 'manifest.ts'), 'utf-8')
assert.match(manifest, new RegExp(`target: ${JSON.stringify(mode)}`))
}
})
test('CLI runs the legacy mode relative to its target directory', () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-legacy-'))
const iconsDir = path.join(rootDir, 'icons')
const cliPath = path.resolve('dist/cli.js')
fs.mkdirSync(iconsDir)
fs.writeFileSync(
path.join(iconsDir, 'check.svg'),
'<svg viewBox="0 0 16 16"><path d="M1 8l4 4L15 2" /></svg>',
)
fs.writeFileSync(
path.join(rootDir, 'svg-sprites.config.ts'),
[
'export default {',
" output: 'sprites',",
' preview: false,',
" sprites: [{ name: 'icons', input: 'icons', format: 'symbol' }],",
'}',
'',
].join('\n'),
)
const result = spawnSync(
process.execPath,
[cliPath, '--mode=legacy', rootDir],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 0, result.stderr)
assert.equal(fs.existsSync(path.join(rootDir, 'sprites', 'icons.sprite.svg')), true)
})