mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da4e7d4fbf |
No files matched your search
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Tests for globals.css CSS variable definitions.
|
||||
*
|
||||
* The PR introduced a two-ramp CSS-variable system for nb-gray:
|
||||
* - :root defines the INVERTED ramp (light mode — high numbers = light,
|
||||
* low numbers = dark text)
|
||||
* - .dark defines the ORIGINAL ramp (dark mode — original values)
|
||||
*
|
||||
* These tests parse the raw CSS file as a string and verify:
|
||||
* 1. :root defines every nb-gray custom property.
|
||||
* 2. .dark defines every nb-gray custom property.
|
||||
* 3. The light and dark ramps are genuinely different (inverted) for key stops.
|
||||
* 4. All values are space-separated RGB triples (not hex, not rgb()).
|
||||
* 5. h1, h2, p now use theme-agnostic text classes (not dark:-prefixed).
|
||||
*
|
||||
* Run with: npx tsx src/app/globals-css.test.ts
|
||||
*/
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dir = dirname(__filename);
|
||||
const css = readFileSync(join(__dir, "globals.css"), "utf-8");
|
||||
|
||||
// ─── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the value of a CSS custom property from a given block of CSS text.
|
||||
* Returns the trimmed raw value string, or null if not found.
|
||||
*/
|
||||
function extractVar(cssText: string, varName: string): string | null {
|
||||
// matches "--varName: VALUE;"
|
||||
const regex = new RegExp(`${varName}\\s*:\\s*([^;]+);`);
|
||||
const match = cssText.match(regex);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the text content between the first occurrence of `selector {` and
|
||||
* its matching closing `}`. Naïve but sufficient for the flat blocks in
|
||||
* globals.css.
|
||||
*/
|
||||
function extractBlock(cssText: string, selector: string): string {
|
||||
const start = cssText.indexOf(selector);
|
||||
if (start === -1) return "";
|
||||
const brace = cssText.indexOf("{", start);
|
||||
if (brace === -1) return "";
|
||||
let depth = 1;
|
||||
let i = brace + 1;
|
||||
while (i < cssText.length && depth > 0) {
|
||||
if (cssText[i] === "{") depth++;
|
||||
else if (cssText[i] === "}") depth--;
|
||||
i++;
|
||||
}
|
||||
return cssText.slice(brace + 1, i - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that a string looks like a space-separated RGB triple, e.g. "255 255 255".
|
||||
*/
|
||||
function isRgbTriple(value: string): boolean {
|
||||
return /^\d{1,3}\s+\d{1,3}\s+\d{1,3}$/.test(value.trim());
|
||||
}
|
||||
|
||||
// ─── setup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const rootBlock = extractBlock(css, ":root");
|
||||
const darkBlock = extractBlock(css, ".dark");
|
||||
|
||||
const STOPS = [
|
||||
"DEFAULT",
|
||||
"50",
|
||||
"100",
|
||||
"200",
|
||||
"250",
|
||||
"300",
|
||||
"350",
|
||||
"400",
|
||||
"500",
|
||||
"600",
|
||||
"700",
|
||||
"800",
|
||||
"850",
|
||||
"900",
|
||||
"910",
|
||||
"920",
|
||||
"925",
|
||||
"930",
|
||||
"935",
|
||||
"940",
|
||||
"950",
|
||||
"960",
|
||||
] as const;
|
||||
|
||||
type Check = { desc: string; ok: boolean };
|
||||
const checks: Check[] = [];
|
||||
|
||||
// ─── 1. :root block exists ─────────────────────────────────────────────────
|
||||
checks.push({
|
||||
desc: ":root block is present in globals.css",
|
||||
ok: rootBlock.length > 0,
|
||||
});
|
||||
|
||||
// ─── 2. .dark block exists ─────────────────────────────────────────────────
|
||||
checks.push({
|
||||
desc: ".dark block is present in globals.css",
|
||||
ok: darkBlock.length > 0,
|
||||
});
|
||||
|
||||
// ─── 3. :root defines all nb-gray stops as RGB triples ─────────────────────
|
||||
for (const stop of STOPS) {
|
||||
const varName =
|
||||
stop === "DEFAULT" ? "--nb-gray-DEFAULT" : `--nb-gray-${stop}`;
|
||||
const value = extractVar(rootBlock, varName);
|
||||
checks.push({
|
||||
desc: `[light/:root] ${varName} is defined as an RGB triple`,
|
||||
ok: value !== null && isRgbTriple(value),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 4. .dark defines all nb-gray stops as RGB triples ─────────────────────
|
||||
for (const stop of STOPS) {
|
||||
const varName =
|
||||
stop === "DEFAULT" ? "--nb-gray-DEFAULT" : `--nb-gray-${stop}`;
|
||||
const value = extractVar(darkBlock, varName);
|
||||
checks.push({
|
||||
desc: `[dark/.dark] ${varName} is defined as an RGB triple`,
|
||||
ok: value !== null && isRgbTriple(value),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 5. Ramps are inverted: light-mode high stops ≠ dark-mode high stops ───
|
||||
// e.g. in light mode --nb-gray-900 should be near-white (high channels),
|
||||
// in dark mode --nb-gray-900 should be near-dark (low channels).
|
||||
{
|
||||
const lightDefault = extractVar(rootBlock, "--nb-gray-DEFAULT");
|
||||
const darkDefault = extractVar(darkBlock, "--nb-gray-DEFAULT");
|
||||
checks.push({
|
||||
desc: "DEFAULT stop differs between light and dark mode",
|
||||
ok: lightDefault !== null && darkDefault !== null && lightDefault !== darkDefault,
|
||||
});
|
||||
|
||||
// Light mode DEFAULT should be near-white (all channels high)
|
||||
if (lightDefault) {
|
||||
const channels = lightDefault.split(/\s+/).map(Number);
|
||||
checks.push({
|
||||
desc: "light mode DEFAULT is near-white (all channels >= 240)",
|
||||
ok: channels.every((c) => c >= 240),
|
||||
});
|
||||
}
|
||||
|
||||
// Dark mode DEFAULT should be near-black (all channels low)
|
||||
if (darkDefault) {
|
||||
const channels = darkDefault.split(/\s+/).map(Number);
|
||||
checks.push({
|
||||
desc: "dark mode DEFAULT is near-black (all channels <= 50)",
|
||||
ok: channels.every((c) => c <= 50),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 6. Light mode 50-stop is dark text (low channels) ─────────────────────
|
||||
{
|
||||
const light50 = extractVar(rootBlock, "--nb-gray-50");
|
||||
if (light50) {
|
||||
const channels = light50.split(/\s+/).map(Number);
|
||||
checks.push({
|
||||
desc: "light mode nb-gray-50 is near-black text (all channels <= 50)",
|
||||
ok: channels.every((c) => c <= 50),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 7. Dark mode 50-stop is bright text (high channels) ───────────────────
|
||||
{
|
||||
const dark50 = extractVar(darkBlock, "--nb-gray-50");
|
||||
if (dark50) {
|
||||
const channels = dark50.split(/\s+/).map(Number);
|
||||
checks.push({
|
||||
desc: "dark mode nb-gray-50 is near-white text (all channels >= 220)",
|
||||
ok: channels.every((c) => c >= 220),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 8. No hex values in the CSS variable declarations ─────────────────────
|
||||
{
|
||||
const hexInRoot = /#[0-9a-fA-F]{3,8}/.test(rootBlock);
|
||||
const hexInDark = /#[0-9a-fA-F]{3,8}/.test(darkBlock);
|
||||
checks.push({
|
||||
desc: ":root block contains no hard-coded hex colour values",
|
||||
ok: !hexInRoot,
|
||||
});
|
||||
checks.push({
|
||||
desc: ".dark block contains no hard-coded hex colour values",
|
||||
ok: !hexInDark,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 9. h1, h2, p use theme-agnostic nb-gray classes ─────────────────────
|
||||
{
|
||||
checks.push({
|
||||
desc: "h1 uses text-nb-gray-100 (not dark:text-nb-gray-100 or text-gray-700)",
|
||||
ok:
|
||||
css.includes("text-nb-gray-100") &&
|
||||
!css.includes("dark:text-nb-gray-100") &&
|
||||
!css.match(/h1[^}]*text-gray-700/),
|
||||
});
|
||||
checks.push({
|
||||
desc: "p uses text-nb-gray-200 (not dark:text-zinc-50 or dark:text-gray-700)",
|
||||
ok:
|
||||
css.includes("text-nb-gray-200") &&
|
||||
!css.includes("dark:text-zinc-50") &&
|
||||
!css.includes("text-gray-700"),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 10. timescape input focus uses text-nb-gray-50 ────────────────────────
|
||||
{
|
||||
checks.push({
|
||||
desc: ".timescape input:focus uses text-nb-gray-50 (not text-white)",
|
||||
ok: css.includes("text-nb-gray-50") && !css.match(/timescape[^}]*text-white/),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── run ───────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log("=== globals.css CSS-variable definitions ===");
|
||||
let failures = 0;
|
||||
for (const { desc, ok } of checks) {
|
||||
if (!ok) failures++;
|
||||
console.log(`${ok ? "✓" : "✗"} ${desc}`);
|
||||
}
|
||||
|
||||
console.log(`\n${failures} test(s) failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Tests for buttonVariants — the cva variant helper exported from Button.tsx.
|
||||
*
|
||||
* These tests cover the light-mode additions introduced in the light-mode draft
|
||||
* PR. The key changes were:
|
||||
* - danger-outline: added explicit light-mode styles (bg-transparent, text-red-600, etc.)
|
||||
* - danger-text: made fully theme-agnostic (dropped the dark:-prefixed classes)
|
||||
* - default-outline: made fully theme-agnostic (dropped the dark:-prefixed classes)
|
||||
* - danger: added explicit light-mode enabled/disabled styles
|
||||
*
|
||||
* Run with: npx tsx src/components/Button.test.ts
|
||||
*/
|
||||
|
||||
import { buttonVariants } from "./Button.js";
|
||||
|
||||
type Case = { desc: string; classes: string[]; notClasses?: string[] };
|
||||
|
||||
function run(name: string, cases: Case[], fn: () => string): number {
|
||||
console.log(`\n=== ${name} ===`);
|
||||
let failures = 0;
|
||||
const result = fn();
|
||||
for (const { desc, classes, notClasses = [] } of cases) {
|
||||
for (const cls of classes) {
|
||||
const ok = result.includes(cls);
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} [${desc}] contains "${cls}"` +
|
||||
(ok ? "" : ` (actual: "${result}")`),
|
||||
);
|
||||
}
|
||||
for (const cls of notClasses) {
|
||||
const ok = !result.includes(cls);
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} [${desc}] does NOT contain "${cls}"` +
|
||||
(ok ? "" : ` (actual: "${result}")`),
|
||||
);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// danger-outline variant
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — danger-outline",
|
||||
[
|
||||
{
|
||||
desc: "light-mode base",
|
||||
classes: [
|
||||
"bg-transparent",
|
||||
"text-red-600",
|
||||
"border-red-300",
|
||||
"hover:bg-red-50",
|
||||
"hover:border-red-400",
|
||||
"focus:ring-red-500/30",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "dark-mode overrides preserved",
|
||||
classes: [
|
||||
"dark:bg-transparent",
|
||||
"dark:text-red-500",
|
||||
"dark:border-transparent",
|
||||
],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "danger-outline" }),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// danger-text variant
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — danger-text",
|
||||
[
|
||||
{
|
||||
desc: "theme-agnostic text",
|
||||
classes: ["text-red-500", "hover:text-red-600"],
|
||||
},
|
||||
{
|
||||
desc: "no padding / shadow",
|
||||
classes: ["border-transparent", "!px-0", "!shadow-none", "!py-0"],
|
||||
},
|
||||
{
|
||||
desc: "focus ring",
|
||||
classes: ["focus:ring-red-500/30"],
|
||||
},
|
||||
{
|
||||
desc: "ring offset is theme-agnostic (no dark: prefix)",
|
||||
classes: ["ring-offset-nb-gray-950/50"],
|
||||
notClasses: ["dark:ring-offset-neutral-950/50"],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "danger-text" }),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// default-outline variant
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — default-outline",
|
||||
[
|
||||
{
|
||||
desc: "ring offset and focus ring are theme-agnostic",
|
||||
classes: ["ring-offset-nb-gray-950/50", "focus:ring-nb-gray-500/20"],
|
||||
},
|
||||
{
|
||||
desc: "light/dark-agnostic base styles",
|
||||
classes: [
|
||||
"bg-transparent",
|
||||
"text-nb-gray-400",
|
||||
"border-transparent",
|
||||
"hover:text-nb-gray-50",
|
||||
"hover:bg-nb-gray-900/30",
|
||||
"hover:border-nb-gray-800/50",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "data-state=open styles are theme-agnostic",
|
||||
classes: [
|
||||
"data-[state=open]:text-nb-gray-50",
|
||||
"data-[state=open]:bg-nb-gray-900/30",
|
||||
"data-[state=open]:border-nb-gray-800/50",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "no leftover dark:-only prefix variants for core styles",
|
||||
notClasses: [
|
||||
"dark:ring-offset-nb-gray-950/50",
|
||||
"dark:focus:ring-nb-gray-500/20",
|
||||
"dark:bg-transparent",
|
||||
"dark:text-nb-gray-400",
|
||||
"data-[state=open]:dark:text-nb-gray-50",
|
||||
],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "default-outline" }),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// danger variant
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — danger",
|
||||
[
|
||||
{
|
||||
desc: "light-mode enabled state",
|
||||
classes: [
|
||||
"enabled:bg-red-600",
|
||||
"enabled:text-white",
|
||||
"enabled:hover:bg-red-700",
|
||||
"enabled:focus:bg-red-700",
|
||||
"enabled:focus:ring-red-400/50",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "light-mode disabled state",
|
||||
classes: [
|
||||
"disabled:bg-red-300",
|
||||
"disabled:text-white",
|
||||
"border-red-600",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "dark-mode overrides preserved",
|
||||
classes: [
|
||||
"dark:bg-red-600",
|
||||
"dark:text-red-100",
|
||||
"hover:dark:bg-red-700",
|
||||
],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "danger" }),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Unchanged variants — regression check: ensure they still work
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — primary (regression)",
|
||||
[
|
||||
{
|
||||
desc: "primary still has netbird colour",
|
||||
classes: ["enabled:dark:bg-netbird", "enabled:bg-netbird"],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "primary" }),
|
||||
);
|
||||
|
||||
failures += run(
|
||||
"buttonVariants — default (regression)",
|
||||
[
|
||||
{
|
||||
desc: "default variant still renders",
|
||||
classes: ["bg-white", "text-gray-900"],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({ variant: "default" }),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Size and rounding defaults
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
failures += run(
|
||||
"buttonVariants — default size and rounding",
|
||||
[
|
||||
{
|
||||
desc: "default size is md",
|
||||
classes: ["py-2.5", "px-4"],
|
||||
},
|
||||
{
|
||||
desc: "default rounded is true",
|
||||
classes: ["rounded-md"],
|
||||
},
|
||||
],
|
||||
() => buttonVariants({}),
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Edge: danger-outline with extra className override doesn't lose base classes
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const cls = buttonVariants({ variant: "danger-outline" });
|
||||
const ok =
|
||||
cls.includes("text-red-600") && cls.includes("border-red-300");
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`\n=== danger-outline boundary ===\n${ok ? "✓" : "✗"} light-mode base classes present when called without extra options`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\n${failures} test(s) failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Tests for NetBirdLogo component source structure.
|
||||
*
|
||||
* The PR introduced dual-image theme switching so the logo automatically
|
||||
* shows the light-text SVG in light mode and the dark-text SVG in dark mode:
|
||||
*
|
||||
* - A <span> wrapping both <Image>s (one per theme) replaces the single
|
||||
* previous <Image>.
|
||||
* - The light-mode image has className="block dark:hidden".
|
||||
* - The dark-mode image has className="hidden dark:block".
|
||||
* - The mobile mark (logomark only) now always has className="md:hidden ml-4"
|
||||
* rather than a conditional cn() call.
|
||||
* - The netbird-full-light.svg asset is imported.
|
||||
*
|
||||
* Because this is a Next.js component that relies on next/image and SVG
|
||||
* asset imports, we verify the source text directly rather than rendering it
|
||||
* in Node.
|
||||
*
|
||||
* Run with: npx tsx src/components/NetBirdLogo.test.ts
|
||||
*/
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dir = dirname(__filename);
|
||||
const source = readFileSync(join(__dir, "NetBirdLogo.tsx"), "utf-8");
|
||||
|
||||
type Check = { desc: string; ok: boolean };
|
||||
const checks: Check[] = [];
|
||||
|
||||
// 1. Light-mode SVG asset is imported
|
||||
checks.push({
|
||||
desc: "imports netbird-full-light.svg",
|
||||
ok: source.includes("netbird-full-light.svg"),
|
||||
});
|
||||
|
||||
// 2. The import alias is used in the JSX
|
||||
checks.push({
|
||||
desc: "NetBirdLogoFullLight is used as an Image src",
|
||||
ok: source.includes("NetBirdLogoFullLight") && source.includes("src={NetBirdLogoFullLight}"),
|
||||
});
|
||||
|
||||
// 3. Dark-mode logo is still imported and used
|
||||
checks.push({
|
||||
desc: "imports netbird-full.svg (dark logo)",
|
||||
ok: source.includes("netbird-full.svg"),
|
||||
});
|
||||
checks.push({
|
||||
desc: "NetBirdLogoFull is used as an Image src",
|
||||
ok: source.includes("src={NetBirdLogoFull}"),
|
||||
});
|
||||
|
||||
// 4. Light-mode image has the correct visibility toggle class
|
||||
checks.push({
|
||||
desc: "light-logo image has className=\"block dark:hidden\"",
|
||||
ok: source.includes("block dark:hidden"),
|
||||
});
|
||||
|
||||
// 5. Dark-mode image has the correct visibility toggle class
|
||||
checks.push({
|
||||
desc: "dark-logo image has className=\"hidden dark:block\"",
|
||||
ok: source.includes("hidden dark:block"),
|
||||
});
|
||||
|
||||
// 6. Both images are wrapped in a <span> (the new structure)
|
||||
checks.push({
|
||||
desc: "a wrapping <span> element is present",
|
||||
ok: source.includes("<span"),
|
||||
});
|
||||
|
||||
// 7. The wrapping span carries the conditional hidden class for mobile
|
||||
checks.push({
|
||||
desc: "wrapping span uses cn() for responsive hiding",
|
||||
ok: source.includes('cn("relative"') || source.includes("cn(\"relative\""),
|
||||
});
|
||||
|
||||
// 8. Mobile logomark uses simple className (no unnecessary conditional)
|
||||
checks.push({
|
||||
desc: "mobile mark className does not use unnecessary mobile-conditional cn",
|
||||
ok:
|
||||
source.includes('"md:hidden ml-4"') ||
|
||||
source.includes("'md:hidden ml-4'"),
|
||||
});
|
||||
|
||||
// 9. Mobile mark still imports NetBirdLogoMark
|
||||
checks.push({
|
||||
desc: "imports netbird.svg (logomark)",
|
||||
ok: source.includes("netbird.svg"),
|
||||
});
|
||||
|
||||
// 10. sizes object is still present for both size values
|
||||
checks.push({
|
||||
desc: "sizes.default is defined",
|
||||
ok: source.includes("default:"),
|
||||
});
|
||||
checks.push({
|
||||
desc: "sizes.large is defined",
|
||||
ok: source.includes("large:"),
|
||||
});
|
||||
|
||||
// 11. Props still have defaults (size and mobile)
|
||||
checks.push({
|
||||
desc: "size prop defaults to 'default'",
|
||||
ok: source.includes("size = \"default\""),
|
||||
});
|
||||
checks.push({
|
||||
desc: "mobile prop defaults to true",
|
||||
ok: source.includes("mobile = true"),
|
||||
});
|
||||
|
||||
// 12. Alt text is preserved on both images
|
||||
const altMatches = source.match(/alt=\{"NetBird Logo"\}/g);
|
||||
checks.push({
|
||||
desc: "at least two images have alt='NetBird Logo'",
|
||||
ok: altMatches !== null && altMatches.length >= 2,
|
||||
});
|
||||
|
||||
// ─── run ───────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log("=== NetBirdLogo source checks ===");
|
||||
let failures = 0;
|
||||
for (const { desc, ok } of checks) {
|
||||
if (!ok) failures++;
|
||||
console.log(`${ok ? "✓" : "✗"} ${desc}`);
|
||||
}
|
||||
|
||||
console.log(`\n${failures} test(s) failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Tests for Separator component class string.
|
||||
*
|
||||
* The PR changed the background class from "bg-zinc-700/40" to
|
||||
* "bg-nb-gray-800/40" so the separator follows the new theme-aware
|
||||
* nb-gray colour system instead of the hard-coded zinc colour.
|
||||
*
|
||||
* Because Separator is a trivial presentational component (no logic), we
|
||||
* verify the component source text directly rather than rendering it.
|
||||
*
|
||||
* Run with: npx tsx src/components/Separator.test.ts
|
||||
*/
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dir = dirname(__filename);
|
||||
const source = readFileSync(join(__dir, "Separator.tsx"), "utf-8");
|
||||
|
||||
type Check = { desc: string; pattern: RegExp | string; shouldMatch: boolean };
|
||||
|
||||
const checks: Check[] = [
|
||||
{
|
||||
desc: "uses nb-gray-800/40 background",
|
||||
pattern: "bg-nb-gray-800/40",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use the old zinc-700/40 background",
|
||||
pattern: "bg-zinc-700/40",
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
desc: "still renders a span element",
|
||||
pattern: "<span",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
desc: "still includes height and width classes",
|
||||
pattern: "h-[1px]",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
desc: "w-full present",
|
||||
pattern: "w-full",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
desc: "block display class present",
|
||||
pattern: /\bblock\b/,
|
||||
shouldMatch: true,
|
||||
},
|
||||
];
|
||||
|
||||
let failures = 0;
|
||||
console.log("=== Separator source checks ===");
|
||||
|
||||
for (const { desc, pattern, shouldMatch } of checks) {
|
||||
const matched =
|
||||
pattern instanceof RegExp ? pattern.test(source) : source.includes(pattern as string);
|
||||
const ok = matched === shouldMatch;
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} ${desc}` +
|
||||
(ok ? "" : ` [expected shouldMatch=${shouldMatch}]`),
|
||||
);
|
||||
}
|
||||
|
||||
// Boundary: the class attribute should contain exactly the expected string
|
||||
const classMatch = source.match(/className=\{["']([^"']+)["']\}/);
|
||||
if (classMatch) {
|
||||
const classValue = classMatch[1];
|
||||
const ok = classValue.includes("bg-nb-gray-800/40") && !classValue.includes("bg-zinc-700/40");
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} className attribute has bg-nb-gray-800/40 and not bg-zinc-700/40` +
|
||||
(ok ? "" : ` (className: "${classValue}")`),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\n${failures} test(s) failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Source-level regression tests for the light-mode class-name changes
|
||||
* introduced in this PR.
|
||||
*
|
||||
* These components had "dark:" prefixes removed, "text-white" replaced with
|
||||
* "text-nb-gray-100", or similar theme-agnostic rewrites. Because the
|
||||
* components import React and Next.js modules (preventing plain-Node
|
||||
* execution), we verify the source files directly to ensure the correct
|
||||
* class strings are present.
|
||||
*
|
||||
* Files covered:
|
||||
* - HelpText.tsx (dark: prefix removed from text colour)
|
||||
* - Input.tsx (hover:text-white → hover:text-nb-gray-100, dark: removed from prefix/suffix)
|
||||
* - PinCodeInput.tsx (dark: prefix removed from bg and border)
|
||||
* - Code.tsx (dark: prefix removed from text colour)
|
||||
* - FullTooltip.tsx (hover:text-neutral-100 → hover:text-nb-gray-100)
|
||||
* - Notification.tsx (text-white → text-nb-gray-100)
|
||||
* - PeerSelector.tsx (text-white → text-nb-gray-100)
|
||||
* - NetworkRouteSelector.tsx (text-white → text-nb-gray-100)
|
||||
* - PortSelector.tsx (text-white → text-nb-gray-100)
|
||||
* - DatePickerWithRange.tsx (text-white → text-nb-gray-100 in CalendarButton)
|
||||
* - DataTableFilter.tsx (text-white → text-nb-gray-100)
|
||||
* - Modal.tsx (border-zinc-700/40 → border-neutral-200)
|
||||
*
|
||||
* Run with: npx tsx src/components/light-mode-class-changes.test.ts
|
||||
*/
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dir = dirname(__filename);
|
||||
|
||||
function readSource(file: string): string {
|
||||
return readFileSync(join(__dir, file), "utf-8");
|
||||
}
|
||||
|
||||
type FileCheck = {
|
||||
file: string;
|
||||
checks: Array<{ desc: string; pattern: string | RegExp; shouldContain: boolean }>;
|
||||
};
|
||||
|
||||
const fileChecks: FileCheck[] = [
|
||||
// ── HelpText.tsx ───────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "HelpText.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "uses theme-agnostic text-nb-gray-300",
|
||||
pattern: "text-nb-gray-300",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT have dark:text-nb-gray-300",
|
||||
pattern: "dark:text-nb-gray-300",
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Input.tsx ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "Input.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "password toggle uses hover:text-nb-gray-100",
|
||||
pattern: "hover:text-nb-gray-100",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "password toggle does NOT use hover:text-white",
|
||||
pattern: "hover:text-white",
|
||||
shouldContain: false,
|
||||
},
|
||||
{
|
||||
desc: "prefix div uses theme-agnostic text-nb-gray-300",
|
||||
pattern: /absolute left-0[^"]*text-xs text-nb-gray-300/,
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "prefix div does NOT use dark:text-nb-gray-300",
|
||||
pattern: "dark:text-nb-gray-300",
|
||||
shouldContain: false,
|
||||
},
|
||||
{
|
||||
desc: "suffix div uses theme-agnostic text-nb-gray-300",
|
||||
pattern: /absolute right-0[^"]*text-xs text-nb-gray-300/,
|
||||
shouldContain: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── PinCodeInput.tsx ───────────────────────────────────────────────────────
|
||||
{
|
||||
file: "PinCodeInput.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "input cell uses bg-nb-gray-900 without dark: prefix",
|
||||
pattern: /"bg-nb-gray-900 border border-nb-gray-700"/,
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use dark:bg-nb-gray-900 (old format)",
|
||||
pattern: "dark:bg-nb-gray-900 border dark:border-nb-gray-700",
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Code.tsx ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "Code.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "light (non-dark prop) text uses theme-agnostic text-nb-gray-200",
|
||||
pattern: '"text-nb-gray-200 hover:text-nb-gray-200"',
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use dark:text-nb-gray-200 for the non-dark prop path",
|
||||
pattern: "dark:text-nb-gray-200 hover:dark:text-nb-gray-200",
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── FullTooltip.tsx ────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "FullTooltip.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "action wrapper uses hover:text-nb-gray-100",
|
||||
pattern: "hover:text-nb-gray-100",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use hover:text-neutral-100",
|
||||
pattern: "hover:text-neutral-100",
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Notification.tsx ───────────────────────────────────────────────────────
|
||||
{
|
||||
file: "Notification.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "icon wrapper uses text-nb-gray-100",
|
||||
pattern: "text-nb-gray-100",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use bare text-white for icon",
|
||||
pattern: /shadow-sm text-white flex/,
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── PeerSelector.tsx ───────────────────────────────────────────────────────
|
||||
{
|
||||
file: "PeerSelector.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "selected-value div uses text-nb-gray-100",
|
||||
pattern: "text-nb-gray-100",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "selected-value wrapper string uses text-nb-gray-100",
|
||||
pattern: '"flex items-center justify-between text-sm text-nb-gray-100',
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use bare text-white in selected value wrapper",
|
||||
pattern: '"flex items-center justify-between text-sm text-white',
|
||||
shouldContain: false,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use text-white anywhere for option/value selection",
|
||||
pattern: '"text-white"',
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── NetworkRouteSelector.tsx ───────────────────────────────────────────────
|
||||
{
|
||||
file: "NetworkRouteSelector.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "selected value text uses text-nb-gray-100",
|
||||
pattern: '"flex items-center justify-between text-sm text-nb-gray-100',
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use text-white in selected value wrapper",
|
||||
pattern: '"flex items-center justify-between text-sm text-white',
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── PortSelector.tsx ───────────────────────────────────────────────────────
|
||||
{
|
||||
file: "PortSelector.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "version callout uses text-nb-gray-100",
|
||||
pattern: '"text-nb-gray-100 font-normal"',
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use text-white in version callout",
|
||||
pattern: '"text-white font-normal"',
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Tooltip.tsx ────────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "Tooltip.tsx",
|
||||
checks: [
|
||||
{
|
||||
desc: "uses text-nb-gray-50 (not text-neutral-50)",
|
||||
pattern: "text-nb-gray-50",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use text-neutral-50",
|
||||
pattern: "text-neutral-50",
|
||||
shouldContain: false,
|
||||
},
|
||||
{
|
||||
desc: "default variant does not include border-neutral-200",
|
||||
pattern: "border-neutral-200",
|
||||
shouldContain: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── run ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let failures = 0;
|
||||
for (const { file, checks } of fileChecks) {
|
||||
console.log(`\n=== ${file} ===`);
|
||||
let source: string;
|
||||
try {
|
||||
source = readSource(file);
|
||||
} catch {
|
||||
console.log(`✗ COULD NOT READ ${file}`);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const { desc, pattern, shouldContain } of checks) {
|
||||
const matches =
|
||||
pattern instanceof RegExp ? pattern.test(source) : source.includes(pattern as string);
|
||||
const ok = matches === shouldContain;
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} ${desc}` +
|
||||
(ok ? "" : ` [expected shouldContain=${shouldContain}]`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Modal.tsx is in a subdirectory ────────────────────────────────────────
|
||||
{
|
||||
console.log("\n=== modal/Modal.tsx ===");
|
||||
const modalSource = readFileSync(join(__dir, "modal", "Modal.tsx"), "utf-8");
|
||||
const modalChecks = [
|
||||
{
|
||||
desc: "SidebarModalContent uses border-neutral-200 in light mode",
|
||||
pattern: "border-neutral-200",
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use border-zinc-700/40 as the primary border",
|
||||
// The old value was "border border-zinc-700/40"; it's now "border border-neutral-200"
|
||||
pattern: "border border-zinc-700/40",
|
||||
shouldContain: false,
|
||||
},
|
||||
];
|
||||
for (const { desc, pattern, shouldContain } of modalChecks) {
|
||||
const matches = modalSource.includes(pattern);
|
||||
const ok = matches === shouldContain;
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} ${desc}` +
|
||||
(ok ? "" : ` [expected shouldContain=${shouldContain}]`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── table/DataTableFilter.tsx ─────────────────────────────────────────────
|
||||
{
|
||||
console.log("\n=== table/DataTableFilter.tsx ===");
|
||||
const dtfSource = readFileSync(
|
||||
join(__dir, "table", "DataTableFilter.tsx"),
|
||||
"utf-8",
|
||||
);
|
||||
const dtfChecks = [
|
||||
{
|
||||
desc: "filter count span uses text-nb-gray-100",
|
||||
pattern: '"text-nb-gray-100"',
|
||||
shouldContain: true,
|
||||
},
|
||||
{
|
||||
desc: "does NOT use text-white for filter count span",
|
||||
pattern: '"text-white"',
|
||||
shouldContain: false,
|
||||
},
|
||||
];
|
||||
for (const { desc, pattern, shouldContain } of dtfChecks) {
|
||||
const matches = dtfSource.includes(pattern);
|
||||
const ok = matches === shouldContain;
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} ${desc}` +
|
||||
(ok ? "" : ` [expected shouldContain=${shouldContain}]`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${failures} test(s) failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Tests for cva variant helpers in Tooltip, InlineLink, and Label components.
|
||||
*
|
||||
* These tests replicate each component's cva call so they can run in plain
|
||||
* Node/tsx without needing the full React/Next.js environment (and without
|
||||
* the path-alias shims for @utils/helpers, etc.).
|
||||
*
|
||||
* The PR switched several components from "dark:text-neutral-50" /
|
||||
* "border-neutral-200" to theme-agnostic "text-nb-gray-50" / "border-nb-gray-*"
|
||||
* classes. The tests here verify those exact class strings are present (or
|
||||
* absent) in the generated output.
|
||||
*
|
||||
* Run with: npx tsx src/components/light-mode-variants.test.ts
|
||||
*/
|
||||
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
// ─── replicated variant helpers ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mirrors tooltipVariants from src/components/Tooltip.tsx (post-PR).
|
||||
* Key change: "text-neutral-50" → "text-nb-gray-50",
|
||||
* removed "border-neutral-200" from both variants.
|
||||
*/
|
||||
const tooltipVariants = cva(
|
||||
[
|
||||
"z-[9999] overflow-hidden rounded-md border text-sm shadow-md",
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: ["bg-nb-gray-940", "text-nb-gray-50", "border-nb-gray-930"],
|
||||
lighter: ["bg-nb-gray-920", "text-nb-gray-50", "border-nb-gray-900"],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Mirrors linkVariants from src/components/InlineLink.tsx (post-PR).
|
||||
* Key change: "hover:text-white" → "hover:text-nb-gray-100" for both
|
||||
* "white" and "dashed" variants.
|
||||
*/
|
||||
const linkVariants = cva(
|
||||
"underline-offset-4 items-center transition-all duration-200 inline-flex texts-inherit gap-1",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-netbird hover:underline font-normal",
|
||||
faded: "text-nb-gray-400 hover:text-nb-gray-300 hover:underline",
|
||||
white: "text-nb-gray-100 hover:text-nb-gray-100 hover:underline",
|
||||
dashed:
|
||||
"text-nb-gray-100/90 underline font-normal decoration-dashed hover:text-nb-gray-100",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Mirrors labelVariants from src/components/Label.tsx (post-PR).
|
||||
* Key change: "dark:text-nb-gray-200" → "text-nb-gray-200".
|
||||
*/
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1.5 inline-block text-nb-gray-200 flex items-center gap-2",
|
||||
);
|
||||
|
||||
// ─── runner ────────────────────────────────────────────────────────────────
|
||||
|
||||
type Check = { desc: string; classes: string[]; notClasses?: string[] };
|
||||
|
||||
function checkVariant(name: string, result: string, checks: Check[]): number {
|
||||
console.log(`\n=== ${name} ===`);
|
||||
let failures = 0;
|
||||
for (const { desc, classes, notClasses = [] } of checks) {
|
||||
for (const cls of classes) {
|
||||
const ok = result.includes(cls);
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} [${desc}] contains "${cls}"` +
|
||||
(ok ? "" : ` (actual: "${result}")`),
|
||||
);
|
||||
}
|
||||
for (const cls of notClasses) {
|
||||
const ok = !result.includes(cls);
|
||||
if (!ok) failures++;
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} [${desc}] does NOT contain "${cls}"` +
|
||||
(ok ? "" : ` (actual: "${result}")`),
|
||||
);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Tooltip variants
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
failures += checkVariant(
|
||||
"tooltipVariants — default",
|
||||
tooltipVariants({ variant: "default" }),
|
||||
[
|
||||
{
|
||||
desc: "uses nb-gray text",
|
||||
classes: ["text-nb-gray-50"],
|
||||
notClasses: ["text-neutral-50"],
|
||||
},
|
||||
{
|
||||
desc: "uses nb-gray border (not neutral-200)",
|
||||
classes: ["border-nb-gray-930"],
|
||||
notClasses: ["border-neutral-200"],
|
||||
},
|
||||
{
|
||||
desc: "background unchanged",
|
||||
classes: ["bg-nb-gray-940"],
|
||||
},
|
||||
{
|
||||
desc: "base animation classes present",
|
||||
classes: ["animate-in", "fade-in-0"],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
failures += checkVariant(
|
||||
"tooltipVariants — lighter",
|
||||
tooltipVariants({ variant: "lighter" }),
|
||||
[
|
||||
{
|
||||
desc: "uses nb-gray text",
|
||||
classes: ["text-nb-gray-50"],
|
||||
notClasses: ["text-neutral-50"],
|
||||
},
|
||||
{
|
||||
desc: "uses nb-gray border (not neutral-200)",
|
||||
classes: ["border-nb-gray-900"],
|
||||
notClasses: ["border-neutral-200"],
|
||||
},
|
||||
{
|
||||
desc: "lighter background",
|
||||
classes: ["bg-nb-gray-920"],
|
||||
},
|
||||
{
|
||||
desc: "darker background not mixed in",
|
||||
notClasses: ["bg-nb-gray-940"],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// InlineLink variants
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
failures += checkVariant(
|
||||
"linkVariants — white",
|
||||
linkVariants({ variant: "white" }),
|
||||
[
|
||||
{
|
||||
desc: "text colour uses nb-gray-100",
|
||||
classes: ["text-nb-gray-100"],
|
||||
notClasses: ["text-white"],
|
||||
},
|
||||
{
|
||||
desc: "hover stays on nb-gray-100 (not white)",
|
||||
classes: ["hover:text-nb-gray-100"],
|
||||
notClasses: ["hover:text-white"],
|
||||
},
|
||||
{
|
||||
desc: "underline on hover",
|
||||
classes: ["hover:underline"],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
failures += checkVariant(
|
||||
"linkVariants — dashed",
|
||||
linkVariants({ variant: "dashed" }),
|
||||
[
|
||||
{
|
||||
desc: "text colour uses nb-gray-100/90",
|
||||
classes: ["text-nb-gray-100/90"],
|
||||
},
|
||||
{
|
||||
desc: "hover uses nb-gray-100 (not white)",
|
||||
classes: ["hover:text-nb-gray-100"],
|
||||
notClasses: ["hover:text-white"],
|
||||
},
|
||||
{
|
||||
desc: "decoration style",
|
||||
classes: ["decoration-dashed", "underline"],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
failures += checkVariant(
|
||||
"linkVariants — default (regression)",
|
||||
linkVariants({ variant: "default" }),
|
||||
[
|
||||
{
|
||||
desc: "default variant unchanged",
|
||||
classes: ["text-netbird", "hover:underline"],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
failures += checkVariant(
|
||||
"linkVariants — faded (regression)",
|
||||
linkVariants({ variant: "faded" }),
|
||||
[
|
||||
{
|
||||
desc: "faded variant unchanged",
|
||||
classes: ["text-nb-gray-400", "hover:text-nb-gray-300"],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Label variants
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
failures += checkVariant(
|
||||
"labelVariants — base",
|
||||
labelVariants(),
|
||||
[
|
||||
{
|
||||
desc: "text colour is theme-agnostic nb-gray-200",
|
||||
classes: ["text-nb-gray-200"],
|
||||
notClasses: ["dark:text-nb-gray-200"],
|
||||
},
|
||||
{
|
||||
desc: "typography classes present",
|
||||
classes: [
|
||||
"text-sm",
|
||||
"font-medium",
|
||||
"tracking-wider",
|
||||
"leading-none",
|
||||
"mb-1.5",
|
||||
"inline-block",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "disabled peer styles present",
|
||||
classes: [
|
||||
"peer-disabled:cursor-not-allowed",
|
||||
"peer-disabled:opacity-70",
|
||||
],
|
||||
},
|
||||
{
|
||||
desc: "flex layout classes present",
|
||||
classes: ["flex", "items-center", "gap-2"],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Boundary / negative cases
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// tooltip: ensure neither variant leaks the other's background
|
||||
{
|
||||
const def = tooltipVariants({ variant: "default" });
|
||||
const lighter = tooltipVariants({ variant: "lighter" });
|
||||
const ok1 = !def.includes("bg-nb-gray-920");
|
||||
const ok2 = !lighter.includes("bg-nb-gray-940");
|
||||
if (!ok1) failures++;
|
||||
if (!ok2) failures++;
|
||||
console.log(
|
||||
`\n=== tooltipVariants — no cross-contamination ===\n` +
|
||||
`${ok1 ? "✓" : "✗"} default does not contain bg-nb-gray-920\n` +
|
||||
`${ok2 ? "✓" : "✗"} lighter does not contain bg-nb-gray-940`,
|
||||
);
|
||||
}
|
||||
|
||||
// linkVariants: calling with no variant falls back to base classes only
|
||||
{
|
||||
const base = linkVariants({});
|
||||
const hasBase = base.includes("underline-offset-4");
|
||||
if (!hasBase) failures++;
|
||||
console.log(
|
||||
`\n=== linkVariants — no variant arg ===\n` +
|
||||
`${hasBase ? "✓" : "✗"} base classes present when no variant is specified`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\n${failures} test(s) failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Tests for the nb-gray colour entries in tailwind.config.ts.
|
||||
*
|
||||
* The PR migrated nb-gray from hard-coded hex values to CSS-variable-backed
|
||||
* `rgb(var(--nb-gray-*) / <alpha-value>)` references so the colour ramp can
|
||||
* be redefined per theme in globals.css. These tests verify:
|
||||
* 1. Every expected stop uses the CSS-variable form.
|
||||
* 2. No stop still contains a hard-coded hex value.
|
||||
* 3. The alpha-value placeholder is present (required for /opacity modifiers).
|
||||
* 4. The DEFAULT stop follows the same pattern.
|
||||
*
|
||||
* Run with: npx tsx tailwind.config.test.ts
|
||||
*/
|
||||
|
||||
import config from "./tailwind.config.js";
|
||||
|
||||
type ColorMap = Record<string, string>;
|
||||
|
||||
// Extract the nb-gray sub-map from the Tailwind theme config.
|
||||
// The config uses theme.extend.colors, so we need to unwrap carefully.
|
||||
const extendedColors = (config.theme?.extend?.colors ?? {}) as Record<
|
||||
string,
|
||||
ColorMap | string
|
||||
>;
|
||||
const nbGray = extendedColors["nb-gray"] as ColorMap | undefined;
|
||||
|
||||
type Check = { desc: string; ok: boolean };
|
||||
|
||||
const checks: Check[] = [];
|
||||
|
||||
// 1. nb-gray entry must exist
|
||||
checks.push({
|
||||
desc: "nb-gray color entry exists in tailwind config",
|
||||
ok: nbGray !== undefined,
|
||||
});
|
||||
|
||||
// Only continue the rest if nbGray is actually defined
|
||||
if (nbGray) {
|
||||
const expectedStops = [
|
||||
"DEFAULT",
|
||||
"50",
|
||||
"100",
|
||||
"200",
|
||||
"250",
|
||||
"300",
|
||||
"350",
|
||||
"400",
|
||||
"500",
|
||||
"600",
|
||||
"700",
|
||||
"800",
|
||||
"850",
|
||||
"900",
|
||||
"910",
|
||||
"920",
|
||||
"925",
|
||||
"930",
|
||||
"935",
|
||||
"940",
|
||||
"950",
|
||||
"960",
|
||||
] as const;
|
||||
|
||||
// 2. All expected stops are present
|
||||
for (const stop of expectedStops) {
|
||||
checks.push({
|
||||
desc: `nb-gray-${stop} stop is defined`,
|
||||
ok: stop in nbGray,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Each stop uses the CSS-variable rgb() form
|
||||
for (const stop of expectedStops) {
|
||||
const value = nbGray[stop] ?? "";
|
||||
const cssVarName =
|
||||
stop === "DEFAULT" ? "--nb-gray-DEFAULT" : `--nb-gray-${stop}`;
|
||||
const usesCssVar = value.includes(`var(${cssVarName})`);
|
||||
checks.push({
|
||||
desc: `nb-gray-${stop} references CSS variable ${cssVarName}`,
|
||||
ok: usesCssVar,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Each stop uses the rgb() wrapper (required for Tailwind opacity modifiers)
|
||||
for (const stop of expectedStops) {
|
||||
const value = nbGray[stop] ?? "";
|
||||
const usesRgb = value.startsWith("rgb(");
|
||||
checks.push({
|
||||
desc: `nb-gray-${stop} uses rgb() wrapper`,
|
||||
ok: usesRgb,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Alpha-value placeholder is present in every stop
|
||||
for (const stop of expectedStops) {
|
||||
const value = nbGray[stop] ?? "";
|
||||
const hasAlpha = value.includes("<alpha-value>");
|
||||
checks.push({
|
||||
desc: `nb-gray-${stop} contains <alpha-value> placeholder`,
|
||||
ok: hasAlpha,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. No stop contains a hard-coded hex value (regression: old format was "#181A1D" etc.)
|
||||
for (const stop of expectedStops) {
|
||||
const value = nbGray[stop] ?? "";
|
||||
const hasHex = /#[0-9a-fA-F]{3,6}/.test(value);
|
||||
checks.push({
|
||||
desc: `nb-gray-${stop} does NOT contain a hard-coded hex colour`,
|
||||
ok: !hasHex,
|
||||
});
|
||||
}
|
||||
|
||||
// 7. DEFAULT stop specifically
|
||||
{
|
||||
const def = nbGray["DEFAULT"] ?? "";
|
||||
checks.push({
|
||||
desc: "DEFAULT stop is rgb(var(--nb-gray-DEFAULT) / <alpha-value>)",
|
||||
ok: def === "rgb(var(--nb-gray-DEFAULT) / <alpha-value>)",
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Spot-check a few specific stop values
|
||||
const spotChecks: Array<{ stop: string; expected: string }> = [
|
||||
{
|
||||
stop: "50",
|
||||
expected: "rgb(var(--nb-gray-50) / <alpha-value>)",
|
||||
},
|
||||
{
|
||||
stop: "100",
|
||||
expected: "rgb(var(--nb-gray-100) / <alpha-value>)",
|
||||
},
|
||||
{
|
||||
stop: "940",
|
||||
expected: "rgb(var(--nb-gray-940) / <alpha-value>)",
|
||||
},
|
||||
{
|
||||
stop: "960",
|
||||
expected: "rgb(var(--nb-gray-960) / <alpha-value>)",
|
||||
},
|
||||
];
|
||||
for (const { stop, expected } of spotChecks) {
|
||||
const actual = nbGray[stop] ?? "";
|
||||
checks.push({
|
||||
desc: `nb-gray-${stop} exact value check`,
|
||||
ok: actual === expected,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── run checks ─────────────────────────────────────────────────────────────
|
||||
|
||||
console.log("=== tailwind.config — nb-gray CSS-variable migration ===");
|
||||
let failures = 0;
|
||||
for (const { desc, ok } of checks) {
|
||||
if (!ok) failures++;
|
||||
console.log(`${ok ? "✓" : "✗"} ${desc}`);
|
||||
}
|
||||
|
||||
console.log(`\n${failures} test(s) failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
Reference in new issue
Block a user