feat(footer): add build-stamp footer with commit SHA

Expose NEXT_PUBLIC_COMMIT_SHA via next.config.mjs (resolves full commit
SHA from VERCEL_GIT_COMMIT_SHA or git), render copyable footer stamp in
DebugFooter component (bottom-center, underlined, a11y-tuned), and add
e2e test for footer visibility and clipboard interaction.
This commit is contained in:
tiennm99 committed 2026-09-01 00:11:29 +07:00
1 parent fe3a89b3a8
commit 2b57bf9526
4 files changed
+90

No files matched your search

+23
View File
@@ -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`,
+51
View File
@@ -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 (
<div className="pointer-events-none fixed inset-x-0 bottom-[env(safe-area-inset-bottom)] z-40 flex justify-center">
{/* 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. */}
<button
type="button"
onClick={handleCopy}
aria-label="Copy build commit"
aria-live="polite"
title={sha}
className="pointer-events-auto flex min-h-11 min-w-[9ch] cursor-pointer select-none items-center justify-center rounded-md px-3 font-mono text-[11px] leading-none text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground active:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
>
{copied ? 'copied!' : sha.slice(0, 7)}
</button>
</div>
);
}
+2
View File
@@ -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}
<DebugFooter />
<Analytics />
<SpeedInsights />
</body>
+14
View File
@@ -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();