diff --git a/package.json b/package.json index 28168475db..1d3667e5d2 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "git-raw-commits>dargs": "Force dargs@7.0.0 (CommonJS) for git-raw-commits@3.0.0 used by lerna. pnpm hoisting was causing ESM dargs@8.1.0 to be resolved instead, breaking 'pnpm run bump' with 'TypeError: dargs is not a function'" }, "engines": { - "node": ">=22", + "node": ">=22.18.0", "npm": "Use pnpm instead.", "pnpm": ">=10" }, diff --git a/packages/command-utils/lib/index.js b/packages/command-utils/lib/index.js index 9d2e9ba28b..b15468ee4c 100644 --- a/packages/command-utils/lib/index.js +++ b/packages/command-utils/lib/index.js @@ -76,7 +76,7 @@ class Command { /** * @param bin {string} The binary name, e.g. `pwd` * @param args {string[]} command arguments - * @param envVars {Object.} Environment variables + * @param [envVars] {Object.} Environment variables */ function getCommand(bin, args, envVars) { return new Command(bin, args, envVars) diff --git a/packages/pkg-utils/lib/get-changed-packages.js b/packages/pkg-utils/lib/get-changed-packages.js index 0d48f8b978..f6b7557f86 100644 --- a/packages/pkg-utils/lib/get-changed-packages.js +++ b/packages/pkg-utils/lib/get-changed-packages.js @@ -26,6 +26,10 @@ const path = require('path') const getPackages = require('./get-packages') const childProcess = require('child_process') +/** + * @param [commitIsh] {string} + * @param [allPackages] {any[]} + */ module.exports = function getChangedPackages( commitIsh = 'HEAD^1', allPackages diff --git a/packages/pkg-utils/lib/get-package.js b/packages/pkg-utils/lib/get-package.js index b546fba67a..d778fcb5b6 100644 --- a/packages/pkg-utils/lib/get-package.js +++ b/packages/pkg-utils/lib/get-package.js @@ -27,6 +27,9 @@ const path = require('path') const readPkgUp = require('read-pkg-up') const Package = require('@lerna/package').Package +/** + * @param [options] {readPkgUp.NormalizeOptions} + */ exports.getPackage = function getPackage(options) { const result = readPackage(options) return new Package(result.packageJson, path.dirname(result.path)) @@ -34,7 +37,7 @@ exports.getPackage = function getPackage(options) { /** * Reads a package.json - * @param options {readPkgUp.NormalizeOptions} + * @param [options] {readPkgUp.NormalizeOptions} * @returns {readPkgUp.NormalizedPackageJson} */ exports.getPackageJSON = function getPackageJSON(options) { diff --git a/packages/ui-codemods/package.json b/packages/ui-codemods/package.json index 1edd2ea0d5..b93801c38b 100644 --- a/packages/ui-codemods/package.json +++ b/packages/ui-codemods/package.json @@ -16,7 +16,7 @@ "lint": "ui-scripts lint", "lint:fix": "ui-scripts lint --fix", "ts:check": "tsc -p tsconfig.build.json --noEmit --emitDeclarationOnly false", - "generate:versioned-exports": "node --experimental-strip-types scripts/generateVersionedExports.ts" + "generate:versioned-exports": "node scripts/generateVersionedExports.ts" }, "license": "MIT", "dependencies": { diff --git a/packages/ui-codemods/scripts/generateVersionedExports.ts b/packages/ui-codemods/scripts/generateVersionedExports.ts index 75c3398f49..47f5170431 100644 --- a/packages/ui-codemods/scripts/generateVersionedExports.ts +++ b/packages/ui-codemods/scripts/generateVersionedExports.ts @@ -24,7 +24,7 @@ /** * Generates a list of all versioned components based on the @instructure/ui meta package - * Run with: node --experimental-strip-types scripts/generateVersionedExports.ts + * Run with: node scripts/generateVersionedExports.ts * from the packages/ui-codemods directory. */ diff --git a/packages/ui-scripts/lib/__node_tests__/create-component-version.test.ts b/packages/ui-scripts/lib/__node_tests__/create-component-version.test.ts new file mode 100644 index 0000000000..df856ce1dd --- /dev/null +++ b/packages/ui-scripts/lib/__node_tests__/create-component-version.test.ts @@ -0,0 +1,75 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { describe, it, expect } from 'vitest' +import { fuzzyMatch } from '../commands/create-component-version.ts' + +const components = [ + { label: '', dir: '', pkg: 'ui-buttons', name: 'Button', versions: ['v1'] }, + { label: '', dir: '', pkg: 'ui-text', name: 'Text', versions: ['v1'] }, + { label: '', dir: '', pkg: 'ui-tabs', name: 'Tabs', versions: ['v1', 'v2'] }, + { + label: '', + dir: '', + pkg: 'ui-buttons', + name: 'IconButton', + versions: ['v1'] + } +] + +describe('fuzzyMatch', () => { + it('matches when the query is a substring of pkg or name', () => { + expect( + fuzzyMatch(components, 'button') + .map((c) => c.name) + .sort() + ).toEqual(['Button', 'IconButton']) + }) + + it('matches characters in order even when they are not contiguous', () => { + const matches = fuzzyMatch(components, 'utt').map((c) => c.name) + expect(matches).toContain('Button') + }) + + it('is case-insensitive', () => { + expect( + fuzzyMatch(components, 'BUTTON') + .map((c) => c.name) + .sort() + ).toEqual(['Button', 'IconButton']) + }) + + it('returns an empty array when no component matches', () => { + expect(fuzzyMatch(components, 'xyz')).toEqual([]) + }) + + it('returns every component for an empty query string', () => { + expect(fuzzyMatch(components, '')).toHaveLength(components.length) + }) + + it('considers both pkg and name when scoring (ordered chars)', () => { + const matches = fuzzyMatch(components, 'tabt').map((c) => c.name) + expect(matches).toContain('Tabs') + }) +}) diff --git a/packages/ui-scripts/lib/__node_tests__/createFile.test.ts b/packages/ui-scripts/lib/__node_tests__/createFile.test.ts new file mode 100644 index 0000000000..598edbcb27 --- /dev/null +++ b/packages/ui-scripts/lib/__node_tests__/createFile.test.ts @@ -0,0 +1,87 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { describe, it, expect, afterEach } from 'vitest' +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import createFile from '../build/buildThemes/createFile.ts' + +describe('createFile', () => { + let dir: string + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }) + }) + + it('writes a file with the license header prepended to the content', async () => { + dir = mkdtempSync(join(tmpdir(), 'create-file-')) + '/' + const target = dir + 'output.ts' + + await createFile(target, 'export const x = 1') + + const written = readFileSync(target, 'utf-8') + expect(written).toContain('The MIT License (MIT)') + expect(written).toContain('export const x = 1') + expect(written.indexOf('The MIT License')).toBeLessThan( + written.indexOf('export const x = 1') + ) + }) + + it('creates missing parent directories', async () => { + dir = mkdtempSync(join(tmpdir(), 'create-file-')) + '/' + const target = dir + 'deeply/nested/output.ts' + + await createFile(target, 'hello') + + expect(existsSync(target)).toBe(true) + }) + + it('overwrites an existing file at the same path', async () => { + dir = mkdtempSync(join(tmpdir(), 'create-file-')) + '/' + const target = dir + 'output.ts' + + writeFileSync(target, 'old content', 'utf-8') + + await createFile(target, 'new content') + + const written = readFileSync(target, 'utf-8') + expect(written).toContain('new content') + expect(written).not.toContain('old content') + }) + + it('does not throw when the target does not already exist', async () => { + dir = mkdtempSync(join(tmpdir(), 'create-file-')) + '/' + const target = dir + 'output.ts' + + // The function tries to unlink first — ENOENT must be swallowed + await expect(createFile(target, 'hello')).resolves.not.toThrow() + }) +}) diff --git a/packages/ui-scripts/lib/__node_tests__/generate-custom-index.test.ts b/packages/ui-scripts/lib/__node_tests__/generate-custom-index.test.ts new file mode 100644 index 0000000000..4557852724 --- /dev/null +++ b/packages/ui-scripts/lib/__node_tests__/generate-custom-index.test.ts @@ -0,0 +1,108 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import generateCustomIndex from '../icons/generate-custom-index.ts' + +describe('generateCustomIndex', () => { + let dir: string + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + function setupFixtures() { + dir = mkdtempSync(join(tmpdir(), 'gen-custom-')) + '/' + mkdirSync(dir + 'svg/Custom', { recursive: true }) + writeFileSync( + dir + 'svg/Custom/ai-info.svg', + '' + ) + writeFileSync( + dir + 'svg/Custom/canvas-logo.svg', + '' + ) + vi.spyOn(process, 'cwd').mockReturnValue(dir.slice(0, -1)) + vi.spyOn(console, 'log').mockImplementation(() => {}) + } + + it('writes one InstUIIcon export per SVG file', () => { + setupFixtures() + generateCustomIndex() + const content = readFileSync( + dir + 'src/generated/custom/index.tsx', + 'utf-8' + ) + const exportCount = (content.match(/^export const /gm) || []).length + expect(exportCount).toBe(2) + }) + + it('converts kebab-case filenames to PascalCase icon names', () => { + setupFixtures() + generateCustomIndex() + const content = readFileSync( + dir + 'src/generated/custom/index.tsx', + 'utf-8' + ) + expect(content).toContain('export const AiInfoInstUIIcon') + expect(content).toContain('export const CanvasLogoInstUIIcon') + }) + + it('forwards the SVG viewBox to wrapCustomIcon', () => { + setupFixtures() + generateCustomIndex() + const content = readFileSync( + dir + 'src/generated/custom/index.tsx', + 'utf-8' + ) + expect(content).toContain( + "wrapCustomIcon(AiInfoPaths, 'AiInfo', '0 0 24 24')" + ) + expect(content).toContain( + "wrapCustomIcon(CanvasLogoPaths, 'CanvasLogo', '0 0 32 32')" + ) + }) + + it('rewrites fill="currentColor" to ={color} so consumers can theme the icon', () => { + setupFixtures() + generateCustomIndex() + const content = readFileSync( + dir + 'src/generated/custom/index.tsx', + 'utf-8' + ) + expect(content).toContain('fill={color}') + expect(content).not.toContain('fill="currentColor"') + }) + + it('throws if the svg/Custom directory does not exist', () => { + dir = mkdtempSync(join(tmpdir(), 'gen-custom-')) + '/' + vi.spyOn(process, 'cwd').mockReturnValue(dir.slice(0, -1)) + vi.spyOn(console, 'log').mockImplementation(() => {}) + expect(() => generateCustomIndex()).toThrow(/SVG directory not found/) + }) +}) diff --git a/packages/ui-scripts/lib/__node_tests__/generate-legacy-icons-data.test.ts b/packages/ui-scripts/lib/__node_tests__/generate-legacy-icons-data.test.ts new file mode 100644 index 0000000000..e8e284d89c --- /dev/null +++ b/packages/ui-scripts/lib/__node_tests__/generate-legacy-icons-data.test.ts @@ -0,0 +1,90 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import generateLegacyIconsData from '../icons/generate-legacy-icons-data.ts' + +describe('generateLegacyIconsData', () => { + let dir: string + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + function setupFixtures() { + dir = mkdtempSync(join(tmpdir(), 'gen-legacy-')) + '/' + // The function derives its source dir from process.cwd() + '/svg/'. + // Build that structure in our temp dir and redirect cwd to it. + mkdirSync(dir + 'svg/Solid', { recursive: true }) + mkdirSync(dir + 'svg/Line', { recursive: true }) + writeFileSync( + dir + 'svg/Solid/check.svg', + '' + ) + writeFileSync( + dir + 'svg/Line/check.svg', + '' + ) + writeFileSync(dir + 'svg/Solid/add.svg', '') + writeFileSync(dir + 'svg/Line/add.svg', '') + vi.spyOn(process, 'cwd').mockReturnValue(dir.slice(0, -1)) + } + + it('returns one merged entry per glyphName, not one per variant', () => { + setupFixtures() + const result = generateLegacyIconsData() + expect(result).toHaveLength(2) + }) + + it('merges Line and Solid variants into lineSrc and solidSrc', () => { + setupFixtures() + const result = generateLegacyIconsData() as Array<{ + name: string + glyphName: string + bidirectional: boolean + lineSrc?: string + solidSrc?: string + }> + + const check = result.find((g) => g.glyphName === 'check') + expect(check).toBeDefined() + expect(check?.name).toEqual('IconCheck') + expect(check?.lineSrc).toEqual('') + expect(check?.solidSrc).toEqual('') + }) + + it('keeps bidirectional false (the function hardcodes an empty list)', () => { + setupFixtures() + const result = generateLegacyIconsData() as Array<{ + bidirectional: boolean + }> + for (const entry of result) { + expect(entry.bidirectional).toBe(false) + } + }) +}) diff --git a/packages/ui-scripts/lib/__node_tests__/generate-lucide-index.test.ts b/packages/ui-scripts/lib/__node_tests__/generate-lucide-index.test.ts new file mode 100644 index 0000000000..6af2f73d13 --- /dev/null +++ b/packages/ui-scripts/lib/__node_tests__/generate-lucide-index.test.ts @@ -0,0 +1,79 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import generateLucideIndex from '../icons/generate-lucide-index.ts' + +describe('generateLucideIndex', () => { + let dir: string + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + function setupFixtures(customIcons: string[] = []) { + dir = mkdtempSync(join(tmpdir(), 'gen-lucide-')) + '/' + mkdirSync(dir + 'svg/Custom', { recursive: true }) + for (const name of customIcons) { + writeFileSync(dir + `svg/Custom/${name}.svg`, '') + } + vi.spyOn(process, 'cwd').mockReturnValue(dir.slice(0, -1)) + vi.spyOn(console, 'log').mockImplementation(() => {}) + } + + it('writes a wrapped export for each Lucide icon', () => { + setupFixtures() + generateLucideIndex() + const content = readFileSync(dir + 'src/generated/lucide/index.ts', 'utf-8') + const exportCount = (content.match(/^export const /gm) || []).length + expect(exportCount).toBeGreaterThan(0) + // Every exported icon should follow the wrapLucideIcon pattern + expect(content).toMatch( + /export const \w+InstUIIcon = wrapLucideIcon\(Lucide\.\w+\)/ + ) + }) + + it('excludes Lucide icons that are shadowed by a Custom SVG of the same name', () => { + // svg/Custom/check.svg → PascalCase "Check" — Lucide also has a Check icon + setupFixtures(['check']) + generateLucideIndex() + const content = readFileSync(dir + 'src/generated/lucide/index.ts', 'utf-8') + // Should NOT export the shadowed Lucide icon + expect(content).not.toContain('export const CheckInstUIIcon') + }) + + it('imports the wrap helper and re-exports under InstUIIcon-suffixed names', () => { + setupFixtures() + generateLucideIndex() + const content = readFileSync(dir + 'src/generated/lucide/index.ts', 'utf-8') + expect(content).toContain( + "import { wrapLucideIcon } from '../../lucide/wrapLucideIcon'" + ) + expect(content).toContain("import * as Lucide from 'lucide-react'") + }) +}) diff --git a/packages/ui-scripts/lib/__node_tests__/generate-react-components.test.ts b/packages/ui-scripts/lib/__node_tests__/generate-react-components.test.ts new file mode 100644 index 0000000000..5a14e3ffcf --- /dev/null +++ b/packages/ui-scripts/lib/__node_tests__/generate-react-components.test.ts @@ -0,0 +1,83 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest' +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import generateReactComponents from '../icons/generate-react-components.ts' + +describe('generateReactComponents', () => { + let dir: string + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }) + }) + + it('writes one .tsx per glyph plus an index.ts', async () => { + dir = mkdtempSync(join(tmpdir(), 'gen-react-')) + '/' + + const glyphs = [ + { + name: 'IconCheck', + variant: 'Solid' as const, + glyphName: 'check', + src: '', + bidirectional: false + }, + { + name: 'IconAdd', + variant: 'Line' as const, + glyphName: 'add', + src: '', + bidirectional: false + } + ] + + generateReactComponents(glyphs, dir) + + await vi.waitFor(() => { + const found = readdirSync(dir).sort() + expect(found).toEqual([ + 'IconAddLine.tsx', + 'IconCheckSolid.tsx', + 'index.ts' + ]) + }) + + const checkComponent = readFileSync(dir + 'IconCheckSolid.tsx', 'utf-8') + expect(checkComponent).toContain('class IconCheckSolid extends Component') + expect(checkComponent).toContain('viewBox="0 0 10 10"') + + const indexFile = readFileSync(dir + 'index.ts', 'utf-8') + + const exportLineCount = (indexFile.match(/^export \{/gm) || []).length + expect(exportLineCount).toBe(glyphs.length) + + expect(indexFile).toContain( + "export { IconCheckSolid } from './IconCheckSolid'" + ) + expect(indexFile).toContain("export { IconAddLine } from './IconAddLine'") + }) +}) diff --git a/packages/ui-scripts/lib/__node_tests__/generate-svg-index.test.ts b/packages/ui-scripts/lib/__node_tests__/generate-svg-index.test.ts new file mode 100644 index 0000000000..ec08bbc8bc --- /dev/null +++ b/packages/ui-scripts/lib/__node_tests__/generate-svg-index.test.ts @@ -0,0 +1,82 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import generateSvgIndex from '../icons/generate-svg-index.ts' + +describe('generateSvgIndex', () => { + let dir: string + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }) + }) + + it('writes a single svg/index.js containing one export per glyph', async () => { + dir = mkdtempSync(join(tmpdir(), 'gen-svg-idx-')) + '/' + + const glyphs = [ + { + name: 'IconCheck', + variant: 'Solid' as const, + glyphName: 'check', + src: '', + bidirectional: false + }, + { + name: 'IconAdd', + variant: 'Line' as const, + glyphName: 'add', + src: '', + bidirectional: false + } + ] + + generateSvgIndex(glyphs, dir) + + const indexPath = dir + 'svg/index.js' + + const content = await vi.waitFor(() => { + const c = readFileSync(indexPath, 'utf-8') + expect(c).toContain('export const IconCheckSolid') + expect(c).toContain('export const IconAddLine') + return c + }) + + const exportLineCount = (content.match(/^export const /gm) || []).length + expect(exportLineCount).toBe(glyphs.length) + + expect(content).toContain('export const IconCheckSolid = {') + expect(content).toContain('variant: "Solid"') + expect(content).toContain('glyphName: "check"') + expect(content).toContain('') + + expect(content).toContain('export const IconAddLine = {') + expect(content).toContain('variant: "Line"') + expect(content).toContain('glyphName: "add"') + expect(content).toContain('') + }) +}) diff --git a/packages/ui-scripts/lib/__node_tests__/generateComponents.test.ts b/packages/ui-scripts/lib/__node_tests__/generateComponents.test.ts new file mode 100644 index 0000000000..dfd7390f42 --- /dev/null +++ b/packages/ui-scripts/lib/__node_tests__/generateComponents.test.ts @@ -0,0 +1,146 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { MockInstance } from 'vitest' +import generateComponent, { + resolveReferences, + generateComponentType +} from '../build/buildThemes/generateComponents.ts' + +describe('resolveReferences', () => { + it('wraps a plain string leaf in quotes and appends a comma-newline', () => { + expect(resolveReferences({ color: '#fff' })).toEqual('color: "#fff",\n') + }) + + it('passes numeric values through without quotes', () => { + expect(resolveReferences({ size: 8 })).toEqual('size: 8,\n') + }) + + it('rewrites a name-ending reference as a dotted path', () => { + expect(resolveReferences({ primary: '{colors.brand.blue}' })).toEqual( + 'primary: colors.brand.blue,\n' + ) + }) + + it('rewrites a numeric-ending reference using bracket notation', () => { + expect(resolveReferences({ primary: '{colors.blue.500}' })).toEqual( + 'primary: colors.blue[500],\n' + ) + }) + + it('serializes nested groups with brace-wrapped sub-objects', () => { + const input = { + brand: { primary: '#fff' }, + ui: { background: '#000' } + } + expect(resolveReferences(input)).toEqual( + 'brand: {primary: "#fff",\n},\nui: {background: "#000",\n}' + ) + }) + + describe('with an empty-string leaf', () => { + let warnSpy: MockInstance + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('defaults to 0 and emits a warning', () => { + expect(resolveReferences({ width: '' })).toEqual('width: 0,\n') + expect(warnSpy).toHaveBeenCalledOnce() + }) + }) +}) + +describe('generateComponentType', () => { + it('maps a color-typed token to a string TS type', () => { + const input = { color: { value: '#fff', type: 'color' } } + expect(generateComponentType(input)).toEqual('{color: string\n}') + }) + + it('maps a number-typed token to a number TS type', () => { + const input = { count: { value: 3, type: 'number' } } + expect(generateComponentType(input)).toEqual('{count: number\n}') + }) + + it('maps a boolean-typed token to the true|false literal union', () => { + const input = { flag: { value: true, type: 'boolean' } } + expect(generateComponentType(input)).toEqual("{flag: 'true' | 'false'\n}") + }) + + it('maps a fontWeights-typed token to string | number', () => { + const input = { weight: { value: 600, type: 'fontWeights' } } + expect(generateComponentType(input)).toEqual('{weight: string | number\n}') + }) + + it('wraps nested groups in brace-wrapped sub-types', () => { + const input = { + group: { + nested: { value: '#fff', type: 'color' } + } + } + expect(generateComponentType(input)).toEqual( + '{group: {nested: string\n}\n}' + ) + }) + + it('inlines token descriptions as JSDoc comments', () => { + const input = { + color: { + value: '#fff', + type: 'color', + description: 'The primary color' + } + } + expect(generateComponentType(input)).toEqual( + '{/** The primary color */\ncolor: string\n}' + ) + }) + + it('throws on unknown token types', () => { + const input = { x: { value: 'whatever', type: 'unknownType' } } + expect(() => generateComponentType(input)).toThrow(/unknown token type/) + }) +}) + +describe('generateComponent (default export)', () => { + it('formats and resolves a Tokens-Studio-style input end-to-end', () => { + const input = { + color: { value: '#fff', type: 'color' } + } + expect(generateComponent(input)).toEqual('color: "#fff",\n') + }) + + it('handles reference values end-to-end', () => { + const input = { + primary: { value: '{colors.brand.blue}', type: 'color' } + } + expect(generateComponent(input)).toEqual('primary: colors.brand.blue,\n') + }) +}) diff --git a/packages/ui-scripts/lib/__node_tests__/generatePrimitives.test.ts b/packages/ui-scripts/lib/__node_tests__/generatePrimitives.test.ts new file mode 100644 index 0000000000..412ad2ab1f --- /dev/null +++ b/packages/ui-scripts/lib/__node_tests__/generatePrimitives.test.ts @@ -0,0 +1,97 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { describe, it, expect } from 'vitest' +import { + generatePrimitives, + generateType +} from '../build/buildThemes/generatePrimitives.ts' + +describe('generatePrimitives', () => { + it('flattens a single leaf token to its value', () => { + const input = { primary: { value: '#fff', type: 'color' } } + expect(generatePrimitives(input)).toEqual({ primary: '#fff' }) + }) + + it('parses numeric-looking values into numbers', () => { + const input = { sm: { value: '8', type: 'dimension' } } + expect(generatePrimitives(input)).toEqual({ sm: 8 }) + }) + + it('recurses into nested groups', () => { + const input = { + colors: { + brand: { + primary: { value: '#fff', type: 'color' }, + accent: { value: '#0f0', type: 'color' } + } + } + } + expect(generatePrimitives(input)).toEqual({ + colors: { + brand: { + primary: '#fff', + accent: '#0f0' + } + } + }) + }) + + it('returns an empty object for empty input', () => { + expect(generatePrimitives({})).toEqual({}) + }) + + it('handles a mix of string and numeric tokens', () => { + const input = { + colors: { primary: { value: '#fff', type: 'color' } }, + spacing: { sm: { value: '4', type: 'dimension' } } + } + expect(generatePrimitives(input)).toEqual({ + colors: { primary: '#fff' }, + spacing: { sm: 4 } + }) + }) +}) + +describe('generateType', () => { + it('produces a string TS type for a string leaf token', () => { + expect(generateType({ primary: '#fff' })).toEqual('{primary: string, }') + }) + + it('produces a number TS type for numeric-looking values', () => { + expect(generateType({ sm: '8' })).toEqual('{sm: number, }') + }) + + it('wraps nested groups in nested braces', () => { + expect(generateType({ colors: { primary: '#fff' } })).toEqual( + '{colors: {primary: string, }}' + ) + }) + + it('serializes multiple sibling keys', () => { + expect(generateType({ primary: '#fff', accent: '#0f0' })).toEqual( + '{primary: string, accent: string, }' + ) + }) +}) diff --git a/packages/ui-scripts/lib/__node_tests__/generateSemantics.test.ts b/packages/ui-scripts/lib/__node_tests__/generateSemantics.test.ts new file mode 100644 index 0000000000..7a07a1ad59 --- /dev/null +++ b/packages/ui-scripts/lib/__node_tests__/generateSemantics.test.ts @@ -0,0 +1,146 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { describe, it, expect } from 'vitest' +import generateSemantics, { + resolveReferences, + resolveTypeReferences, + mergeSemanticSets, + generateSemanticsType +} from '../build/buildThemes/generateSemantics.ts' + +describe('resolveReferences', () => { + it('quotes plain string leaf values', () => { + expect(resolveReferences({ color: '#fff' })).toEqual('"color": "#fff",\n') + }) + + it('passes numeric values through without quotes', () => { + expect(resolveReferences({ size: 8 })).toEqual('"size": 8,\n') + }) + + it('rewrites a name-ending reference as primitives.', () => { + expect(resolveReferences({ primary: '{colors.brand.blue}' })).toEqual( + '"primary": primitives.colors.brand.blue,\n' + ) + }) + + it('rewrites a numeric-ending reference using bracket notation', () => { + expect(resolveReferences({ primary: '{colors.blue.500}' })).toEqual( + '"primary": primitives.colors.blue[500],\n' + ) + }) + + it('serializes nested groups with brace-wrapped sub-objects', () => { + const input = { + brand: { primary: '#fff' }, + ui: { background: '#000' } + } + expect(resolveReferences(input)).toEqual( + '"brand": {"primary": "#fff",\n},\n"ui": {"background": "#000",\n}' + ) + }) +}) + +describe('resolveTypeReferences', () => { + it('produces "string, " for string leaves', () => { + expect(resolveTypeReferences({ color: '#fff' })).toEqual( + '"color": string, ' + ) + }) + + it('produces "number, " for numeric leaves', () => { + expect(resolveTypeReferences({ size: 8 })).toEqual('"size": number, ') + }) + + it('rewrites a reference as Primitives[indexed]', () => { + expect(resolveTypeReferences({ primary: '{colors.brand.blue}' })).toEqual( + "\"primary\": Primitives['colors']['brand']['blue'], " + ) + }) + + it('wraps nested groups in brace-wrapped sub-types', () => { + const input = { + group: { nested: '#fff' } + } + expect(resolveTypeReferences(input)).toEqual( + '"group": {"nested": string, }' + ) + }) +}) + +describe('mergeSemanticSets', () => { + it('returns an empty object for an empty list', () => { + expect(mergeSemanticSets([])).toEqual({}) + }) + + it('returns the same shape for a single-set input', () => { + const set = { colors: { primary: '#fff' } } + expect(mergeSemanticSets([set])).toEqual(set) + }) + + it('deep-merges two semantic sets', () => { + const a = { colors: { primary: '#fff' } } + const b = { colors: { secondary: '#000' } } + expect(mergeSemanticSets([a, b])).toEqual({ + colors: { primary: '#fff', secondary: '#000' } + }) + }) + + it('lets later sets override earlier ones for the same key', () => { + const a = { colors: { primary: '#fff' } } + const b = { colors: { primary: '#000' } } + expect(mergeSemanticSets([a, b])).toEqual({ + colors: { primary: '#000' } + }) + }) +}) + +describe('generateSemanticsType', () => { + it('wraps the inner type string in braces', () => { + const input = { + colors: { primary: { value: '#fff', type: 'color' } } + } + expect(generateSemanticsType(input)).toEqual( + '{"colors": {"primary": string, }}' + ) + }) +}) + +describe('generateSemantics (default export)', () => { + it('flattens Tokens-Studio leaves and resolves to a quoted output', () => { + const input = { + color: { value: '#fff', type: 'color' } + } + expect(generateSemantics(input)).toEqual('"color": "#fff",\n') + }) + + it('resolves reference values to primitives.', () => { + const input = { + primary: { value: '{colors.brand.blue}', type: 'color' } + } + expect(generateSemantics(input)).toEqual( + '"primary": primitives.colors.brand.blue,\n' + ) + }) +}) diff --git a/packages/ui-scripts/lib/__node_tests__/get-glyph-data.test.ts b/packages/ui-scripts/lib/__node_tests__/get-glyph-data.test.ts new file mode 100644 index 0000000000..afbe96da94 --- /dev/null +++ b/packages/ui-scripts/lib/__node_tests__/get-glyph-data.test.ts @@ -0,0 +1,111 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { describe, it, expect, afterEach } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import getGlyphData from '../icons/get-glyph-data.ts' + +describe('getGlyphData', () => { + let dir: string + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }) + }) + + // Set up two icons in both Solid and Line variants, plus a stray + // non-svg file we expect the function to ignore. + function setupFixtures() { + dir = mkdtempSync(join(tmpdir(), 'get-glyph-')) + '/' + mkdirSync(dir + 'Solid') + mkdirSync(dir + 'Line') + writeFileSync(dir + 'Solid/check.svg', '') + writeFileSync(dir + 'Line/check.svg', '') + writeFileSync(dir + 'Solid/add.svg', '') + writeFileSync(dir + 'Line/add.svg', '') + writeFileSync(dir + 'Solid/README.txt', 'not an svg, must be ignored') + } + + it('returns one entry per icon × variant', () => { + setupFixtures() + const result = getGlyphData(dir, [], 'Icon') + expect(result).toHaveLength(4) // 2 icons × 2 variants + }) + + it('ignores non-svg files in the variant directories', () => { + setupFixtures() + const result = getGlyphData(dir, [], 'Icon') + expect(result.every((g) => !g.src.includes('not an svg'))).toBe(true) + }) + + it('applies the prefix to each component name', () => { + setupFixtures() + const result = getGlyphData(dir, [], 'MyIcon') + for (const glyph of result) { + expect(glyph.name.startsWith('MyIcon')).toBe(true) + } + expect(result.map((g) => g.name).sort()).toEqual([ + 'MyIconAdd', + 'MyIconAdd', + 'MyIconCheck', + 'MyIconCheck' + ]) + }) + + it('flags glyphs as bidirectional only when listed', () => { + setupFixtures() + const result = getGlyphData(dir, ['check'], 'Icon') + + const check = result.find( + (g) => g.glyphName === 'check' && g.variant === 'Solid' + ) + const add = result.find( + (g) => g.glyphName === 'add' && g.variant === 'Solid' + ) + + expect(check?.bidirectional).toBe(true) + expect(add?.bidirectional).toBe(false) + }) + + it('reads the full SVG source content into the src field', () => { + setupFixtures() + const result = getGlyphData(dir, [], 'Icon') + + expect(result).toContainEqual({ + name: 'IconCheck', + glyphName: 'check', + variant: 'Solid', + src: '', + bidirectional: false + }) + expect(result).toContainEqual({ + name: 'IconAdd', + glyphName: 'add', + variant: 'Line', + src: '', + bidirectional: false + }) + }) +}) diff --git a/packages/ui-scripts/lib/__node_tests__/handle-map-js-tokens-to-source.test.ts b/packages/ui-scripts/lib/__node_tests__/handle-map-js-tokens-to-source.test.ts new file mode 100644 index 0000000000..a3e2bad32f --- /dev/null +++ b/packages/ui-scripts/lib/__node_tests__/handle-map-js-tokens-to-source.test.ts @@ -0,0 +1,107 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { describe, it, expect } from 'vitest' +import { handleMapJSTokensToSource } from '../utils/handle-map-js-tokens-to-source.ts' + +describe('handleMapJSTokensToSource', () => { + it('wraps leaf primitive values in { value }', () => { + const input = { colors: { primary: '#fff', secondary: '#000' } } + expect(handleMapJSTokensToSource(input)).toEqual({ + colors: { + primary: { value: '#fff' }, + secondary: { value: '#000' } + } + }) + }) + + it('preserves nested groups recursively', () => { + const input = { + colors: { + brand: { primary: '#fff', accent: '#0f0' }, + ui: { background: '#000' } + } + } + expect(handleMapJSTokensToSource(input)).toEqual({ + colors: { + brand: { + primary: { value: '#fff' }, + accent: { value: '#0f0' } + }, + ui: { + background: { value: '#000' } + } + } + }) + }) + + it('excludes the "media" top-level key', () => { + const input = { + colors: { primary: '#fff' }, + media: { sm: 480 } + } + const result = handleMapJSTokensToSource(input) + expect(result).toEqual({ + colors: { primary: { value: '#fff' } } + }) + expect(result).not.toHaveProperty('media') + }) + + it('drops top-level non-object values', () => { + const input = { + version: '1.0.0', + colors: { primary: '#fff' } + } + expect(handleMapJSTokensToSource(input)).toEqual({ + colors: { primary: { value: '#fff' } } + }) + }) + + it('returns empty object for empty input', () => { + expect(handleMapJSTokensToSource({})).toEqual({}) + }) + + it('keeps an empty group as an empty group', () => { + expect(handleMapJSTokensToSource({ colors: {} })).toEqual({ colors: {} }) + }) + + it('wraps numeric leaf values', () => { + const input = { spacing: { sm: 8, md: 16 } } + expect(handleMapJSTokensToSource(input)).toEqual({ + spacing: { + sm: { value: 8 }, + md: { value: 16 } + } + }) + }) + + it('treats arrays as leaf values (not as plain objects)', () => { + const input = { gradients: { warm: ['#f00', '#ff0'] } } + expect(handleMapJSTokensToSource(input)).toEqual({ + gradients: { + warm: { value: ['#f00', '#ff0'] } + } + }) + }) +}) diff --git a/packages/ui-scripts/lib/__node_tests__/lint.test.ts b/packages/ui-scripts/lib/__node_tests__/lint.test.ts new file mode 100644 index 0000000000..a6a494a680 --- /dev/null +++ b/packages/ui-scripts/lib/__node_tests__/lint.test.ts @@ -0,0 +1,78 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@instructure/command-utils', () => ({ + runCommandSync: vi.fn() +})) + +import lint from '../test/lint.ts' +import { runCommandSync } from '@instructure/command-utils' + +const runMock = vi.mocked(runCommandSync) + +describe('lint handler', () => { + beforeEach(() => { + runMock.mockClear() + }) + + it('lints "." when no positional paths are provided', async () => { + await lint.handler({ _: ['lint'] }) + expect(runMock).toHaveBeenCalledWith('eslint', ['.']) + }) + + it('keeps only .js, .jsx, .ts, .tsx paths', async () => { + await lint.handler({ + _: ['lint', 'a.ts', 'b.js', 'c.tsx', 'd.jsx', 'e.md', 'f.json'] + }) + const [, paths] = runMock.mock.calls[0] + expect(paths).toEqual(['a.ts', 'b.js', 'c.tsx', 'd.jsx']) + }) + + it('drops paths with non-JS/TS extensions', async () => { + await lint.handler({ _: ['lint', 'README.md', 'package.json'] }) + expect(runMock).not.toHaveBeenCalled() + }) + + it('passes --fix to eslint when argv.fix is true', async () => { + await lint.handler({ _: ['lint', 'a.ts'], fix: true }) + expect(runMock).toHaveBeenCalledWith('eslint', ['a.ts', '--fix']) + }) + + it('does not pass --fix when argv.fix is false', async () => { + await lint.handler({ _: ['lint', 'a.ts'], fix: false }) + expect(runMock).toHaveBeenCalledWith('eslint', ['a.ts']) + }) + + it('does not pass --fix when argv.fix is undefined', async () => { + await lint.handler({ _: ['lint', 'a.ts'] }) + expect(runMock).toHaveBeenCalledWith('eslint', ['a.ts']) + }) + + it('appends --fix even when linting the default "." path', async () => { + await lint.handler({ _: ['lint'], fix: true }) + expect(runMock).toHaveBeenCalledWith('eslint', ['.', '--fix']) + }) +}) diff --git a/packages/ui-scripts/lib/__node_tests__/svg2jsx.test.ts b/packages/ui-scripts/lib/__node_tests__/svg2jsx.test.ts new file mode 100644 index 0000000000..8c08fbab8d --- /dev/null +++ b/packages/ui-scripts/lib/__node_tests__/svg2jsx.test.ts @@ -0,0 +1,111 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { describe, it, expect } from 'vitest' +import { convertSvgAttrName, svg2jsx } from '../icons/svg2jsx.ts' + +describe('convertSvgAttrName', () => { + it('maps xlink:href to xlinkHref', () => { + expect(convertSvgAttrName('xlink:href')).toEqual('xlinkHref') + }) + + it('maps xml:space to xmlSpace', () => { + expect(convertSvgAttrName('xml:space')).toEqual('xmlSpace') + }) + + it('maps xml:lang to xmlLang', () => { + expect(convertSvgAttrName('xml:lang')).toEqual('xmlLang') + }) + + it('preserves data-* attributes unchanged', () => { + expect(convertSvgAttrName('data-foo')).toEqual('data-foo') + expect(convertSvgAttrName('data-my-attr')).toEqual('data-my-attr') + }) + + it('preserves aria-* attributes unchanged', () => { + expect(convertSvgAttrName('aria-label')).toEqual('aria-label') + expect(convertSvgAttrName('aria-hidden')).toEqual('aria-hidden') + }) + + it('preserves xmlns and xmlns:* attributes unchanged', () => { + expect(convertSvgAttrName('xmlns')).toEqual('xmlns') + expect(convertSvgAttrName('xmlns:xlink')).toEqual('xmlns:xlink') + }) + + it('converts kebab-case attributes to camelCase', () => { + expect(convertSvgAttrName('stroke-width')).toEqual('strokeWidth') + expect(convertSvgAttrName('stroke-linecap')).toEqual('strokeLinecap') + expect(convertSvgAttrName('fill-rule')).toEqual('fillRule') + }) + + it('leaves single-word attributes unchanged', () => { + expect(convertSvgAttrName('fill')).toEqual('fill') + expect(convertSvgAttrName('width')).toEqual('width') + }) +}) + +describe('svg2jsx', () => { + it('returns empty string for empty input', () => { + expect(svg2jsx('')).toEqual('') + }) + + it('returns empty string for whitespace-only input', () => { + expect(svg2jsx(' \n ')).toEqual('') + }) + + it('converts kebab-case attribute names to camelCase', () => { + const input = '' + expect(svg2jsx(input)).toEqual( + '' + ) + }) + + it('preserves data-* and aria-* attributes', () => { + const input = '' + expect(svg2jsx(input)).toEqual( + '' + ) + }) + + it('self-closes void/SVG elements that lack a trailing slash', () => { + const input = '' + expect(svg2jsx(input)).toContain('') + expect(svg2jsx(input)).toContain('') + }) + + it('normalizes already self-closed tags', () => { + const input = '' + expect(svg2jsx(input)).toContain('') + }) + + it('escapes curly braces inside attribute values', () => { + const input = '' + expect(svg2jsx(input)).toEqual('') + }) + + it('handles xlink:href via the special-case mapping', () => { + const input = '' + expect(svg2jsx(input)).toContain('') + }) +}) diff --git a/packages/ui-scripts/lib/__node_tests__/visual-diff.test.ts b/packages/ui-scripts/lib/__node_tests__/visual-diff.test.ts new file mode 100644 index 0000000000..eb2955f725 --- /dev/null +++ b/packages/ui-scripts/lib/__node_tests__/visual-diff.test.ts @@ -0,0 +1,157 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { describe, it, expect } from 'vitest' +import { + badgeFor, + thumb, + indexByName, + sourceLinkFor +} from '../commands/visual-diff.ts' + +describe('badgeFor', () => { + it('returns the OK pill for unchanged status', () => { + const html = badgeFor('unchanged') + expect(html).toContain('class="pill pass"') + expect(html).toContain('>ok<') + }) + + it('returns the fail pill for changed status', () => { + const html = badgeFor('changed') + expect(html).toContain('class="pill fail"') + expect(html).toContain('>changed<') + }) + + it('returns the new pill for added status', () => { + const html = badgeFor('added') + expect(html).toContain('class="pill new"') + expect(html).toContain('>new<') + }) + + it('returns the removed pill for removed status', () => { + const html = badgeFor('removed') + expect(html).toContain('class="pill gone"') + expect(html).toContain('>removed<') + }) +}) + +describe('thumb', () => { + it('builds an img tag with the right src for the given mode and name', () => { + expect(thumb('baseline', 'Button.png')).toContain( + 'src="baseline/Button.png"' + ) + expect(thumb('actual', 'Card.png')).toContain('src="actual/Card.png"') + expect(thumb('diff', 'Tabs.png')).toContain('src="diff/Tabs.png"') + }) + + it('includes data-name and data-mode attributes for JS hooks', () => { + const html = thumb('baseline', 'Button.png') + expect(html).toContain('data-name="Button.png"') + expect(html).toContain('data-mode="baseline"') + }) + + it('marks the image as lazy-loaded and uses the thumb class', () => { + const html = thumb('baseline', 'Button.png') + expect(html).toContain('loading="lazy"') + expect(html).toContain('class="thumb"') + }) +}) + +describe('indexByName', () => { + it('maps each file path to an entry keyed by its basename', () => { + const result = indexByName([ + '/some/dir/Button.png', + '/another/dir/Card.png' + ]) + expect(result.get('Button.png')).toEqual({ path: '/some/dir/Button.png' }) + expect(result.get('Card.png')).toEqual({ path: '/another/dir/Card.png' }) + }) + + it('contains exactly one entry per input file', () => { + expect(indexByName(['/a/X.png', '/b/Y.png', '/c/Z.png']).size).toBe(3) + }) + + it('returns an empty map for an empty input list', () => { + expect(indexByName([]).size).toBe(0) + }) +}) + +describe('sourceLinkFor', () => { + it('returns an empty string when no meta is provided', () => { + expect(sourceLinkFor('Button.png', null, 'https://github.com/x/y')).toBe('') + }) + + it('returns an empty string when no sourceBaseUrl is provided', () => { + expect(sourceLinkFor('Button.png', { Button: '/components/button' })).toBe( + '' + ) + }) + + it('returns an empty string when meta has no entry for the screenshot', () => { + expect( + sourceLinkFor('Unknown.png', { Button: '/x' }, 'https://github.com/x/y') + ).toBe('') + }) + + it('builds an absolute href when meta has the entry', () => { + const html = sourceLinkFor( + 'Button.png', + { Button: '/components/button' }, + 'https://github.com/instructure/instructure-ui/blob/main/regression-test/src/app' + ) + expect(html).toContain( + 'href="https://github.com/instructure/instructure-ui/blob/main/regression-test/src/app/components/button/page.tsx"' + ) + }) + + it('strips a trailing slash from sourceBaseUrl when building the href', () => { + const html = sourceLinkFor( + 'Button.png', + { Button: '/components/button' }, + 'https://example.com/base/' + ) + expect(html).toContain( + 'href="https://example.com/base/components/button/page.tsx"' + ) + }) + + it('uses a display path that drops the leading slash', () => { + const html = sourceLinkFor( + 'Button.png', + { Button: '/components/button' }, + 'https://example.com' + ) + expect(html).toContain('>components/button/page.tsx<') + }) + + it('opens the link in a new tab with noopener', () => { + const html = sourceLinkFor( + 'Button.png', + { Button: '/x' }, + 'https://example.com' + ) + expect(html).toContain('target="_blank"') + expect(html).toContain('rel="noopener"') + }) +}) diff --git a/packages/ui-scripts/lib/build/babel.js b/packages/ui-scripts/lib/build/babel.ts similarity index 91% rename from packages/ui-scripts/lib/build/babel.js rename to packages/ui-scripts/lib/build/babel.ts index b8a4e9b4ae..e492ad17ac 100644 --- a/packages/ui-scripts/lib/build/babel.js +++ b/packages/ui-scripts/lib/build/babel.ts @@ -25,15 +25,16 @@ import path from 'path' import { runCommandsConcurrently, getCommand } from '@instructure/command-utils' import { fileURLToPath } from 'url' +import type { Argv } from 'yargs' const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const specifyCJSFormat = path.resolve(__dirname, 'specify-commonjs-format.js') +const specifyCJSFormat = path.resolve(__dirname, 'specify-commonjs-format.ts') export default { command: 'build', desc: 'Build the packages with Babel.js', - builder: (yargs) => { + builder: (yargs: Argv) => { yargs.option('copy-files', { boolean: true, desc: 'Copy files that will not be compiled' @@ -47,11 +48,11 @@ export default { desc: 'What kind of modules to build. "es": build into the /es folder using ESM; "cjs": build into the /lib folder using commonJS', choices: ['es', 'cjs'], default: 'es', - coerce: (value) => value.split(',') + coerce: (value: string) => value.split(',') }) yargs.strictOptions(true) }, - handler: async (argv) => { + handler: async (argv: any) => { const { BABEL_ENV, NODE_ENV, DEBUG, OMIT_INSTUI_DEPRECATION_WARNINGS } = process.env @@ -87,7 +88,7 @@ export default { } } - const commands = { + const commands: Record = { es: getCommand('babel', [...babelArgs, '--out-dir', 'es'], { ...envVars, ...{ ES_MODULES: '1' } @@ -102,7 +103,10 @@ export default { } const commandsToRun = argv.modules.reduce( - (obj, key) => ({ ...obj, [key]: commands[key] }), + (obj: Record, key: string) => ({ + ...obj, + [key]: commands[key] + }), {} ) runCommandsConcurrently(commandsToRun) diff --git a/packages/ui-scripts/lib/build/buildThemes/createFile.js b/packages/ui-scripts/lib/build/buildThemes/createFile.ts similarity index 94% rename from packages/ui-scripts/lib/build/buildThemes/createFile.js rename to packages/ui-scripts/lib/build/buildThemes/createFile.ts index 7b97f075e0..e7d7a244fa 100644 --- a/packages/ui-scripts/lib/build/buildThemes/createFile.js +++ b/packages/ui-scripts/lib/build/buildThemes/createFile.ts @@ -49,11 +49,13 @@ const license = `/* ` -const createFile = async (filePath, fileContent) => { +const createFile = async (filePath: string, fileContent: string) => { try { await promises.unlink(filePath) } catch (error) { - if (error.code !== 'ENOENT') { + if ( + !(error instanceof Error && 'code' in error && error.code === 'ENOENT') + ) { // Only throw if it's not a "file not found" error throw error } diff --git a/packages/ui-scripts/lib/build/buildThemes/generateComponents.js b/packages/ui-scripts/lib/build/buildThemes/generateComponents.ts similarity index 92% rename from packages/ui-scripts/lib/build/buildThemes/generateComponents.js rename to packages/ui-scripts/lib/build/buildThemes/generateComponents.ts index 63f928baf7..73dcf00ce5 100644 --- a/packages/ui-scripts/lib/build/buildThemes/generateComponents.js +++ b/packages/ui-scripts/lib/build/buildThemes/generateComponents.ts @@ -22,10 +22,10 @@ * SOFTWARE. */ -const isReference = (expression) => +const isReference = (expression: string) => expression[0] === '{' && expression[expression.length - 1] === '}' -const formatComponent = (collection, key) => { +const formatComponent = (collection: any, key?: string): any => { const value = key ? collection[key] : collection if (typeof value === 'object' && !value.value && !value.type) { return Object.keys(value).reduce((acc, key) => { @@ -45,7 +45,7 @@ const formatComponent = (collection, key) => { return value.value } -const formatReference = (reference) => { +const formatReference = (reference: string) => { const referenceArr = reference.slice(1, -1).split('.') const lastElement = referenceArr[referenceArr.length - 1] @@ -55,7 +55,7 @@ const formatReference = (reference) => { return `${reference.slice(1, -1)},\n` } -export const resolveReferences = (semantics, key) => { +export const resolveReferences = (semantics: any, key?: string): string => { const value = key ? semantics[key] : semantics if (typeof value === 'object') { return Object.keys(value).reduce((acc, key, index) => { @@ -86,12 +86,12 @@ export const resolveReferences = (semantics, key) => { return `"${value}",\n` } -const generateComponent = (data) => { +const generateComponent = (data: any) => { const formattedSemantic = formatComponent(data) return resolveReferences(formattedSemantic) } -const parseType = (key, tokenObject, acc) => { +const parseType = (key: string, tokenObject: any, acc: string): string => { let ret = acc if (tokenObject.type) { // Add composition token support if needed @@ -102,7 +102,7 @@ const parseType = (key, tokenObject, acc) => { // the following types are coming from SingleXYToken in @token-studio/types // we could add them as imports from @token-studio/types if needed case 'boolean': - ret += 'true' | 'false' + ret += "'true' | 'false'" break case 'textDecoration': ret += "'none' | 'underline' | 'line-through' | 'strikethrough'" @@ -174,7 +174,7 @@ const parseType = (key, tokenObject, acc) => { * Generates the type for a component as a JSON string * @param data an object directly from a Tokens Studio JSON file. */ -export const generateComponentType = (data) => { +export const generateComponentType = (data: any) => { return `{${parseType('', data, '')}}` } diff --git a/packages/ui-scripts/lib/build/buildThemes/generatePrimitives.js b/packages/ui-scripts/lib/build/buildThemes/generatePrimitives.ts similarity index 91% rename from packages/ui-scripts/lib/build/buildThemes/generatePrimitives.js rename to packages/ui-scripts/lib/build/buildThemes/generatePrimitives.ts index 9772ae8bb3..208fe30c95 100644 --- a/packages/ui-scripts/lib/build/buildThemes/generatePrimitives.js +++ b/packages/ui-scripts/lib/build/buildThemes/generatePrimitives.ts @@ -22,7 +22,7 @@ * SOFTWARE. */ -export const generatePrimitives = (collection, key) => { +export const generatePrimitives = (collection: any, key?: string): any => { const value = key ? collection[key] : collection if (typeof value === 'object' && !value.value && !value.type) { return Object.keys(value).reduce((acc, key) => { @@ -37,7 +37,7 @@ export const generatePrimitives = (collection, key) => { return value.value } -const generateTypeData = (primitives, key) => { +const generateTypeData = (primitives: any, key?: string): string => { const value = key ? primitives[key] : primitives if (typeof value === 'object' && !value.value && !value.type) { return Object.keys(value).reduce((acc, key, index) => { @@ -59,7 +59,7 @@ const generateTypeData = (primitives, key) => { return `${typeof value}, ` } -export const generateType = (primitives, key) => { +export const generateType = (primitives: any, key?: string) => { const typeData = generateTypeData(primitives, key) return `{${typeData}}` diff --git a/packages/ui-scripts/lib/build/buildThemes/setupThemes.ts b/packages/ui-scripts/lib/build/buildThemes/setupThemes.ts index 5ee01c6df0..58bb7f4dd7 100644 --- a/packages/ui-scripts/lib/build/buildThemes/setupThemes.ts +++ b/packages/ui-scripts/lib/build/buildThemes/setupThemes.ts @@ -23,15 +23,15 @@ */ import { promises } from 'fs' -import createFile from './createFile.js' -import { generatePrimitives, generateType } from './generatePrimitives.js' +import createFile from './createFile.ts' +import { generatePrimitives, generateType } from './generatePrimitives.ts' import generateSemantics, { generateSemanticsType, mergeSemanticSets } from './generateSemantics.ts' import generateComponent, { generateComponentType -} from './generateComponents.js' +} from './generateComponents.ts' import { exec } from 'child_process' import { promisify } from 'node:util' diff --git a/packages/ui-scripts/lib/build/clean.js b/packages/ui-scripts/lib/build/clean.ts similarity index 100% rename from packages/ui-scripts/lib/build/clean.js rename to packages/ui-scripts/lib/build/clean.ts diff --git a/packages/ui-scripts/lib/build/generate-all-tokens.js b/packages/ui-scripts/lib/build/generate-all-tokens.ts similarity index 92% rename from packages/ui-scripts/lib/build/generate-all-tokens.js rename to packages/ui-scripts/lib/build/generate-all-tokens.ts index 592af75cec..295f23914e 100644 --- a/packages/ui-scripts/lib/build/generate-all-tokens.js +++ b/packages/ui-scripts/lib/build/generate-all-tokens.ts @@ -24,8 +24,8 @@ import path from 'path' import { error } from '@instructure/command-utils' -import { handleMapJSTokensToSource } from '../utils/handle-map-js-tokens-to-source.js' -import { handleGenerateTokens } from '../utils/handle-generate-tokens.js' +import { handleMapJSTokensToSource } from '../utils/handle-map-js-tokens-to-source.ts' +import { handleGenerateTokens } from '../utils/handle-generate-tokens.ts' import { createRequire } from 'node:module' const require = createRequire(import.meta.url) @@ -72,7 +72,7 @@ export default { desc: 'Generate cross-platform design tokens for all themes', handler: async () => { const outputDir = 'tokens' - const generators = [] + const generators: Promise[] = [] for (const conf of tokenScriptsConfig) { const { sourceTokens, themeKey, outputPackage, groupOutput } = conf // For workspace packages in pnpm, construct path from project root @@ -97,7 +97,11 @@ export default { subpath ) } catch (err) { - error(`Failed to resolve ${sourceTokens}: ${err.message}`) + error( + `Failed to resolve ${sourceTokens}: ${ + err instanceof Error ? err.message : String(err) + }` + ) process.exit(1) } const tokens = require(resolvedSource).default @@ -120,7 +124,11 @@ export default { const outputPackageName = outputPackageMatch[2] themePath = path.join(process.cwd(), 'packages', outputPackageName) } catch (err) { - error(`Failed to resolve ${outputPackage}: ${err.message}`) + error( + `Failed to resolve ${outputPackage}: ${ + err instanceof Error ? err.message : String(err) + }` + ) process.exit(1) } const outputPath = groupOutput diff --git a/packages/ui-scripts/lib/build/specify-commonjs-format.js b/packages/ui-scripts/lib/build/specify-commonjs-format.ts similarity index 100% rename from packages/ui-scripts/lib/build/specify-commonjs-format.js rename to packages/ui-scripts/lib/build/specify-commonjs-format.ts diff --git a/packages/ui-scripts/lib/build/webpack.js b/packages/ui-scripts/lib/build/webpack.ts similarity index 95% rename from packages/ui-scripts/lib/build/webpack.js rename to packages/ui-scripts/lib/build/webpack.ts index 13512d8909..08d5e2636d 100644 --- a/packages/ui-scripts/lib/build/webpack.js +++ b/packages/ui-scripts/lib/build/webpack.ts @@ -23,16 +23,17 @@ */ import { runCommandSync, resolveBin } from '@instructure/command-utils' +import type { Argv } from 'yargs' export default { command: 'bundle', desc: 'Build and optionally start an app with Webpack', - builder: (yargs) => { + builder: (yargs: Argv) => { yargs.option('port', { alias: 'p', desc: '', default: '9090' }) yargs.option('watch', { boolean: true, desc: '' }) yargs.strictOptions(true) }, - handler: async (argv) => { + handler: async (argv: any) => { const { NODE_ENV, DEBUG, OMIT_INSTUI_DEPRECATION_WARNINGS } = process.env let command, webpackArgs diff --git a/packages/ui-scripts/lib/commands/bump.js b/packages/ui-scripts/lib/commands/bump.ts similarity index 88% rename from packages/ui-scripts/lib/commands/bump.js rename to packages/ui-scripts/lib/commands/bump.ts index a68655e30f..8699998d20 100644 --- a/packages/ui-scripts/lib/commands/bump.js +++ b/packages/ui-scripts/lib/commands/bump.ts @@ -23,17 +23,18 @@ */ import { execSync } from 'node:child_process' -import pkgUtils from '@instructure/pkg-utils' +import * as pkgUtils from '@instructure/pkg-utils' import { error, info } from '@instructure/command-utils' -import { addNewExportsEntiresToPackageJSONs } from '../utils/addNewExportsEntiresToPackageJSONs.js' -import { addNewExportFileToMetaPackage } from '../utils/addNewExportFileToMetaPackage.js' +import type { Argv } from 'yargs' +import { addNewExportsEntiresToPackageJSONs } from '../utils/addNewExportsEntiresToPackageJSONs.ts' +import { addNewExportFileToMetaPackage } from '../utils/addNewExportFileToMetaPackage.ts' import { commitVersionBump, checkWorkingDirectory, getCommitsSinceLastRelease -} from '../utils/git.js' -import { bumpPackages } from '../utils/npm.js' +} from '../utils/git.ts' +import { bumpPackages } from '../utils/npm.ts' const calcNextVersionType = () => { if (getCommitsSinceLastRelease().includes('BREAKING CHANGE')) { @@ -45,22 +46,22 @@ const calcNextVersionType = () => { export default { command: 'bump', desc: "bump version in all package.json-s, generate changelogs and commit this change. Use the releaseType param to explicitely tell lerna what version to bump to. (patch, minor, major, prerelease. Prerelease means that it'll publish a security postfixed version)", - builder: (yargs) => { + builder: (yargs: Argv) => { yargs.option('releaseType', { type: 'string', describe: 'optional release type/version argument: major, minor, patch, prerelease, [version]' }) }, - handler: async (argv) => { - const pkgJSON = pkgUtils.getPackageJSON(undefined) + handler: async (argv: any) => { + const pkgJSON = pkgUtils.getPackageJSON() // optional release type/version argument: major, minor, patch, prerelease [version] // e.g. ui-scripts bump --releaseType=major await bump(pkgJSON.name, argv.releaseType) } } -async function bump(packageName, requestedVersion) { +async function bump(packageName: string, requestedVersion: string) { const newVersionType = requestedVersion ?? calcNextVersionType() checkWorkingDirectory() diff --git a/packages/ui-scripts/lib/commands/create-component-version.ts b/packages/ui-scripts/lib/commands/create-component-version.ts index dd5e36c3f6..cec4d24dec 100644 --- a/packages/ui-scripts/lib/commands/create-component-version.ts +++ b/packages/ui-scripts/lib/commands/create-component-version.ts @@ -143,10 +143,7 @@ async function detectOrPromptComponent( /** * Detect if cwd is inside a component directory (has v1/v2 subdirs). */ -function detectComponentFromCwd( - repoRoot: string, - cwd: string -): string | null { +function detectComponentFromCwd(repoRoot: string, cwd: string): string | null { let dir = cwd while (dir.startsWith(repoRoot) && dir !== repoRoot) { // Check if this directory has version subdirectories @@ -198,8 +195,12 @@ function discoverComponents(repoRoot: string): ComponentInfo[] { /** * Simple fuzzy match: all query characters must appear in order. + * @internal — exported only for tests; not part of the package's public API. */ -function fuzzyMatch(components: ComponentInfo[], query: string): ComponentInfo[] { +export function fuzzyMatch( + components: ComponentInfo[], + query: string +): ComponentInfo[] { const q = query.toLowerCase() return components.filter((c) => { const target = `${c.pkg} ${c.name}`.toLowerCase() diff --git a/packages/ui-scripts/lib/commands/deprecate.js b/packages/ui-scripts/lib/commands/deprecate.ts similarity index 88% rename from packages/ui-scripts/lib/commands/deprecate.js rename to packages/ui-scripts/lib/commands/deprecate.ts index 57aaaa8ca0..ad1c7a33fa 100644 --- a/packages/ui-scripts/lib/commands/deprecate.js +++ b/packages/ui-scripts/lib/commands/deprecate.ts @@ -22,7 +22,7 @@ * SOFTWARE. */ -import pkgUtils from '@instructure/pkg-utils' +import * as pkgUtils from '@instructure/pkg-utils' import { runCommandAsync, error, @@ -30,20 +30,21 @@ import { confirm } from '@instructure/command-utils' -import { checkNpmAuth, cleanupNPMRCFile } from '../utils/npm.js' +import { checkNpmAuth, cleanupNPMRCFile } from '../utils/npm.ts' +import type { Argv } from 'yargs' export default { command: 'deprecate', desc: 'deprecate ALL of a certain version of instUI pnpm packages by ' + 'running "pnpm deprecate".', - builder: (yargs) => { + builder: (yargs: Argv) => { yargs.option('versionToDeprecate', { type: 'string', describe: 'The version number to deprecate, e.g. "8.11.0". Defaults to the ' + 'current version.', - default: pkgUtils.getPackageJSON(undefined).version + default: pkgUtils.getPackageJSON().version }) yargs.option('fixVersion', { type: 'string', @@ -53,7 +54,7 @@ export default { demandOption: true }) }, - handler: async (argv) => { + handler: async (argv: any) => { try { await doDeprecate(argv.versionToDeprecate, argv.fixVersion) } catch (err) { @@ -63,7 +64,7 @@ export default { } } -async function doDeprecate(versionToDeprecate, fixVersion) { +async function doDeprecate(versionToDeprecate: string, fixVersion: string) { const message = `A critical bug was fixed in ${fixVersion}` checkNpmAuth() @@ -75,7 +76,7 @@ async function doDeprecate(versionToDeprecate, fixVersion) { if (!['Y', 'y'].includes(reply.trim())) { process.exit(0) } - const wait = (delay) => + const wait = (delay: number) => new Promise((resolve) => { setTimeout(resolve, delay) }) diff --git a/packages/ui-scripts/lib/commands/index.js b/packages/ui-scripts/lib/commands/index.ts similarity index 76% rename from packages/ui-scripts/lib/commands/index.js rename to packages/ui-scripts/lib/commands/index.ts index 03793cfb69..4d27e2d94e 100644 --- a/packages/ui-scripts/lib/commands/index.js +++ b/packages/ui-scripts/lib/commands/index.ts @@ -22,19 +22,19 @@ * SOFTWARE. */ -import bump from './bump.js' -import server from './server.js' -import tag from './tag.js' -import deprecate from './deprecate.js' -import publish from './publish.js' -import publishPrivate from './publish-private.js' +import bump from './bump.ts' +import server from './server.ts' +import tag from './tag.ts' +import deprecate from './deprecate.ts' +import publish from './publish.ts' +import publishPrivate from './publish-private.ts' import visualDiff from './visual-diff.ts' -import lint from '../test/lint.js' -import bundle from '../build/webpack.js' -import clean from '../build/clean.js' -import build from '../build/babel.js' -import generateAllTokens from '../build/generate-all-tokens.js' -import buildIcons from '../icons/build-icons.js' +import lint from '../test/lint.ts' +import bundle from '../build/webpack.ts' +import clean from '../build/clean.ts' +import build from '../build/babel.ts' +import generateAllTokens from '../build/generate-all-tokens.ts' +import buildIcons from '../icons/build-icons.ts' import buildThemes from '../build/build-themes.ts' import createComponentVersion from './create-component-version.ts' diff --git a/packages/ui-scripts/lib/commands/publish-private.js b/packages/ui-scripts/lib/commands/publish-private.ts similarity index 91% rename from packages/ui-scripts/lib/commands/publish-private.js rename to packages/ui-scripts/lib/commands/publish-private.ts index 2210820b24..cff5d24018 100644 --- a/packages/ui-scripts/lib/commands/publish-private.js +++ b/packages/ui-scripts/lib/commands/publish-private.ts @@ -27,22 +27,23 @@ import path from 'node:path' import fs from 'node:fs' import readline from 'node:readline/promises' -import pkgUtils from '@instructure/pkg-utils' +import * as pkgUtils from '@instructure/pkg-utils' import { error, info, runCommandAsync } from '@instructure/command-utils' +import type { Argv } from 'yargs' const PRIVATE_TAG = 'security' export default { command: 'publish-private', desc: 'publishes ALL non-private packages to a private npm-compatible registry. Reads INSTUI_PRIVATE_REGISTRY and INSTUI_PRIVATE_REGISTRY_TOKEN from the environment.', - builder: (yargs) => { + builder: (yargs: Argv) => { yargs.option('yes', { type: 'boolean', describe: 'Skip the interactive registry-hostname confirmation prompt', default: false }) }, - handler: async (argv) => { + handler: async (argv: any) => { try { await publishPrivate({ skipConfirm: argv.yes }) } catch (err) { @@ -52,7 +53,7 @@ export default { } } -async function publishPrivate({ skipConfirm }) { +async function publishPrivate({ skipConfirm }: { skipConfirm: boolean }) { const registry = process.env.INSTUI_PRIVATE_REGISTRY const token = process.env.INSTUI_PRIVATE_REGISTRY_TOKEN @@ -79,7 +80,7 @@ async function publishPrivate({ skipConfirm }) { process.exit(1) } - const packages = pkgUtils.getPackages().filter((pkg) => !pkg.private) + const packages = pkgUtils.getPackages().filter((pkg: any) => !pkg.private) if (packages.length === 0) { error('No publishable (non-private) packages found.') process.exit(1) @@ -122,7 +123,7 @@ async function publishPrivate({ skipConfirm }) { ) } -async function confirmHostname(hostname) { +async function confirmHostname(hostname: string) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout @@ -140,7 +141,7 @@ async function confirmHostname(hostname) { } } -function writeTempNpmrc(registryUrl, token) { +function writeTempNpmrc(registryUrl: URL, token: string) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'instui-publish-private-')) const file = path.join(dir, '.npmrc') const authPath = registryUrl.pathname.endsWith('/') @@ -152,7 +153,7 @@ function writeTempNpmrc(registryUrl, token) { return file } -function cleanupTempNpmrc(npmrcPath) { +function cleanupTempNpmrc(npmrcPath: string) { try { fs.rmSync(path.dirname(npmrcPath), { recursive: true, force: true }) } catch { @@ -160,11 +161,11 @@ function cleanupTempNpmrc(npmrcPath) { } } -async function publishPackage(pkg, npmrcPath) { +async function publishPackage(pkg: any, npmrcPath: string) { const childEnv = { NPM_CONFIG_USERCONFIG: npmrcPath } // Skip if this version is already on the private registry. - let versions = [] + let versions: string[] = [] try { const { stdout } = await runCommandAsync( 'pnpm', diff --git a/packages/ui-scripts/lib/commands/publish.js b/packages/ui-scripts/lib/commands/publish.ts similarity index 88% rename from packages/ui-scripts/lib/commands/publish.js rename to packages/ui-scripts/lib/commands/publish.ts index 23a9052615..70f0484a0d 100644 --- a/packages/ui-scripts/lib/commands/publish.js +++ b/packages/ui-scripts/lib/commands/publish.ts @@ -22,21 +22,22 @@ * SOFTWARE. */ -import pkgUtils from '@instructure/pkg-utils' +import * as pkgUtils from '@instructure/pkg-utils' import { error, info, runCommandAsync } from '@instructure/command-utils' import { checkWorkingDirectory, isReleaseCommit, runGitCommand -} from '../utils/git.js' -import { bumpPackages, checkNpmAuth, cleanupNPMRCFile } from '../utils/npm.js' +} from '../utils/git.ts' +import { bumpPackages, checkNpmAuth, cleanupNPMRCFile } from '../utils/npm.ts' import semver from 'semver' +import type { Argv } from 'yargs' export default { command: 'publish', desc: 'publishes ALL packages to pnpm with the "pnpm publish" command', - builder: (yargs) => { + builder: (yargs: Argv) => { yargs.option('isMaintenance', { type: 'boolean', describe: 'If true pnpm publish will use vXYZ_maintenance as tag', @@ -49,7 +50,7 @@ export default { default: false }) }, - handler: async (argv) => { + handler: async (argv: any) => { const { isMaintenance, prRelease } = argv try { const pkgJSON = pkgUtils.getPackageJSON() @@ -66,14 +67,24 @@ export default { } } -async function publish({ packageName, version, isMaintenance, prRelease }) { +async function publish({ + packageName, + version, + isMaintenance, + prRelease +}: { + packageName: string + version: string + isMaintenance: boolean + prRelease: boolean +}) { const isRegularRelease = isReleaseCommit(version) checkNpmAuth() try { checkWorkingDirectory() - const packages = pkgUtils.getPackages().filter((pkg) => !pkg.private) + const packages = pkgUtils.getPackages().filter((pkg: any) => !pkg.private) if (isRegularRelease) { // If on legacy branch, and it is a release, its tag should say vx_maintenance @@ -105,7 +116,7 @@ async function publish({ packageName, version, isMaintenance, prRelease }) { /** * Publishes each package to pnpm. */ -async function publishRegularVersion(arg) { +async function publishRegularVersion(arg: any) { const { version, packages, tag } = arg for await (const pkg of publishPackages(packages, version, tag)) { info(`📦 Version ${version} of ${pkg.name} was successfully published!`) @@ -117,7 +128,7 @@ async function publishRegularVersion(arg) { * and the current commit, then publishes each package to npm * with the new snapshot version. */ -async function publishSnapshotVersion(arg) { +async function publishSnapshotVersion(arg: any) { const { version, packageName, packages, tag, prRelease } = arg const snapshotVersion = calculateNextSnapshotVersion(version, prRelease) @@ -136,7 +147,7 @@ async function publishSnapshotVersion(arg) { * Calculates the new snapshot version. * @returns the new snapshot version */ -function calculateNextSnapshotVersion(version, prRelease) { +function calculateNextSnapshotVersion(version: string, prRelease: boolean) { const ver = `v${version}` // get the commit count between the current released version // and the commit that we are on currently @@ -170,7 +181,7 @@ function calculateNextSnapshotVersion(version, prRelease) { * An async generator function which will do the publishing * for each package. */ -async function* publishPackages(packages, version, tag) { +async function* publishPackages(packages: any[], version: string, tag: string) { for (const pkg of packages) { let packageVersions = [] try { @@ -205,8 +216,8 @@ async function* publishPackages(packages, version, tag) { } } -async function publishPackage(pkg, tag) { - const wait = (delay) => +async function publishPackage(pkg: any, tag: string) { + const wait = (delay: number) => new Promise((resolve) => { setTimeout(resolve, delay) }) diff --git a/packages/ui-scripts/lib/commands/server.js b/packages/ui-scripts/lib/commands/server.ts similarity index 98% rename from packages/ui-scripts/lib/commands/server.js rename to packages/ui-scripts/lib/commands/server.ts index 1393071923..69a95b98ac 100644 --- a/packages/ui-scripts/lib/commands/server.js +++ b/packages/ui-scripts/lib/commands/server.ts @@ -34,7 +34,7 @@ export default { default: 9090 } }, - handler: (argv) => { + handler: (argv: any) => { process.exit( runCommandSync(resolveBin('http-server'), ['__build__', '-p', argv.port]) .status diff --git a/packages/ui-scripts/lib/commands/tag.js b/packages/ui-scripts/lib/commands/tag.ts similarity index 91% rename from packages/ui-scripts/lib/commands/tag.js rename to packages/ui-scripts/lib/commands/tag.ts index 8e1d2f8e58..117c79a2cf 100644 --- a/packages/ui-scripts/lib/commands/tag.js +++ b/packages/ui-scripts/lib/commands/tag.ts @@ -22,19 +22,20 @@ * SOFTWARE. */ -import pkgUtils from '@instructure/pkg-utils' +import * as pkgUtils from '@instructure/pkg-utils' import { runCommandAsync, error, info, confirm } from '@instructure/command-utils' -import { checkNpmAuth, cleanupNPMRCFile } from '../utils/npm.js' +import { checkNpmAuth, cleanupNPMRCFile } from '../utils/npm.ts' +import type { Argv } from 'yargs' export default { command: 'tag', desc: 'Add/remove/list pnpm distribution tag', - builder: (yargs) => { + builder: (yargs: Argv) => { yargs.option('command', { type: 'string', describe: 'dist-tag command to run. "add" or "rm" or "ls"', @@ -51,12 +52,12 @@ export default { default: 'latest' }) }, - handler: async (argv) => { + handler: async (argv: any) => { await distTag(argv.command, argv.versionToTag, argv.tag) } } -async function distTag(command, versionToTag, tag) { +async function distTag(command: string, versionToTag: string, tag: string) { checkNpmAuth() try { @@ -73,7 +74,7 @@ async function distTag(command, versionToTag, tag) { if (!['Y', 'y'].includes(reply.trim())) { process.exit(0) } - const wait = (delay) => + const wait = (delay: number) => new Promise((resolve) => { setTimeout(resolve, delay) }) diff --git a/packages/ui-scripts/lib/commands/visual-diff.ts b/packages/ui-scripts/lib/commands/visual-diff.ts index 934afee085..d19c45f8c7 100644 --- a/packages/ui-scripts/lib/commands/visual-diff.ts +++ b/packages/ui-scripts/lib/commands/visual-diff.ts @@ -57,7 +57,8 @@ type Args = { type Meta = Record -function sourceLinkFor( +/** @internal — exported only for tests; not part of the package's public API. */ +export function sourceLinkFor( name: string, meta: Meta | null, sourceBaseUrl?: string @@ -83,7 +84,8 @@ function walk(dir: string): string[] { return out } -function indexByName(files: string[]): Map { +/** @internal — exported only for tests; not part of the package's public API. */ +export function indexByName(files: string[]): Map { const map = new Map() for (const f of files) map.set(basename(f), { path: f }) return map @@ -121,7 +123,8 @@ function copyInto(src: string, destDir: string, name: string) { copyFileSync(src, join(destDir, name)) } -function badgeFor(s: Status): string { +/** @internal — exported only for tests; not part of the package's public API. */ +export function badgeFor(s: Status): string { return { unchanged: 'ok', changed: 'changed', @@ -130,7 +133,8 @@ function badgeFor(s: Status): string { }[s] } -function thumb(mode: string, name: string): string { +/** @internal — exported only for tests; not part of the package's public API. */ +export function thumb(mode: string, name: string): string { return `` } @@ -140,13 +144,19 @@ function row(r: Result, meta: Meta | null, sourceBaseUrl?: string): string { const d = r.status === 'changed' ? thumb('diff', r.name) : '' const pixelMeta = r.status === 'changed' - ? `
${r.numDiff} pixels differ${r.sizeMismatch ? ' · size mismatch' : ''}
` + ? `
${r.numDiff} pixels differ${ + r.sizeMismatch ? ' · size mismatch' : '' + }
` : '' const source = sourceLinkFor(r.name, meta, sourceBaseUrl) const hasBoth = r.status === 'changed' || r.status === 'unchanged' return ` -
-

${r.name}

${badgeFor(r.status)}${pixelMeta}${source}
+
+

${r.name}

${badgeFor( + r.status + )}${pixelMeta}${source}
Baseline
${b}
Actual
${a}
Diff
${d}
` } @@ -163,8 +173,8 @@ function renderHtml( prNumber && prUrl ? `PR #${prNumber}` : prNumber - ? `PR #${prNumber}` - : '' + ? `PR #${prNumber}` + : '' return ` Visual regression report