mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-11 03:13:56 +00:00
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:
1 parent
fe3a89b3a8
commit
2b57bf9526
4 files changed
+90
No files matched your search
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user