diff --git a/src/app/my-tickets/gate-code-copy.test.ts b/src/app/my-tickets/gate-code-copy.test.ts
new file mode 100644
index 0000000..a2d5353
--- /dev/null
+++ b/src/app/my-tickets/gate-code-copy.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+// #242 "done when": the shared CopyButton is used on My tickets for the gate code.
+describe('My tickets gate code (#242)', () => {
+ it('renders CopyButton for the gate code', () => {
+ const page = readFileSync(join(__dirname, 'page.tsx'), 'utf8');
+ expect(page).toMatch(/import \{ CopyButton \} from '@\/components\/copy-button'/);
+ expect(page).toMatch(//);
+ });
+});
diff --git a/src/components/copy-button.test.tsx b/src/components/copy-button.test.tsx
new file mode 100644
index 0000000..da2f941
--- /dev/null
+++ b/src/components/copy-button.test.tsx
@@ -0,0 +1,77 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+
+const copyToClipboard = vi.fn<(text: string) => Promise>();
+vi.mock('@/lib/clipboard', () => ({ copyToClipboard: (t: string) => copyToClipboard(t) }));
+
+import { CopyButton } from './copy-button';
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+let host: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ copyToClipboard.mockReset();
+ host = document.createElement('div');
+ document.body.appendChild(host);
+ root = createRoot(host);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ vi.useRealTimers();
+});
+
+function render(value = 'GATE-1234', label = 'Copy gate code') {
+ act(() => root.render());
+ return {
+ button: host.querySelector('button')!,
+ status: host.querySelector('[role="status"]')!,
+ };
+}
+
+async function click(button: HTMLButtonElement) {
+ await act(async () => {
+ button.click();
+ });
+}
+
+describe('CopyButton (#242)', () => {
+ it('is a labelled button with an empty polite live region at rest', () => {
+ const { button, status } = render();
+ expect(button.getAttribute('type')).toBe('button');
+ expect(button.getAttribute('aria-label')).toBe('Copy gate code');
+ expect(status.getAttribute('aria-live')).toBe('polite');
+ expect(status.textContent).toBe('');
+ });
+
+ it('copies the value in one click and announces "Copied"', async () => {
+ copyToClipboard.mockResolvedValue(true);
+ const { button, status } = render('GATE-1234');
+ await click(button);
+ expect(copyToClipboard).toHaveBeenCalledWith('GATE-1234');
+ expect(status.textContent).toBe('Copied');
+ });
+
+ it('announces "Copy failed" when the clipboard is unavailable', async () => {
+ copyToClipboard.mockResolvedValue(false);
+ const { button, status } = render();
+ await click(button);
+ expect(status.textContent).toBe('Copy failed');
+ });
+
+ it('clears the announcement after 2 seconds', async () => {
+ copyToClipboard.mockResolvedValue(true);
+ const { button, status } = render();
+ await click(button);
+ expect(status.textContent).toBe('Copied');
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(status.textContent).toBe('');
+ });
+});