Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions frontend/cntr/CopyButton/CopyButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import CopyButton from './CopyButton';

describe('CopyButton', () => {
beforeEach(() => {
jest.useFakeTimers();

Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
},
});
});

afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
jest.restoreAllMocks();
});

it('renders default Copy label', () => {
render(<CopyButton text="hello" />);

expect(
screen.getByRole('button', { name: /copy/i }),
).toBeInTheDocument();
});

it('renders custom label', () => {
render(
<CopyButton
text="hello"
label="Copy API Key"
/>,
);

expect(
screen.getByRole('button', {
name: /copy api key/i,
}),
).toBeInTheDocument();
});

it('copies text using clipboard api', async () => {
render(<CopyButton text="workspace-123" />);

fireEvent.click(
screen.getByRole('button'),
);

await waitFor(() => {
expect(
navigator.clipboard.writeText,
).toHaveBeenCalledWith(
'workspace-123',
);
});
});

it('shows copied confirmation', async () => {
render(<CopyButton text="abc" />);

fireEvent.click(
screen.getByRole('button'),
);

expect(
await screen.findByText(/copied!/i),
).toBeInTheDocument();
});

it('returns to default label after 2000ms', async () => {
render(<CopyButton text="abc" />);

fireEvent.click(
screen.getByRole('button'),
);

expect(
await screen.findByText(/copied!/i),
).toBeInTheDocument();

jest.advanceTimersByTime(2000);

expect(
screen.getByRole('button', {
name: /copy/i,
}),
).toBeInTheDocument();
});

it('falls back to execCommand when clipboard api is unavailable', () => {
Object.defineProperty(
navigator,
'clipboard',
{
value: undefined,
configurable: true,
},
);

const execSpy = jest
.spyOn(document, 'execCommand')
.mockImplementation(() => true);

render(<CopyButton text="fallback-text" />);

fireEvent.click(
screen.getByRole('button'),
);

expect(execSpy).toHaveBeenCalledWith(
'copy',
);
});
});
95 changes: 95 additions & 0 deletions frontend/cntr/CopyButton/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React, { useEffect, useRef, useState } from 'react';

interface CopyButtonProps {
text: string;
label?: string;
}

const COPY_TIMEOUT_MS = 2000;

export default function CopyButton({
text,
label = 'Copy',
}: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<number | null>(null);

const fallbackCopy = (value: string) => {
const textarea = document.createElement('textarea');

textarea.value = value;
textarea.setAttribute('readonly', '');
textarea.style.position = 'absolute';
textarea.style.left = '-9999px';

document.body.appendChild(textarea);

textarea.select();

try {
document.execCommand('copy');
} finally {
document.body.removeChild(textarea);
}
};

const copyToClipboard = async () => {
try {
if (
typeof navigator !== 'undefined' &&
navigator.clipboard &&
navigator.clipboard.writeText
) {
await navigator.clipboard.writeText(text);
} else {
fallbackCopy(text);
}

setCopied(true);

if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}

timeoutRef.current = window.setTimeout(() => {
setCopied(false);
}, COPY_TIMEOUT_MS);
} catch {
fallbackCopy(text);

setCopied(true);

if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}

timeoutRef.current = window.setTimeout(() => {
setCopied(false);
}, COPY_TIMEOUT_MS);
}
};

useEffect(() => {
return () => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
};
}, []);

return (
<button
type="button"
onClick={copyToClipboard}
aria-label={copied ? 'Copied!' : label}
>
{copied ? (
<>
✓ Copied!
</>
) : (
label
)}
</button>
);
}
165 changes: 165 additions & 0 deletions frontend/cntr/CountdownTimer/CountdownTimer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import React from "react";

import {
render,
screen,
act,
} from "@testing-library/react";

import {
CountdownTimer,
} from "./CountdownTimer";

describe(
"CountdownTimer",
() => {
beforeEach(() => {
jest.useFakeTimers();

jest.setSystemTime(
new Date(
"2026-01-01T12:00:00Z",
),
);
});

afterEach(() => {
jest.useRealTimers();
});

it(
"renders HH:MM:SS format",
() => {
render(
<CountdownTimer
endsAt={
new Date(
"2026-01-01T13:01:05Z",
)
}
/>,
);

expect(
screen.getByText(
"01:01:05",
),
).toBeInTheDocument();
},
);

it(
"turns red when 5 minutes remain",
() => {
render(
<CountdownTimer
endsAt={
new Date(
"2026-01-01T12:05:00Z",
)
}
/>,
);

expect(
screen.getByTestId(
"countdown-timer",
),
).toHaveClass(
"text-red-600",
);
},
);

it(
"calls onExpire exactly once",
() => {
const onExpire =
jest.fn();

render(
<CountdownTimer
endsAt={
new Date(
"2026-01-01T12:00:02Z",
)
}
onExpire={
onExpire
}
/>,
);

act(() => {
jest.advanceTimersByTime(
5000,
);
});

expect(
onExpire,
).toHaveBeenCalledTimes(
1,
);
},
);

it(
"updates every second",
() => {
render(
<CountdownTimer
endsAt={
new Date(
"2026-01-01T12:00:10Z",
)
}
/>,
);

act(() => {
jest.advanceTimersByTime(
1000,
);
});

expect(
screen.getByText(
"00:00:09",
),
).toBeInTheDocument();
},
);

it(
"clears interval on unmount",
() => {
const clearSpy =
jest.spyOn(
window,
"clearInterval",
);

const {
unmount,
} = render(
<CountdownTimer
endsAt={
new Date(
"2026-01-01T12:00:10Z",
)
}
/>,
);

unmount();

expect(
clearSpy,
).toHaveBeenCalled();

clearSpy.mockRestore();
},
);
},
);
Loading
Loading