diff --git a/components/layout/header/environment-badge.tsx b/components/layout/header/environment-badge.tsx new file mode 100644 index 0000000..df889bf --- /dev/null +++ b/components/layout/header/environment-badge.tsx @@ -0,0 +1,207 @@ +"use client"; + +import * as React from "react"; +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + readBuildMetadata, + type BuildMetadata, +} from "@/lib/build-metadata"; + +const HOVER_OPEN_DELAY_MS = 200; +const HOVER_CLOSE_DELAY_MS = 150; + +export function EnvironmentBadge() { + if (process.env.NODE_ENV !== "development") { + return null; + } + + return ; +} + +function DevelopmentBadge({ metadata }: { metadata: BuildMetadata }) { + const [open, setOpen] = React.useState(false); + const openSource = React.useRef<"explicit" | "hover" | null>(null); + const openTimer = React.useRef(null); + const closeTimer = React.useRef(null); + const closingFromHover = React.useRef(false); + + React.useEffect( + () => () => { + if (openTimer.current !== null) { + window.clearTimeout(openTimer.current); + } + if (closeTimer.current !== null) { + window.clearTimeout(closeTimer.current); + } + }, + [] + ); + + function clearTimers() { + if (openTimer.current !== null) { + window.clearTimeout(openTimer.current); + openTimer.current = null; + } + + if (closeTimer.current !== null) { + window.clearTimeout(closeTimer.current); + closeTimer.current = null; + } + } + + function openOnHover(event: React.PointerEvent) { + if (event.pointerType === "touch" || openSource.current === "explicit") { + return; + } + + clearTimers(); + openTimer.current = window.setTimeout(() => { + openSource.current = "hover"; + setOpen(true); + }, HOVER_OPEN_DELAY_MS); + } + + function closeAfterHover() { + clearTimers(); + + if (openSource.current !== "hover") { + return; + } + + closeTimer.current = window.setTimeout(() => { + closingFromHover.current = true; + openSource.current = null; + setOpen(false); + }, HOVER_CLOSE_DELAY_MS); + } + + function changeOpen(nextOpen: boolean) { + clearTimers(); + + if (nextOpen) { + openSource.current ??= "explicit"; + } else { + closingFromHover.current = openSource.current === "hover"; + openSource.current = null; + } + + setOpen(nextOpen); + } + + function pinHoveredPopover(event: React.MouseEvent) { + if (open && openSource.current === "hover") { + event.preventDefault(); + clearTimers(); + openSource.current = "explicit"; + } + } + + return ( + + + + DEV + + + { + if (openSource.current === "hover") { + event.preventDefault(); + } + }} + onCloseAutoFocus={(event) => { + if (closingFromHover.current) { + event.preventDefault(); + closingFromHover.current = false; + } + }} + > + + + + ); +} + +export function BuildDetails({ metadata }: { metadata: BuildMetadata }) { + const builtAt = new Date(metadata.builtAt); + const builtAtLabel = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(builtAt); + const relativeBuiltAt = formatRelativeTime(builtAt, new Date()); + + return ( +
+ + {metadata.branch} + + + {metadata.commitSha.slice(0, 8)} + {metadata.dirty ? (dirty) : null} + + + {metadata.commitSubject} + + + + +
+ ); +} + +function BuildDetail({ + children, + label, + title, +}: { + children: React.ReactNode; + label: string; + title: string; +}) { + return ( + <> +
{label}
+
+ {children} +
+ + ); +} + +function formatRelativeTime(date: Date, now: Date) { + const seconds = Math.round((date.getTime() - now.getTime()) / 1000); + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + const absoluteSeconds = Math.abs(seconds); + + if (absoluteSeconds < 60) { + return formatter.format(seconds, "second"); + } + if (absoluteSeconds < 60 * 60) { + return formatter.format(Math.round(seconds / 60), "minute"); + } + if (absoluteSeconds < 60 * 60 * 24) { + return formatter.format(Math.round(seconds / (60 * 60)), "hour"); + } + + return formatter.format(Math.round(seconds / (60 * 60 * 24)), "day"); +} diff --git a/components/layout/header/page-header.tsx b/components/layout/header/page-header.tsx index eefc7aa..2cb967f 100644 --- a/components/layout/header/page-header.tsx +++ b/components/layout/header/page-header.tsx @@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { CategoryIcon } from "@/components/common/icons/category-icon"; import { Skeleton } from "@/components/ui/skeleton"; +import { EnvironmentBadge } from "@/components/layout/header/environment-badge"; interface PageHeaderProps { title: string; @@ -78,14 +79,19 @@ export function PageHeader({ title, description, children, appLogo }: PageHeader
-

{title}

+
+

{title}

+ +
{description && (

{description}

)}
{children && ( -
{children}
+
+ {children} +
)} diff --git a/lib/build-metadata.build.ts b/lib/build-metadata.build.ts new file mode 100644 index 0000000..1096b61 --- /dev/null +++ b/lib/build-metadata.build.ts @@ -0,0 +1,30 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { parseBuildMetadata } from "./build-metadata"; + +const repositoryRoot = fileURLToPath(new URL("..", import.meta.url)); + +interface DiscoverBuildMetadataOptions { + repositoryRoot?: string; +} + +export function discoverBuildMetadata(options: DiscoverBuildMetadataOptions = {}) { + const root = options.repositoryRoot ?? repositoryRoot; + const branch = runGit(["branch", "--show-current"], root) || "detached"; + + return parseBuildMetadata({ + branch, + builtAt: new Date().toISOString(), + commitSha: runGit(["rev-parse", "HEAD"], root), + commitSubject: runGit(["log", "-1", "--format=%s", "HEAD"], root), + dirty: String(runGit(["status", "--porcelain=v1"], root).length > 0), + }); +} + +function runGit(args: string[], cwd: string) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }).trim(); +} diff --git a/lib/build-metadata.ts b/lib/build-metadata.ts new file mode 100644 index 0000000..ec5d64d --- /dev/null +++ b/lib/build-metadata.ts @@ -0,0 +1,65 @@ +export interface BuildMetadata { + branch: string; + builtAt: string; + commitSha: string; + commitSubject: string; + dirty: boolean; +} + +interface BuildMetadataInput { + branch: string | undefined; + builtAt: string | undefined; + commitSha: string | undefined; + commitSubject: string | undefined; + dirty: string | undefined; +} + +const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,64}$/i; +const ISO_8601_TIMESTAMP_PATTERN = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + +export function readBuildMetadata(): BuildMetadata { + return parseBuildMetadata({ + branch: process.env.APP_BUILD_BRANCH, + builtAt: process.env.APP_BUILD_AT, + commitSha: process.env.APP_BUILD_COMMIT_SHA, + commitSubject: process.env.APP_BUILD_COMMIT_SUBJECT, + dirty: process.env.APP_BUILD_DIRTY, + }); +} + +export function parseBuildMetadata(input: BuildMetadataInput): BuildMetadata { + const branch = requireValue("APP_BUILD_BRANCH", input.branch); + const builtAt = requireValue("APP_BUILD_AT", input.builtAt); + const commitSha = requireValue("APP_BUILD_COMMIT_SHA", input.commitSha); + const commitSubject = requireValue("APP_BUILD_COMMIT_SUBJECT", input.commitSubject); + const dirty = requireValue("APP_BUILD_DIRTY", input.dirty); + + if (!COMMIT_SHA_PATTERN.test(commitSha)) { + throw new Error("APP_BUILD_COMMIT_SHA must be a Git commit SHA"); + } + + if (!ISO_8601_TIMESTAMP_PATTERN.test(builtAt) || Number.isNaN(Date.parse(builtAt))) { + throw new Error("APP_BUILD_AT must be an ISO 8601 timestamp"); + } + + if (dirty !== "false" && dirty !== "true") { + throw new Error('APP_BUILD_DIRTY must be either "true" or "false"'); + } + + return { + branch, + builtAt, + commitSha, + commitSubject, + dirty: dirty === "true", + }; +} + +function requireValue(name: string, value: string | undefined) { + if (!value) { + throw new Error(`${name} is required`); + } + + return value; +} diff --git a/next.config.ts b/next.config.ts index ba1e7c7..71c5a19 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,12 +1,26 @@ import type { NextConfig } from "next"; +import { PHASE_DEVELOPMENT_SERVER, type PHASE_TYPE } from "next/constants"; +import { discoverBuildMetadata } from "./lib/build-metadata.build"; import packageJson from "./package.json"; -const nextConfig: NextConfig = { - // Produce standalone output for self-hosted deploys (Docker or zipped bundle) - output: "standalone", - env: { - NEXT_PUBLIC_APP_VERSION: packageJson.version, - }, -}; +export default function createNextConfig(phase: PHASE_TYPE): NextConfig { + const buildMetadata = + phase === PHASE_DEVELOPMENT_SERVER ? discoverBuildMetadata() : null; -export default nextConfig; + return { + // Produce standalone output for self-hosted deploys (Docker or zipped bundle) + output: "standalone", + env: { + NEXT_PUBLIC_APP_VERSION: packageJson.version, + ...(buildMetadata + ? { + APP_BUILD_AT: buildMetadata.builtAt, + APP_BUILD_BRANCH: buildMetadata.branch, + APP_BUILD_COMMIT_SHA: buildMetadata.commitSha, + APP_BUILD_COMMIT_SUBJECT: buildMetadata.commitSubject, + APP_BUILD_DIRTY: String(buildMetadata.dirty), + } + : {}), + }, + }; +} diff --git a/package.json b/package.json index e0b7fb4..1cc982c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crapdash", - "version": "1.4.1", + "version": "1.4.2", "private": true, "license": "MIT", "packageManager": "pnpm@11.0.3", diff --git a/tests/components/environment-badge.test.tsx b/tests/components/environment-badge.test.tsx new file mode 100644 index 0000000..3a4c368 --- /dev/null +++ b/tests/components/environment-badge.test.tsx @@ -0,0 +1,60 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + BuildDetails, + EnvironmentBadge, +} from "@/components/layout/header/environment-badge"; + +const buildMetadata = { + branch: "environment-badge", + builtAt: "2026-08-07T07:15:00.000Z", + commitSha: "98556cc1d4a18439855616c0b86e4eaa6b5d2821", + commitSubject: "Add environment badge details", + dirty: true, +}; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("EnvironmentBadge", () => { + it("renders a local badge in development", () => { + vi.stubEnv("NODE_ENV", "development"); + vi.stubEnv("APP_BUILD_BRANCH", buildMetadata.branch); + vi.stubEnv("APP_BUILD_AT", buildMetadata.builtAt); + vi.stubEnv("APP_BUILD_COMMIT_SHA", buildMetadata.commitSha); + vi.stubEnv("APP_BUILD_COMMIT_SUBJECT", buildMetadata.commitSubject); + vi.stubEnv("APP_BUILD_DIRTY", String(buildMetadata.dirty)); + + const html = renderToStaticMarkup(); + + expect(html).toContain('data-slot="badge"'); + expect(html).toContain('data-variant="outline"'); + expect(html).toContain("rounded-sm"); + expect(html).not.toContain("rounded-full"); + expect(html).toContain("DEV"); + expect(html).toContain("Development environment. Show build details"); + expect(html).toContain('aria-haspopup="dialog"'); + expect(html).toContain(" { + vi.stubEnv("NODE_ENV", environment); + + expect(renderToStaticMarkup()).toBe(""); + }); + + it("renders complete build details", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("BRANCH"); + expect(html).toContain(buildMetadata.branch); + expect(html).toContain("COMMIT"); + expect(html).toContain(buildMetadata.commitSha.slice(0, 8)); + expect(html).toContain("(dirty)"); + expect(html).toContain("MESSAGE"); + expect(html).toContain(buildMetadata.commitSubject); + expect(html).toContain("BUILT"); + expect(html).toContain(`dateTime="${buildMetadata.builtAt}"`); + }); +}); diff --git a/tests/lib/build-metadata.build.test.ts b/tests/lib/build-metadata.build.test.ts new file mode 100644 index 0000000..6431098 --- /dev/null +++ b/tests/lib/build-metadata.build.test.ts @@ -0,0 +1,56 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { discoverBuildMetadata } from "@/lib/build-metadata.build"; + +let repositoryRoot: string; + +beforeEach(async () => { + repositoryRoot = await mkdtemp(join(tmpdir(), "crapdash-build-metadata-")); + runGit(["init", "--initial-branch=main"]); + runGit(["config", "user.email", "tests@crapdash.invalid"]); + runGit(["config", "user.name", "Crapdash Tests"]); + runGit(["config", "commit.gpgsign", "false"]); + await writeFile(join(repositoryRoot, "dashboard.txt"), "initial\n"); + runGit(["add", "dashboard.txt"]); + runGit(["commit", "--message", "Add dashboard"]); +}); + +afterEach(async () => { + await rm(repositoryRoot, { recursive: true, force: true }); +}); + +describe("discoverBuildMetadata", () => { + it("discovers metadata from an attached branch", () => { + const metadata = discoverBuildMetadata({ repositoryRoot }); + + expect(metadata).toMatchObject({ + branch: "main", + commitSha: runGit(["rev-parse", "HEAD"]), + commitSubject: "Add dashboard", + dirty: false, + }); + expect(metadata.builtAt).toEqual(expect.any(String)); + }); + + it("labels a detached checkout without rejecting it", () => { + runGit(["switch", "--detach", "HEAD"]); + + expect(discoverBuildMetadata({ repositoryRoot })).toMatchObject({ + branch: "detached", + commitSha: runGit(["rev-parse", "HEAD"]), + commitSubject: "Add dashboard", + dirty: false, + }); + }); +}); + +function runGit(args: string[]) { + return execFileSync("git", args, { + cwd: repositoryRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); +} diff --git a/tests/lib/build-metadata.test.ts b/tests/lib/build-metadata.test.ts new file mode 100644 index 0000000..56b7b61 --- /dev/null +++ b/tests/lib/build-metadata.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + parseBuildMetadata, + readBuildMetadata, +} from "@/lib/build-metadata"; + +const validInput = { + branch: "environment-badge", + builtAt: "2026-08-07T07:15:00.000Z", + commitSha: "98556cc1d4a18439855616c0b86e4eaa6b5d2821", + commitSubject: "Add environment badge details", + dirty: "true", +}; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("parseBuildMetadata", () => { + it("parses valid build metadata", () => { + expect(parseBuildMetadata(validInput)).toEqual({ + branch: validInput.branch, + builtAt: validInput.builtAt, + commitSha: validInput.commitSha, + commitSubject: validInput.commitSubject, + dirty: true, + }); + }); + + it.each([ + ["branch", "APP_BUILD_BRANCH"], + ["builtAt", "APP_BUILD_AT"], + ["commitSha", "APP_BUILD_COMMIT_SHA"], + ["commitSubject", "APP_BUILD_COMMIT_SUBJECT"], + ["dirty", "APP_BUILD_DIRTY"], + ] as const)("rejects a missing %s", (field, variableName) => { + expect(() => parseBuildMetadata({ ...validInput, [field]: undefined })).toThrow( + `${variableName} is required` + ); + }); + + it("rejects an invalid commit SHA", () => { + expect(() => + parseBuildMetadata({ ...validInput, commitSha: "not-a-commit" }) + ).toThrow("APP_BUILD_COMMIT_SHA must be a Git commit SHA"); + }); + + it("rejects an invalid build timestamp", () => { + expect(() => parseBuildMetadata({ ...validInput, builtAt: "yesterday" })).toThrow( + "APP_BUILD_AT must be an ISO 8601 timestamp" + ); + }); + + it("rejects an invalid dirty flag", () => { + expect(() => parseBuildMetadata({ ...validInput, dirty: "yes" })).toThrow( + 'APP_BUILD_DIRTY must be either "true" or "false"' + ); + }); +}); + +describe("readBuildMetadata", () => { + it("reads embedded build metadata", () => { + vi.stubEnv("APP_BUILD_BRANCH", validInput.branch); + vi.stubEnv("APP_BUILD_AT", validInput.builtAt); + vi.stubEnv("APP_BUILD_COMMIT_SHA", validInput.commitSha); + vi.stubEnv("APP_BUILD_COMMIT_SUBJECT", validInput.commitSubject); + vi.stubEnv("APP_BUILD_DIRTY", validInput.dirty); + + expect(readBuildMetadata()).toEqual({ + branch: validInput.branch, + builtAt: validInput.builtAt, + commitSha: validInput.commitSha, + commitSubject: validInput.commitSubject, + dirty: true, + }); + }); +}); diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts new file mode 100644 index 0000000..e4bd7ef --- /dev/null +++ b/tests/next-config.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { + PHASE_DEVELOPMENT_SERVER, + PHASE_PRODUCTION_BUILD, +} from "next/constants"; +import createNextConfig from "@/next.config"; +import packageJson from "@/package.json"; + +const buildMetadata = vi.hoisted(() => ({ + branch: "environment-badge", + builtAt: "2026-08-07T07:15:00.000Z", + commitSha: "98556cc1d4a18439855616c0b86e4eaa6b5d2821", + commitSubject: "Add environment badge details", + dirty: true, +})); + +vi.mock("@/lib/build-metadata.build", () => ({ + discoverBuildMetadata: vi.fn(() => buildMetadata), +})); + +describe("next config build metadata", () => { + it("embeds Git metadata for the development server", () => { + const config = createNextConfig(PHASE_DEVELOPMENT_SERVER); + + expect(config.env).toEqual({ + NEXT_PUBLIC_APP_VERSION: packageJson.version, + APP_BUILD_BRANCH: buildMetadata.branch, + APP_BUILD_AT: buildMetadata.builtAt, + APP_BUILD_COMMIT_SHA: buildMetadata.commitSha, + APP_BUILD_COMMIT_SUBJECT: buildMetadata.commitSubject, + APP_BUILD_DIRTY: String(buildMetadata.dirty), + }); + }); + + it("excludes Git metadata from production builds", () => { + const config = createNextConfig(PHASE_PRODUCTION_BUILD); + + expect(config.env).toEqual({ + NEXT_PUBLIC_APP_VERSION: packageJson.version, + }); + }); +});