Compare commits

...
7 changed files with 1469 additions and 0 deletions

No files matched your search

+238
View File
@@ -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);
+237
View File
@@ -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);
+130
View File
@@ -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);
+84
View File
@@ -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);
+288
View File
@@ -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);
+161
View File
@@ -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);