mirror of
https://github.com/gromlab-ru/svg-sprites.git
synced 2026-07-22 04:40:17 +03:00
sync
This commit is contained in:
47
test/cli-input.test.mjs
Normal file
47
test/cli-input.test.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
test('CLI accepts repeated --input paths and glob patterns', () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-cli-input-'))
|
||||
const iconsDir = path.join(rootDir, 'icons')
|
||||
fs.mkdirSync(iconsDir)
|
||||
fs.writeFileSync(path.join(iconsDir, 'check.svg'), '<svg viewBox="0 0 16 16"><path /></svg>')
|
||||
fs.writeFileSync(path.join(iconsDir, 'close.svg'), '<svg viewBox="0 0 16 16"><path /></svg>')
|
||||
|
||||
const result = spawnSync(process.execPath, [
|
||||
'dist/cli.js',
|
||||
'--mode=standalone',
|
||||
'--name=cli-icons',
|
||||
'--input',
|
||||
'icons/*.svg',
|
||||
'--input',
|
||||
'!icons/close.svg',
|
||||
rootDir,
|
||||
], {
|
||||
cwd: path.resolve('.'),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
const sprite = fs.readFileSync(path.join(rootDir, '.svg-sprite', 'sprite.svg'), 'utf8')
|
||||
assert.match(sprite, /id="check"/)
|
||||
assert.doesNotMatch(sprite, /id="close"/)
|
||||
})
|
||||
|
||||
test('CLI rejects removed input flags', () => {
|
||||
const result = spawnSync(process.execPath, [
|
||||
'dist/cli.js',
|
||||
'--input-folder=icons',
|
||||
'.',
|
||||
], {
|
||||
cwd: path.resolve('.'),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
assert.notEqual(result.status, 0)
|
||||
assert.match(result.stderr, /Unknown argument: --input-folder=icons/)
|
||||
})
|
||||
76
test/documentation-guides.test.mjs
Normal file
76
test/documentation-guides.test.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const rootDir = path.resolve(import.meta.dirname, '..')
|
||||
const docsDir = path.join(rootDir, 'docs')
|
||||
const guideModes = new Map([
|
||||
['standalone.md', 'standalone'],
|
||||
['standalone-vite.md', 'standalone@vite'],
|
||||
['standalone-webpack.md', 'standalone@webpack'],
|
||||
['react-vite.md', 'react@vite'],
|
||||
['react-webpack.md', 'react@webpack'],
|
||||
['next-app-turbopack.md', 'next@app/turbopack'],
|
||||
['next-app-webpack.md', 'next@app/webpack'],
|
||||
['next-pages-turbopack.md', 'next@pages/turbopack'],
|
||||
['next-pages-webpack.md', 'next@pages/webpack'],
|
||||
])
|
||||
|
||||
const sectionHeadings = {
|
||||
en: ['Generate the sprite', 'Debug and preview', 'Type the config'],
|
||||
ru: ['Генерация спрайта', 'Дебаг и превью', 'Типизация конфига'],
|
||||
}
|
||||
|
||||
function markdownFiles(directory) {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const entryPath = path.join(directory, entry.name)
|
||||
return entry.isDirectory()
|
||||
? markdownFiles(entryPath)
|
||||
: entry.name.endsWith('.md') ? [entryPath] : []
|
||||
})
|
||||
}
|
||||
|
||||
test('exact-mode guides are reusable by docs and skills', () => {
|
||||
for (const language of ['en', 'ru']) {
|
||||
const guidesDir = path.join(docsDir, language, 'guides')
|
||||
const guideFiles = fs.readdirSync(guidesDir)
|
||||
.filter((file) => file !== 'README.md')
|
||||
.sort()
|
||||
assert.deepEqual(guideFiles, [...guideModes.keys()].sort())
|
||||
|
||||
for (const [file, mode] of guideModes) {
|
||||
const source = fs.readFileSync(path.join(guidesDir, file), 'utf8')
|
||||
assert.equal(source.match(/^# /gm)?.length, 1, `${language}/${file} must have one H1`)
|
||||
const headings = source.match(/^## .+$/gm)?.map((heading) => (
|
||||
heading.replace(/^## (?:\d+\. )?/, '')
|
||||
))
|
||||
assert.deepEqual(headings, sectionHeadings[language])
|
||||
assert.match(source, /npx --yes (?:--package=@gromlab\/svg-sprites@latest svg-sprites|@gromlab\/svg-sprites@latest)/)
|
||||
assert.match(source, /npm install --save-dev @gromlab\/svg-sprites/)
|
||||
assert.match(source, new RegExp(`mode: '${mode.replaceAll('/', '\\/')}'`))
|
||||
assert.doesNotMatch(source, /\]\([^)]+\)/, `${language}/${file} must not depend on its location`)
|
||||
|
||||
const generation = source.indexOf(sectionHeadings[language][0])
|
||||
const preview = source.indexOf(sectionHeadings[language][1])
|
||||
const typing = source.indexOf(sectionHeadings[language][2])
|
||||
assert.ok(generation < preview && preview < typing)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('all local documentation links resolve', () => {
|
||||
for (const file of markdownFiles(docsDir)) {
|
||||
const source = fs.readFileSync(file, 'utf8')
|
||||
for (const match of source.matchAll(/\]\(([^)]+)\)/g)) {
|
||||
const target = match[1]
|
||||
if (/^(?:https?:|mailto:|#)/.test(target)) continue
|
||||
const targetPath = decodeURIComponent(target.split('#')[0])
|
||||
assert.equal(
|
||||
fs.existsSync(path.resolve(path.dirname(file), targetPath)),
|
||||
true,
|
||||
`${path.relative(rootDir, file)} links to missing ${target}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -14,7 +14,6 @@ const GENERATED_FILES = [
|
||||
'index.js',
|
||||
'react',
|
||||
'sprite.svg',
|
||||
'state.json',
|
||||
'svg-sprite.manifest.d.ts',
|
||||
'svg-sprite.manifest.js',
|
||||
]
|
||||
@@ -73,6 +72,7 @@ test('generates the current isolated React Vite contract', async () => {
|
||||
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/)
|
||||
@@ -83,23 +83,29 @@ test('generates the current isolated React Vite contract', async () => {
|
||||
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/)
|
||||
|
||||
const state = JSON.parse(fs.readFileSync(path.join(generatedDir, 'state.json'), 'utf8'))
|
||||
assert.deepEqual(state.owner, { mode: 'react@vite', contractVersion: 5 })
|
||||
assert.equal(state.files.includes('.svg-sprite/react/react-component.js'), true)
|
||||
})
|
||||
|
||||
test('supports Webpack target and a custom icons directory', async () => {
|
||||
const { rootDir, configPath } = createReactFixture({
|
||||
name: 'documents',
|
||||
inputFolder: './assets',
|
||||
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'),
|
||||
'<svg viewBox="0 0 16 16"><path d="M0 0h2v2H0z" /></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)
|
||||
@@ -109,9 +115,11 @@ test('supports Webpack target and a custom icons directory', async () => {
|
||||
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('merges inputFolder and inputFiles and deduplicates identical paths', async () => {
|
||||
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')
|
||||
@@ -120,7 +128,7 @@ test('merges inputFolder and inputFiles and deduplicates identical paths', async
|
||||
fs.writeFileSync(path.join(iconsDir, 'local.svg'), '<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>')
|
||||
const configPath = path.join(rootDir, 'config.ts')
|
||||
fs.writeFileSync(configPath, `export default { name: 'mixed-inputs', inputFiles: [${JSON.stringify(path.relative(rootDir, sharedIcon))}, ${JSON.stringify(path.relative(rootDir, sharedIcon))}] }`)
|
||||
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')
|
||||
@@ -130,7 +138,7 @@ test('merges inputFolder and inputFiles and deduplicates identical paths', async
|
||||
assert.match(sprite, /id="shared"/)
|
||||
})
|
||||
|
||||
test('supports inputFiles without the default icons directory', async () => {
|
||||
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')
|
||||
@@ -138,14 +146,49 @@ test('supports inputFiles without the default icons directory', async () => {
|
||||
fs.mkdirSync(path.dirname(sharedIcon), { recursive: true })
|
||||
fs.writeFileSync(sharedIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
|
||||
const configPath = path.join(rootDir, 'config.ts')
|
||||
fs.writeFileSync(configPath, `export default { inputFiles: [${JSON.stringify(path.relative(rootDir, sharedIcon))}] }`)
|
||||
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('rejects duplicate names and explicitly missing input folders', async () => {
|
||||
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'), '<svg viewBox="0 0 16 16"><path d="M0 0h1v1H0z" /></svg>')
|
||||
fs.writeFileSync(path.join(nestedDir, 'included.svg'), '<svg viewBox="0 0 16 16"><path d="M1 1h2v2H1z" /></svg>')
|
||||
fs.writeFileSync(path.join(nestedDir, 'draft.svg'), '<svg viewBox="0 0 16 16"><path d="M2 2h3v3H2z" /></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'), '<svg viewBox="0 0 16 16"><path d="M0 0h1v1H0z" /></svg>')
|
||||
fs.writeFileSync(path.join(iconsDir, 'icon1.svg'), '<svg viewBox="0 0 16 16"><path d="M1 1h2v2H1z" /></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')
|
||||
@@ -156,12 +199,24 @@ test('rejects duplicate names and explicitly missing input folders', async () =>
|
||||
fs.writeFileSync(first, '<svg viewBox="0 0 16 16"><path /></svg>')
|
||||
fs.writeFileSync(second, '<svg viewBox="0 0 16 16"><path /></svg>')
|
||||
const duplicateConfig = path.join(rootDir, 'duplicate.ts')
|
||||
fs.writeFileSync(duplicateConfig, `export default { inputFiles: [${JSON.stringify(path.relative(rootDir, first))}, ${JSON.stringify(path.relative(rootDir, second))}] }`)
|
||||
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 { inputFolder: './missing', inputFiles: [${JSON.stringify(path.relative(rootDir, first))}] }`)
|
||||
await assert.rejects(generateReactSprite(missingConfig, 'vite'), /Input directory does not exist/)
|
||||
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 () => {
|
||||
@@ -195,6 +250,7 @@ test('generates every Next.js exact mode without a client boundary', async () =>
|
||||
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)
|
||||
@@ -202,31 +258,33 @@ test('generates every Next.js exact mode without a client boundary', async () =>
|
||||
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 removes only obsolete managed files and preserves user files', async () => {
|
||||
test('writer replaces the generated directory and preserves root user files', async () => {
|
||||
const { rootDir, configPath } = createReactFixture()
|
||||
await generateReactSprite(configPath, 'vite')
|
||||
const statePath = path.join(rootDir, '.svg-sprite', 'state.json')
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'))
|
||||
state.files.push('.svg-sprite/obsolete.js')
|
||||
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`)
|
||||
fs.writeFileSync(path.join(rootDir, '.svg-sprite', 'obsolete.js'), '/* @generated by @gromlab/svg-sprites. */\n')
|
||||
fs.writeFileSync(path.join(rootDir, '.svg-sprite', 'notes.md'), 'Keep me\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.readFileSync(path.join(rootDir, '.svg-sprite', 'notes.md'), 'utf8'), 'Keep me\n')
|
||||
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 refuses user content and symbolic links in managed paths', async () => {
|
||||
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 assert.rejects(generateReactSprite(first.configPath, 'vite'), /Refusing to overwrite a user file/)
|
||||
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-'))
|
||||
@@ -235,6 +293,24 @@ test('writer refuses user content and symbolic links in managed paths', async ()
|
||||
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)}`
|
||||
|
||||
104
test/standalone-icon-element.test.mjs
Normal file
104
test/standalone-icon-element.test.mjs
Normal file
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { JSDOM } from 'jsdom'
|
||||
|
||||
import { generateSprite } from '../dist/index.js'
|
||||
|
||||
const CHECK_SVG = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M9 16 4 11l2-2 3 3 8-8 2 2z"/></svg>'
|
||||
const UNSAFE_SVG = '<svg viewBox="0 0 16 16"><path d="M1 1h14v14H1z"/></svg>'
|
||||
|
||||
test('standalone Webpack icon element renders generated icons without external runtime dependencies', async (context) => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-icon-element-'))
|
||||
const iconsDir = path.join(rootDir, 'icons')
|
||||
fs.mkdirSync(iconsDir)
|
||||
fs.writeFileSync(path.join(rootDir, 'package.json'), '{"type":"module"}')
|
||||
fs.writeFileSync(path.join(iconsDir, 'check.svg'), CHECK_SVG)
|
||||
fs.writeFileSync(path.join(iconsDir, 'folder open.svg'), UNSAFE_SVG)
|
||||
|
||||
await generateSprite(rootDir, {
|
||||
mode: 'standalone@webpack',
|
||||
name: 'app-icons',
|
||||
generatedNotice: false,
|
||||
})
|
||||
|
||||
const entryUrl = pathToFileURL(path.join(rootDir, '.svg-sprite', 'index.js')).href
|
||||
const generated = await import(entryUrl)
|
||||
assert.equal(generated.appIconsIconTagName, 'app-icons-icon')
|
||||
assert.doesNotThrow(() => generated.defineAppIconsIconElement())
|
||||
|
||||
const dom = new JSDOM('<body></body>', { url: 'https://example.test/' })
|
||||
const originals = new Map()
|
||||
for (const [name, value] of Object.entries({
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
customElements: dom.window.customElements,
|
||||
})) {
|
||||
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name))
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value })
|
||||
}
|
||||
|
||||
const browserErrors = []
|
||||
const originalConsoleError = console.error
|
||||
console.error = (...values) => browserErrors.push(values.join(' '))
|
||||
|
||||
context.after(() => {
|
||||
console.error = originalConsoleError
|
||||
dom.window.close()
|
||||
for (const [name, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor)
|
||||
else delete globalThis[name]
|
||||
}
|
||||
})
|
||||
|
||||
const icon = dom.window.document.createElement('app-icons-icon')
|
||||
icon.icon = 'folder open'
|
||||
dom.window.document.body.append(icon)
|
||||
|
||||
generated.defineAppIconsIconElement()
|
||||
generated.defineAppIconsIconElement()
|
||||
|
||||
assert.equal(icon.icon, 'folder open')
|
||||
assert.equal(icon.getAttribute('icon'), 'folder open')
|
||||
assert.equal(icon.shadowRoot.querySelector('svg').getAttribute('viewBox'), '0 0 16 16')
|
||||
assert.equal(
|
||||
icon.shadowRoot.querySelector('use').getAttribute('href'),
|
||||
generated.getAppIconsIconHref('folder open'),
|
||||
)
|
||||
assert.match(icon.shadowRoot.querySelector('use').getAttribute('href'), /#icon-[a-f0-9]{16}$/)
|
||||
|
||||
icon.icon = 'check'
|
||||
assert.equal(icon.shadowRoot.querySelector('svg').getAttribute('viewBox'), '0 0 24 24')
|
||||
assert.match(icon.shadowRoot.querySelector('use').getAttribute('href'), /sprite\.svg#check$/)
|
||||
assert.throws(() => { icon.icon = 'missing' }, /Unknown app-icons icon: missing/)
|
||||
|
||||
icon.setAttribute('icon', 'missing')
|
||||
assert.equal(icon.icon, null)
|
||||
assert.equal(icon.shadowRoot.querySelector('use').hasAttribute('href'), false)
|
||||
assert.equal(icon.shadowRoot.querySelector('svg').hasAttribute('hidden'), true)
|
||||
assert.deepEqual(browserErrors, ['<app-icons-icon>: unknown icon "missing".'])
|
||||
|
||||
const collisionDom = new JSDOM('<body></body>')
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: collisionDom.window.HTMLElement,
|
||||
})
|
||||
Object.defineProperty(globalThis, 'customElements', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: collisionDom.window.customElements,
|
||||
})
|
||||
collisionDom.window.customElements.define(
|
||||
'app-icons-icon',
|
||||
class extends collisionDom.window.HTMLElement {},
|
||||
)
|
||||
assert.throws(
|
||||
() => generated.defineAppIconsIconElement(),
|
||||
/Cannot define <app-icons-icon>: this custom element name is already registered/,
|
||||
)
|
||||
collisionDom.window.close()
|
||||
})
|
||||
@@ -44,9 +44,9 @@ test('standalone generates an asset and deployment-neutral JSON manifest', async
|
||||
assert.equal(result.manifestPath, path.join(rootDir, '.svg-sprite', 'svg-sprite.manifest.json'))
|
||||
assert.deepEqual(generatedFiles(rootDir), [
|
||||
'sprite.svg',
|
||||
'state.json',
|
||||
'svg-sprite.manifest.json',
|
||||
])
|
||||
assert.equal(fs.existsSync(path.join(rootDir, '.gitignore')), false)
|
||||
|
||||
const source = fs.readFileSync(result.spritePath, 'utf8')
|
||||
assert.match(source, /@generated by @gromlab\/svg-sprites/)
|
||||
@@ -65,8 +65,20 @@ test('standalone generates an asset and deployment-neutral JSON manifest', async
|
||||
/^icon-[a-f0-9]{16}$/,
|
||||
)
|
||||
|
||||
const state = JSON.parse(fs.readFileSync(path.join(rootDir, '.svg-sprite', 'state.json'), 'utf8'))
|
||||
assert.deepEqual(state.owner, { mode: 'standalone', contractVersion: 2 })
|
||||
})
|
||||
|
||||
test('standalone preserves a user-owned .gitignore', async () => {
|
||||
const rootDir = fixture()
|
||||
const gitignorePath = path.join(rootDir, '.gitignore')
|
||||
fs.writeFileSync(gitignorePath, '/public/cache/\n')
|
||||
|
||||
await generateSprite(rootDir, {
|
||||
mode: 'standalone',
|
||||
name: 'app-icons',
|
||||
generatedNotice: false,
|
||||
})
|
||||
|
||||
assert.equal(fs.readFileSync(gitignorePath, 'utf8'), '/public/cache/\n')
|
||||
})
|
||||
|
||||
test('standalone Vite generates a typed facade and resolved manifest', async () => {
|
||||
@@ -78,13 +90,13 @@ test('standalone Vite generates a typed facade and resolved manifest', async ()
|
||||
})
|
||||
|
||||
assert.equal(result.target, 'vite')
|
||||
assert.equal(fs.existsSync(path.join(rootDir, '.gitignore')), true)
|
||||
assert.deepEqual(generatedFiles(rootDir), [
|
||||
'icon-data.d.ts',
|
||||
'icon-data.js',
|
||||
'index.d.ts',
|
||||
'index.js',
|
||||
'sprite.svg',
|
||||
'state.json',
|
||||
'svg-sprite.manifest.d.ts',
|
||||
'svg-sprite.manifest.js',
|
||||
])
|
||||
@@ -92,13 +104,26 @@ test('standalone Vite generates a typed facade and resolved manifest', async ()
|
||||
const entry = fs.readFileSync(path.join(rootDir, '.svg-sprite', 'index.js'), 'utf8')
|
||||
const declarations = fs.readFileSync(path.join(rootDir, '.svg-sprite', 'index.d.ts'), 'utf8')
|
||||
const manifest = fs.readFileSync(result.manifestPath, 'utf8')
|
||||
const manifestDeclarations = fs.readFileSync(path.join(rootDir, '.svg-sprite', 'svg-sprite.manifest.d.ts'), 'utf8')
|
||||
assert.match(entry, /sprite\.svg\?no-inline/)
|
||||
assert.match(entry, /export const iconsSpriteUrl = spriteUrl/)
|
||||
assert.match(entry, /export const iconsIconTagName = "icons-icon"/)
|
||||
assert.match(entry, /export function getIconsIconHref\(icon\)/)
|
||||
assert.match(entry, /export function defineIconsIconElement\(\)/)
|
||||
assert.match(entry, /class IconsIconElement extends globalThis\.HTMLElement/)
|
||||
assert.match(entry, /this\.attachShadow\(\{ mode: 'open' \}\)/)
|
||||
assert.match(entry, /"folder open": "0 0 16 16"/)
|
||||
assert.match(declarations, /getIconsIconHref\(icon: IconsIconName\): string/)
|
||||
assert.match(declarations, /export interface IconsIconElement extends HTMLElement/)
|
||||
assert.match(declarations, /icon: IconsIconName \| null/)
|
||||
assert.match(declarations, /"icons-icon": IconsIconElement/)
|
||||
assert.match(manifest, /"mode": "standalone@vite"/)
|
||||
assert.match(manifest, /export const spriteManifest = createSpriteManifest\(spriteUrl\)/)
|
||||
assert.doesNotMatch(entry, /react|jsx|\.css/)
|
||||
assert.match(manifestDeclarations, /export type SpriteManifestData/)
|
||||
assert.match(manifestDeclarations, /mode: 'standalone@vite'/)
|
||||
assert.doesNotMatch(manifestDeclarations, /from '@gromlab\/svg-sprites'/)
|
||||
assert.doesNotMatch(entry, /react|jsx|\.css|\blit\b/i)
|
||||
|
||||
})
|
||||
|
||||
test('standalone Webpack generates a typed facade and resolved manifest', async () => {
|
||||
@@ -112,31 +137,39 @@ test('standalone Webpack generates a typed facade and resolved manifest', async
|
||||
assert.equal(result.target, 'webpack')
|
||||
const entry = fs.readFileSync(path.join(rootDir, '.svg-sprite', 'index.js'), 'utf8')
|
||||
const manifest = fs.readFileSync(result.manifestPath, 'utf8')
|
||||
const manifestDeclarations = fs.readFileSync(path.join(rootDir, '.svg-sprite', 'svg-sprite.manifest.d.ts'), 'utf8')
|
||||
assert.match(entry, /new URL\('\.\/sprite\.svg', import\.meta\.url\)\.href/)
|
||||
assert.match(entry, /export function getIconsIconHref\(icon\)/)
|
||||
assert.match(entry, /export const iconsIconTagName = "icons-icon"/)
|
||||
assert.match(entry, /export function defineIconsIconElement\(\)/)
|
||||
assert.match(manifest, /"mode": "standalone@webpack"/)
|
||||
assert.match(manifest, /new URL\('\.\/sprite\.svg', import\.meta\.url\)\.href/)
|
||||
assert.doesNotMatch(entry, /react|jsx|\.css/)
|
||||
assert.match(manifestDeclarations, /export type SpriteManifestData/)
|
||||
assert.match(manifestDeclarations, /mode: 'standalone@webpack'/)
|
||||
assert.doesNotMatch(manifestDeclarations, /from '@gromlab\/svg-sprites'/)
|
||||
assert.doesNotMatch(entry, /react|jsx|\.css|\blit\b/i)
|
||||
|
||||
})
|
||||
|
||||
test('switching standalone modes removes obsolete managed files', async () => {
|
||||
test('switching standalone modes replaces the generated directory', async () => {
|
||||
const rootDir = fixture()
|
||||
const options = { name: 'icons', generatedNotice: false }
|
||||
|
||||
await generateSprite(rootDir, { ...options, mode: 'standalone@vite' })
|
||||
assert.equal(fs.existsSync(path.join(rootDir, '.gitignore')), true)
|
||||
fs.writeFileSync(path.join(rootDir, '.svg-sprite', 'user.txt'), 'keep')
|
||||
await generateSprite(rootDir, { ...options, mode: 'standalone' })
|
||||
|
||||
assert.deepEqual(generatedFiles(rootDir), [
|
||||
'sprite.svg',
|
||||
'state.json',
|
||||
'svg-sprite.manifest.json',
|
||||
'user.txt',
|
||||
])
|
||||
assert.equal(fs.readFileSync(path.join(rootDir, '.svg-sprite', 'user.txt'), 'utf8'), 'keep')
|
||||
assert.equal(fs.existsSync(path.join(rootDir, '.gitignore')), false)
|
||||
assert.equal(fs.existsSync(path.join(rootDir, '.svg-sprite', 'user.txt')), false)
|
||||
|
||||
await generateSprite(rootDir, { ...options, mode: 'standalone@webpack' })
|
||||
assert.equal(fs.existsSync(path.join(rootDir, '.gitignore')), true)
|
||||
assert.equal(fs.existsSync(path.join(rootDir, '.svg-sprite', 'svg-sprite.manifest.json')), false)
|
||||
assert.equal(fs.existsSync(path.join(rootDir, '.svg-sprite', 'index.js')), true)
|
||||
assert.equal(fs.existsSync(path.join(rootDir, '.svg-sprite', 'user.txt')), true)
|
||||
assert.equal(fs.existsSync(path.join(rootDir, '.svg-sprite', 'user.txt')), false)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user