import { readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { twMerge } from './tw-merge'; import { cn } from './utils'; /** * `src/lib/tw-merge/` is a vendored copy of tailwind-merge's own source, taken * so the kit can publish zero runtime dependencies. Copied code rots quietly: * someone edits a file to fix a symptom, or bumps the devDependency without * re-copying, and `cn()` starts behaving differently from the package everyone * assumes it is. * * So the copy is checked against the package rather than trusted. The version * is pinned exactly and listed in `.ncurc.json#reject`, which makes a bump a * deliberate act; this suite is what makes that act complete. */ const VENDOR_DIR = path.resolve(__dirname, 'tw-merge'); const UPSTREAM_DIR = path.resolve(__dirname, '../../node_modules/tailwind-merge/src'); /** * The only edits the copy carries, and why they exist. * * The kit compiles with `verbatimModuleSyntax`, which upstream does not — so * every import whose bindings are all types has to say `import type`. That is * a whole-program compiler option: unlike the ESLint rules in eslint.config.js, * it cannot be switched off for one folder, so the files have to move instead. * * Each entry is a single-line replacement applied to the *upstream* file before * comparing (first occurrence only). Anything that changes outside these lines * still fails the test, which is the point — this table is the complete diff, * not a licence to edit. */ const TYPE_IMPORT_PATCH: Record> = { 'lib/class-group-utils.ts': [['import {', 'import type {']], 'lib/config-utils.ts': [ [ "import { AnyClassGroupIds, AnyConfig } from './types'", "import type { AnyClassGroupIds, AnyConfig } from './types'", ], ], 'lib/create-tailwind-merge.ts': [ [ "import { ClassNameValue, twJoin } from './tw-join'", "import type { ClassNameValue } from './tw-join';\nimport { twJoin } from './tw-join'", ], ["import { AnyConfig } from './types'", "import type { AnyConfig } from './types'"], ], 'lib/default-config.ts': [ [ "import { Config, DefaultClassGroupIds, DefaultThemeGroupIds } from './types'", "import type { Config, DefaultClassGroupIds, DefaultThemeGroupIds } from './types'", ], ], 'lib/extend-tailwind-merge.ts': [ [ "import { AnyConfig, ConfigExtension, DefaultClassGroupIds, DefaultThemeGroupIds } from './types'", "import type { AnyConfig, ConfigExtension, DefaultClassGroupIds, DefaultThemeGroupIds } from './types'", ], ], 'lib/from-theme.ts': [ [ "import { DefaultThemeGroupIds, NoInfer, ThemeGetter, ThemeObject } from './types'", "import type { DefaultThemeGroupIds, NoInfer, ThemeGetter, ThemeObject } from './types'", ], ], 'lib/merge-classlist.ts': [ [ "import { ConfigUtils } from './config-utils'", "import type { ConfigUtils } from './config-utils'", ], ], 'lib/merge-configs.ts': [ [ "import { AnyConfig, ConfigExtension, NoInfer } from './types'", "import type { AnyConfig, ConfigExtension, NoInfer } from './types'", ], ], 'lib/parse-class-name.ts': [ [ "import { AnyConfig, ParsedClassName } from './types'", "import type { AnyConfig, ParsedClassName } from './types'", ], ], 'lib/sort-modifiers.ts': [ ["import { AnyConfig } from './types'", "import type { AnyConfig } from './types'"], ], }; /** Every `.ts` under a directory, as paths relative to it, sorted. */ function sources(dir: string, prefix = ''): string[] { return readdirSync(dir, { withFileTypes: true }) .flatMap((e) => e.isDirectory() ? sources(path.join(dir, e.name), path.posix.join(prefix, e.name)) : e.name.endsWith('.ts') ? [path.posix.join(prefix, e.name)] : [], ) .sort(); } /* * Line endings are normalised on both sides before comparing. * * The vendored folder is committed, so git rewrites its endings on checkout — * on Windows that means CRLF — while `node_modules/tailwind-merge` is unpacked * from a tarball and keeps LF. Byte-comparing the two therefore failed 17 of * these assertions on any Windows clone while passing in CI, which reads as a * forked vendor copy and is nothing of the sort: the content is identical. * * This does not weaken the check. The assertion below says so itself — it is * "not a formatting check", it exists to catch an *edit* that forks the merge * logic. A line ending is imposed by git's checkout filter, not typed by * anyone, so it was never part of what this suite is asserting. */ const lf = (s: string) => s.replace(/\r\n/g, '\n'); /** Upstream file text with this kit's known edits applied. */ function expected(rel: string): string { let text = lf(readFileSync(path.join(UPSTREAM_DIR, rel), 'utf8')); for (const [from, to] of TYPE_IMPORT_PATCH[rel] ?? []) { if (!text.includes(from)) { throw new Error(`patch line no longer present upstream in ${rel}:\n ${from}`); } text = text.replace(from, to); } return text; } describe('vendored tailwind-merge', () => { const vendored = sources(VENDOR_DIR); it('carries the whole upstream source tree', () => { expect(vendored).toEqual(sources(UPSTREAM_DIR)); expect(vendored.length).toBeGreaterThan(10); }); it('patches only files that still exist upstream', () => { expect(Object.keys(TYPE_IMPORT_PATCH).sort()).toEqual( Object.keys(TYPE_IMPORT_PATCH) .filter((f) => vendored.includes(f)) .sort(), ); }); it.each(vendored)('%s matches upstream', (rel) => { /* Not a formatting check — an edit here silently forks the merge logic. If this fails after an intentional upgrade, re-copy the folder as described in src/lib/tw-merge/README.md rather than patching the assertion. */ expect(lf(readFileSync(path.join(VENDOR_DIR, rel), 'utf8'))).toBe(expected(rel)); }); }); describe('vendored copy still covers the installed Tailwind', () => { /** * `lib/default-config.ts` is 2500 lines that *are* Tailwind's class-group * table — it carries "Deprecated since Tailwind CSS v4.1.0" annotations. So * the copy is only correct for the Tailwind versions it was written against. * * That used to look after itself: `tailwind-merge` was a caret range and * drifted along with the ecosystem. It is exact-pinned now, and listed in * `.ncurc.json#reject`, so ncu will never offer it — which means a Tailwind * bump can quietly leave `cn()` behind. The failure is the bad kind: an * unknown utility simply is not recognised as conflicting, both classes * survive, source order decides, and a consumer's `className` silently loses. * No error, no other failing test. * * Upstream states the range in its README rather than in `peerDependencies`, * so that is what this reads. If the sentence ever changes shape the test * fails asking for a manual check, which is the right answer — better than * assuming the pairing is fine. */ const SUPPORT_LINE = /Supports Tailwind v(\d+)\.(\d+) up to v(\d+)\.(\d+)/; it('the installed tailwindcss is inside the range tailwind-merge claims', () => { const readme = readFileSync( path.resolve(__dirname, '../../node_modules/tailwind-merge/README.md'), 'utf8', ); const m = readme.match(SUPPORT_LINE); expect( m, "tailwind-merge's README no longer states its Tailwind range in the expected form — check the pairing by hand and update this test", ).not.toBeNull(); const [, loMajor, loMinor, hiMajor, hiMinor] = m!.map(Number); const installed = JSON.parse( readFileSync(path.resolve(__dirname, '../../node_modules/tailwindcss/package.json'), 'utf8'), ).version as string; const [major, minor] = installed.split('.').map(Number); const rank = (a: number, b: number) => a * 1000 + b; expect( rank(major, minor), `tailwindcss ${installed} is outside tailwind-merge's stated support ` + `(v${loMajor}.${loMinor}–v${hiMajor}.${hiMinor}). Bump tailwind-merge and re-copy ` + `src/lib/tw-merge/ as described in its README, or pin tailwindcss back.`, ).toBeLessThanOrEqual(rank(hiMajor, hiMinor)); expect(rank(major, minor)).toBeGreaterThanOrEqual(rank(loMajor, loMinor)); }); }); describe('cn() conflict resolution', () => { /* A behavioural floor under the file comparison: these are the merges the kit's components actually depend on, so a future upstream change that the file check would happily accept still has to keep them working. */ it.each([ ['px-2 px-4', 'px-4'], ['bg-primary bg-secondary', 'bg-secondary'], ['text-sm text-lg', 'text-lg'], ['rounded-md rounded-none', 'rounded-none'], ['p-2 px-4', 'p-2 px-4'], ['hover:bg-primary hover:bg-secondary', 'hover:bg-secondary'], ['size-4 size-6', 'size-6'], ])('twMerge(%o) → %o', (input, expectedOut) => { expect(twMerge(input)).toBe(expectedOut); }); it('lets a consumer className win through cn()', () => { expect(cn('bg-primary text-primary-foreground', 'bg-destructive')).toBe( 'text-primary-foreground bg-destructive', ); }); it('keeps the cx() conditional forms cn() is built on', () => { const off = 0 as number; expect(cn('a', off ? 'b' : null, ['c', null], { d: true, e: false })).toBe('a c d'); }); });