276 lines
8.5 KiB
Go
276 lines
8.5 KiB
Go
package bundler
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func twTestCompile(t *testing.T, candidates ...string) string {
|
|
t.Helper()
|
|
css, _, err := twCompile(`@import "tailwindcss";`, ".", candidates)
|
|
if err != nil {
|
|
t.Fatalf("twCompile error: %v", err)
|
|
}
|
|
return css
|
|
}
|
|
|
|
// Edge cases that the modifier/fraction rewrite fixes.
|
|
func TestEngineEdgeCases(t *testing.T) {
|
|
cases := []struct {
|
|
candidate string
|
|
contains string
|
|
}{
|
|
// Improper fraction read as a fraction (not an opacity modifier).
|
|
{"aspect-16/9", "aspect-ratio: 16/9"},
|
|
// Proper fraction.
|
|
{"w-1/2", "width: calc(1 / 2 * 100%)"},
|
|
// Arbitrary opacity modifier decoded (not "[0.5]%").
|
|
{"bg-white/[0.5]", "color-mix(in oklab, var(--color-white) 50%, transparent)"},
|
|
// Bare spacing.
|
|
{"m-4", "margin: calc(var(--spacing) * 4)"},
|
|
{"p-4", "padding: calc(var(--spacing) * 4)"},
|
|
// text-{size}/{leading}: font-size AND line-height (the dropped-modifier fix).
|
|
{"text-sm/6", "line-height: calc(var(--spacing) * 6)"},
|
|
// Theme color via @theme namespace.
|
|
{"bg-red-500", "background-color: var(--color-red-500)"},
|
|
}
|
|
for _, c := range cases {
|
|
css := twTestCompile(t, c.candidate)
|
|
if !strings.Contains(css, c.contains) {
|
|
t.Errorf("compile(%q): expected to contain %q\n---\n%s", c.candidate, c.contains, css)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSegmentTopLevel(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
sep string
|
|
want []string
|
|
}{
|
|
{"a:b:c", ":", []string{"a", "b", "c"}},
|
|
{"var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0)", ",",
|
|
[]string{"var(--a, 0 0 1px rgb(0, 0, 0))", " 0 0 1px rgb(0, 0, 0)"}},
|
|
{"display:grid", ":", []string{"display", "grid"}},
|
|
{"[display:grid]", ":", []string{"[display:grid]"}},
|
|
{"red-500/50", "/", []string{"red-500", "50"}},
|
|
{"calc(1/2)/3", "/", []string{"calc(1/2)", "3"}},
|
|
}
|
|
for _, c := range cases {
|
|
got := segment(c.in, c.sep)
|
|
if len(got) != len(c.want) {
|
|
t.Errorf("segment(%q,%q) = %v, want %v", c.in, c.sep, got, c.want)
|
|
continue
|
|
}
|
|
for i := range got {
|
|
if got[i] != c.want[i] {
|
|
t.Errorf("segment(%q,%q)[%d] = %q, want %q", c.in, c.sep, i, got[i], c.want[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEscapeIdentifier(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"flex", "flex"},
|
|
{"hover:bg-red-500", `hover\:bg-red-500`},
|
|
{"w-1/2", `w-1\/2`},
|
|
{"bg-[#fff]", `bg-\[\#fff\]`},
|
|
{"2xl", `\32 xl`},
|
|
}
|
|
for _, c := range cases {
|
|
if got := escape(c.in); got != c.want {
|
|
t.Errorf("escape(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDecodeArbitraryValue(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"100%_!important", "100% !important"},
|
|
{"calc(100dvh_-_5rem)", "calc(100dvh - 5rem)"},
|
|
{`url(/a_b.png)`, "url(/a_b.png)"},
|
|
{`var(--my_var)`, "var(--my_var)"},
|
|
{"calc(var(--spacing)*4_+_env(safe-area-inset-bottom,0px))",
|
|
"calc(var(--spacing) * 4 + env(safe-area-inset-bottom,0px))"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := decodeArbitraryValue(c.in); got != c.want {
|
|
t.Errorf("decodeArbitraryValue(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInferDataType(t *testing.T) {
|
|
cases := []struct {
|
|
value string
|
|
types []string
|
|
want string
|
|
}{
|
|
{"#ff0000", []string{dtColor, dtLength}, dtColor},
|
|
{"10rem", []string{dtColor, dtLength}, dtLength},
|
|
{"50%", []string{dtLength, dtPercentage}, dtPercentage},
|
|
{"16/9", []string{dtRatio, dtColor}, dtRatio},
|
|
{"var(--x)", []string{dtColor, dtLength}, ""},
|
|
{"calc(1px+2px)", []string{dtLength}, dtLength},
|
|
{"red", []string{dtColor}, dtColor},
|
|
}
|
|
for _, c := range cases {
|
|
if got := inferDataType(c.value, c.types); got != c.want {
|
|
t.Errorf("inferDataType(%q,%v) = %q, want %q", c.value, c.types, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNumericPredicates(t *testing.T) {
|
|
if !isValidSpacingMultiplier("0.5") {
|
|
t.Error("0.5 should be a valid spacing multiplier")
|
|
}
|
|
if isValidSpacingMultiplier("0.3") {
|
|
t.Error("0.3 should NOT be a valid spacing multiplier")
|
|
}
|
|
if !isPositiveInteger("3") || isPositiveInteger("3.0") || isPositiveInteger("03") {
|
|
t.Error("isPositiveInteger canonical-form check failed")
|
|
}
|
|
}
|
|
|
|
// Apostrophes inside JS comments (e.g. "don't", "button's") must not desync the
|
|
// quote-based class scanner. Before comments were skipped, a stray apostrophe
|
|
// flipped quote parity and swallowed every class literal until the next quote —
|
|
// silently dropping singly-used utilities like the lineup card's switch and
|
|
// drag styles.
|
|
func TestExtractCandidatesSkipsComments(t *testing.T) {
|
|
src := "// we don't need reactivity here\n" +
|
|
"const cur = locked ? \"cursor-not-allowed\" : \"cursor-move\";\n" +
|
|
"/* the button's thumb: it's offset */\n" +
|
|
"const thumb = on ? \"translate-x-3\" : \"\";\n" +
|
|
"const tpl = `<input class=\"sr-only\"/>`;\n"
|
|
|
|
got := map[string]bool{}
|
|
for _, c := range extractCandidates(src) {
|
|
got[c] = true
|
|
}
|
|
for _, want := range []string{"cursor-not-allowed", "cursor-move", "translate-x-3", "sr-only"} {
|
|
if !got[want] {
|
|
t.Errorf("candidate %q was not extracted past comment apostrophes", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// scan returns the set of candidates extracted from src.
|
|
func scan(src string) map[string]bool {
|
|
got := map[string]bool{}
|
|
for _, c := range extractCandidates(src) {
|
|
got[c] = true
|
|
}
|
|
return got
|
|
}
|
|
|
|
// Quotes inside a regex literal must not be read as a string (which would
|
|
// desync the scanner and drop following class literals), and a division `/`
|
|
// must not be mistaken for a regex (which would swallow the code after it).
|
|
func TestExtractCandidatesRegexLiterals(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
src string
|
|
want []string
|
|
}{
|
|
{
|
|
"apostrophe in regex",
|
|
`const r = /it's/; const c = "cursor-move";`,
|
|
[]string{"cursor-move"},
|
|
},
|
|
{
|
|
"regex after return keyword",
|
|
`function f(){ return /a"b/; } const c = "p-4";`,
|
|
[]string{"p-4"},
|
|
},
|
|
{
|
|
"char class containing slash and quote",
|
|
`const r = /[/']/g; const c = "block";`,
|
|
[]string{"block"},
|
|
},
|
|
{
|
|
"division is not a regex (string between divisions survives)",
|
|
`const w = a / 2; cls = "text-lg"; const h = b / 4; cls2 = "m-2";`,
|
|
[]string{"text-lg", "m-2"},
|
|
},
|
|
{
|
|
"regex inside a template interpolation, class after it",
|
|
"const t = html`<a class=${x.replace(/'/g, \"\")}>${y ? \"p-5\" : \"p-6\"}</a>`;",
|
|
[]string{"p-5", "p-6"},
|
|
},
|
|
{
|
|
"regex with braces in template interpolation keeps ${} balanced",
|
|
"const t = html`<a class=${s.match(/[{}]/) ? \"flex\" : \"hidden\"}></a>`;",
|
|
[]string{"flex", "hidden"},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
got := scan(tc.src)
|
|
for _, w := range tc.want {
|
|
if !got[w] {
|
|
t.Errorf("%s: candidate %q not extracted (src=%q)", tc.name, w, tc.src)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The preflight is written against Tailwind's compile-time CSS functions, e.g.
|
|
// `font-family: --theme(--default-font-family, …)`. If those are not resolved, they
|
|
// reach the browser verbatim; the browser cannot parse `--theme(…)` and DROPS THE
|
|
// WHOLE DECLARATION. The symptom is subtle and global: every page silently loses its
|
|
// font-family and falls back to the browser default, and no @theme override of
|
|
// --font-sans can ever take effect. That shipped for a while, so pin it.
|
|
func TestPreflightResolvesThemeFunctions(t *testing.T) {
|
|
css, _, err := twCompile(`@import "tailwindcss";`, ".", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
flat := strings.Join(strings.Fields(css), " ")
|
|
if strings.Contains(css, "--theme(") {
|
|
t.Error("the compiled CSS still contains an unresolved --theme(…); the browser will drop those declarations")
|
|
}
|
|
if !strings.Contains(flat, "font-family: var(--default-font-family)") {
|
|
t.Error("preflight did not resolve html's font-family to a var()")
|
|
}
|
|
}
|
|
|
|
// And the whole point of resolving it: an app's @theme override of --font-sans must
|
|
// actually reach the page.
|
|
func TestThemeFontOverrideReachesHTML(t *testing.T) {
|
|
css, _, err := twCompile(`
|
|
@import "tailwindcss";
|
|
|
|
@font-face {
|
|
font-family: "Lora";
|
|
src: url("/fonts/lora.woff2") format("woff2");
|
|
}
|
|
|
|
@theme {
|
|
--font-sans: "Lora", serif;
|
|
}
|
|
`, ".", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The chain the browser walks: html -> --default-font-family -> --font-sans.
|
|
flat := strings.Join(strings.Fields(css), " ")
|
|
for _, want := range []string{
|
|
"font-family: var(--default-font-family)",
|
|
"--default-font-family: var(--font-sans)",
|
|
`--font-sans: "Lora", serif`,
|
|
} {
|
|
if !strings.Contains(flat, want) {
|
|
t.Errorf("missing %q — the font override does not reach the page", want)
|
|
}
|
|
}
|
|
// A vendored face must survive the build; dropping it would leave the family
|
|
// declared but never loaded.
|
|
if !strings.Contains(css, "@font-face") || !strings.Contains(css, "lora.woff2") {
|
|
t.Error("@font-face was dropped from the output")
|
|
}
|
|
}
|