diff --git a/next.config.mjs b/next.config.mjs index 5588e26..add6fdb 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,5 +1,28 @@ +import { execSync } from 'node:child_process'; + +// The commit this build was made from, for the debug footer on every page. +// The FULL sha: the footer shows the short form but copies this, so what +// lands in the clipboard is directly usable with git. Vercel's build +// container has no .git directory but provides the sha in an env var; local +// builds ask git; anything else shows 'unknown' rather than failing the +// build over a label. +function resolveCommitSha() { + const fromPlatform = process.env.VERCEL_GIT_COMMIT_SHA; + if (fromPlatform) return fromPlatform; + try { + return execSync('git rev-parse HEAD', { + stdio: ['ignore', 'pipe', 'ignore'], + }).toString().trim(); + } catch { + return 'unknown'; + } +} + /** @type {import('next').NextConfig} */ const nextConfig = { + env: { + NEXT_PUBLIC_COMMIT_SHA: resolveCommitSha(), + }, // `next dev` and `next build` both write to the output directory, so a build // started while a dev server is serving makes that server read manifests the // build has just replaced. Leaving this unset keeps production on `.next`, diff --git a/src/app/components/DebugFooter.js b/src/app/components/DebugFooter.js new file mode 100644 index 0000000..723871c --- /dev/null +++ b/src/app/components/DebugFooter.js @@ -0,0 +1,51 @@ +"use client"; + +import { useEffect, useRef, useState } from 'react'; + +// Build stamp rendered on every page: which commit is this deployment? +// +// Bottom-center, styled as a link; clicking copies the FULL sha (the label +// shows the short form). z-40 keeps it under dialogs (z-50) and under the +// game screen's action bar and map (z-500+), so on a phone mid-round it ducks +// behind the controls. The wrapper stays click-transparent; only the button +// itself takes the pointer. +export default function DebugFooter() { + const sha = process.env.NEXT_PUBLIC_COMMIT_SHA || 'unknown'; + const [copied, setCopied] = useState(false); + const resetTimerRef = useRef(null); + + useEffect(() => () => clearTimeout(resetTimerRef.current), []); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(sha); + setCopied(true); + clearTimeout(resetTimerRef.current); + resetTimerRef.current = setTimeout(() => setCopied(false), 1500); + } catch (error) { + // Clipboard access can be denied (insecure context, permissions); the + // label is still readable, so failing quietly beats an alert. + console.error('Could not copy commit sha:', error); + } + }; + + return ( +
+ {/* The visual is deliberately tiny, so the tap target comes from + padding: ~44px tall, well past the label's own box. min-w in ch + keeps the width stable when the 7-char sha swaps for the 7-char + 'copied!', so the click never shifts the thing being clicked. + aria-live announces that swap to screen readers. */} + +
+ ); +} diff --git a/src/app/layout.js b/src/app/layout.js index e5301af..db284ab 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -1,4 +1,5 @@ import { THEME_STORAGE_KEY } from '../lib/theme'; +import DebugFooter from './components/DebugFooter'; import { Analytics } from '@vercel/analytics/next'; import { SpeedInsights } from '@vercel/speed-insights/next'; import { Geist, Geist_Mono } from "next/font/google"; @@ -53,6 +54,7 @@ export default function RootLayout({ children }) { className={`${geistSans.variable} ${geistMono.variable} antialiased`} > {children} + diff --git a/tests/e2e/home.spec.js b/tests/e2e/home.spec.js index 0e5090b..81f3804 100644 --- a/tests/e2e/home.spec.js +++ b/tests/e2e/home.spec.js @@ -35,6 +35,20 @@ test('lists an uncovered district as disabled, with the reason', async ({ page } await expect(page.getByRole('link', { name: /^Cu Chi$/ })).toHaveCount(0); }); +test('shows the build commit in the debug footer and copies it on click', async ({ page, context }) => { + // The dev server resolves the sha from git, so this asserts the real wiring: + // config env -> layout -> footer -> clipboard. + const stamp = page.getByRole('button', { name: 'Copy build commit' }); + await expect(stamp).toBeVisible(); + await expect(stamp).toHaveText(/^[0-9a-f]{7}$/); + + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await stamp.click(); + await expect(stamp).toHaveText('copied!'); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + expect(copied).toMatch(/^[0-9a-f]{40}$/); +}); + test('shows the stubbed leaderboard in the modal', async ({ page }) => { await page.getByRole('button', { name: /Leaderboard/i }).click(); await expect(page.getByText('top-player')).toBeVisible();