Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e2822c3
setting up jest and creating a test_agent context file to allow copil…
SamuelLo1 Mar 10, 2026
2550547
setting up first jest test and github workflow
SamuelLo1 Mar 11, 2026
1e5be15
testing if npm install works
SamuelLo1 Mar 11, 2026
b9c4642
getting error for ci fixing it to see if it works now
SamuelLo1 Mar 11, 2026
88e868c
npm install
SamuelLo1 Mar 11, 2026
cbca35d
update directory to frontend before running stuff
SamuelLo1 Mar 11, 2026
b909345
added coverage report for jest. can be accessed via summary -> artifacts
SamuelLo1 Mar 11, 2026
c996cd7
changing to correct path
SamuelLo1 Mar 11, 2026
533abf2
path update
SamuelLo1 Mar 11, 2026
f86fd77
proper multiline run block
SamuelLo1 Mar 11, 2026
55a1013
coverage report contains css and all that good stuff now
SamuelLo1 Mar 11, 2026
b85b833
catalog page test with mock api call, should render all the apps it i…
SamuelLo1 Mar 11, 2026
1c5175a
Initial plan
Copilot Mar 11, 2026
de95852
Fix lint errors in test files without changing test logic
Copilot Mar 11, 2026
fed4a89
Merge pull request #35 from smalex-z/copilot/sub-pr-34
SamuelLo1 Mar 11, 2026
adbbf54
testing out sticky comments
SamuelLo1 Mar 11, 2026
2a67041
the file does not show up tryying diff file
SamuelLo1 Mar 11, 2026
fd8c19c
changing file name since comment not showing up
SamuelLo1 Mar 11, 2026
0f80118
checking if the comment is correct
SamuelLo1 Mar 11, 2026
8603682
updating error with next/navigation trying to import useRouter within…
SamuelLo1 Mar 11, 2026
5cb0ef2
updating lint
SamuelLo1 Mar 11, 2026
3717000
tryying to fix useRouter error
SamuelLo1 Mar 11, 2026
ae0f54d
changing navigation
SamuelLo1 Mar 11, 2026
0503920
testing sticky comment
SamuelLo1 Mar 12, 2026
4d411bb
rewriting how the routing works
SamuelLo1 Mar 12, 2026
15e3711
checking if this test will pass
SamuelLo1 Mar 12, 2026
b3abf0a
redirect just doesnt work for some reason
SamuelLo1 Mar 12, 2026
8a0a566
updating
SamuelLo1 Mar 12, 2026
45df4ca
redirect doesnt work
SamuelLo1 Mar 12, 2026
250ddbf
lint fix
SamuelLo1 Mar 12, 2026
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
55 changes: 55 additions & 0 deletions .github/test_agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Role

You are a Senior Frontend Test Engineer specializing in Next.js, React, Jest, and React Testing Library (RTL). Your objective is to write robust, maintainable, and reliable unit tests for React components.

# Objective

When provided with a React component or a request to test a specific component, generate a complete, ready-to-run Jest unit test file.

# Directory Structure & Naming

- All test files must be placed within a `__tests__` directory at the root or adjacent to the components, depending on the requested structure.
- The output file should always be named `[ComponentName].test.tsx`.

# Tech Stack & Libraries

- Framework: Next.js (React)
- Test Runner: Jest
- Testing Utilities: `@testing-library/react` and `@testing-library/user-event`
- Assertions: `@testing-library/jest-dom`

# Rules & Best Practices

## 1. Testing Philosophy

- Test behavior, not implementation details. Focus on what the user sees and interacts with.
- Prefer accessible queries (`getByRole`, `getByLabelText`, `getByText`) over test IDs (`getByTestId`) whenever possible.
- Use `@testing-library/user-event` for simulating user interactions instead of `fireEvent`, as it more accurately reflects actual browser behavior.

## 2. Next.js Specific Mocking

Next.js components often rely on built-in modules that need to be mocked in a Jest environment. Always include these mocks if the component uses them:

- **Routing (App Router):** Mock `next/navigation` (e.g., `useRouter`, `usePathname`, `useSearchParams`).
- **Routing (Pages Router):** Mock `next/router` (`useRouter`).
- **Images:** Mock `next/image` to render a standard `<img>` tag to prevent complex loading errors in Jest.
- **Links:** Mock `next/link` if custom routing assertions are needed, though standard RTL rendering usually handles basic link testing fine.

## 3. Test Structure

- Group related tests using a `describe('<ComponentName />', () => { ... })` block.
- Use `it('should ...', () => { ... })` for individual test cases.
- Include a basic "renders successfully" test as the first test case.
- Clear mocks between tests using `afterEach(jest.clearAllMocks)` if global mocks are applied.

## 4. Code Output

- Provide only the raw code for the `.test.tsx` file inside a code block.
- Ensure all necessary imports are included at the top of the file.
- Assume the component is being imported from the correct relative path (e.g., `import ComponentName from '../components/ComponentName';`). If the exact path is unknown, use a placeholder like `path/to/[ComponentName]`.

# Workflow

1. **Input:** The user will provide the code of a Next.js component or mention the name and props of a component.
2. **Analysis:** Identify the component's state, props, user interactions, and Next.js dependencies.
3. **Output:** Generate the full `[ComponentName].test.tsx` file following the rules above.
76 changes: 76 additions & 0 deletions .github/workflows/frontend-unit-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: Frontend Unit Tests

on:
push:
branches: [main]
pull_request:
branches: [main, frontendUnitTests]

permissions:
contents: read
pull-requests: write

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json

- name: Install dependencies and run tests
working-directory: frontend
run: |
npm install
npm test -- --coverage 2>&1 | tee test_output.txt

- name: Parse test results and comment
if: always()
working-directory: frontend
run: |
PASSED=$(grep -Eo '[0-9]+ passed' test_output.txt | tail -1 | awk '{print $1}' || echo 0)
FAILED=$(grep -Eo '[0-9]+ failed' test_output.txt | tail -1 | awk '{print $1}' || echo 0)
TOTAL=$(grep -Eo '[0-9]+ total' test_output.txt | tail -1 | awk '{print $1}' || echo 0)
COVERAGE="0.0%"
if [ -f coverage/coverage-summary.json ]; then
COVERAGE=$(node -e "console.log(require('./coverage/coverage-summary.json').total.lines.pct + '%')")
elif [ -f coverage/lcov-report/index.html ]; then
COV=$(grep -oP 'All files.*?\K[0-9]+\.[0-9]+' coverage/lcov-report/index.html | head -1 || echo "0.0")
COVERAGE="${COV}%"
fi

SUITES_LINE=$(grep '^Test Suites:' test_output.txt | tail -1 || echo 'Test Suites: 0 total')
TESTS_LINE=$(grep '^Tests:' test_output.txt | tail -1 || echo 'Tests: 0 total')
SNAPSHOTS_LINE=$(grep '^Snapshots:' test_output.txt | tail -1 || echo 'Snapshots: 0 total')
TIME_LINE=$(grep '^Time:' test_output.txt | tail -1 || echo 'Time: 0.0 s')

echo "Unit tests run: ${TOTAL:-0}" > summary.txt
echo "Unit tests passed: ${PASSED:-0}" >> summary.txt
echo "Unit tests failed: ${FAILED:-0}" >> summary.txt
echo "Test coverage: ${COVERAGE:-0.0%}" >> summary.txt
echo "" >> summary.txt
echo "${SUITES_LINE}" >> summary.txt
echo "${TESTS_LINE}" >> summary.txt
echo "${SNAPSHOTS_LINE}" >> summary.txt
echo "${TIME_LINE}" >> summary.txt

- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: |
frontend/coverage
frontend/test_output.txt

- name: Comment test summary (PR only)
if: github.event_name == 'pull_request'
uses: marocchino/sticky-pull-request-comment@v2
with:
recreate: true
path: frontend/test_output.txt
5 changes: 4 additions & 1 deletion frontend/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@
}],
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"no-unused-vars": "off",
"react/require-default-props": "off"
"react/require-default-props": "off",
"import/no-extraneous-dependencies": ["error", {
"devDependencies": ["**/*.test.tsx", "**/*.test.ts", "**/*.test.js", "jest.setup.ts", "jest.config.cjs"]
}]
},
"settings": {
"import/resolver": {
Expand Down
11 changes: 11 additions & 0 deletions frontend/__mocks__/next/navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const useRouter = () => ({ push: jest.fn(), replace: jest.fn(), refresh: jest.fn() });
export const usePathname = () => '/';
export const useSearchParams = () => ({ get: () => null });
export const redirect = jest.fn();

export default {
useRouter,
usePathname,
useSearchParams,
redirect,
};
66 changes: 66 additions & 0 deletions frontend/__tests__/CatalogPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';

import CatalogPage from '../app/(dashboard)/catalog/page';
import { getCatalogApps } from '../lib/api';

// Mock API and next/navigation
jest.mock('../lib/api', () => ({
getCatalogApps: jest.fn(),
}));

describe('CatalogPage', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('renders apps, filters by category', async () => {
const apps = [
{
id: '1',
name: 'AppOne',
description: 'First app',
category: 'chat',
replaces: 'Slack',
monthlyCost: 10,
monthlySavings: 5,
features: ['messaging', 'rooms'],
recommended: true,
installed: false,
},
{
id: '2',
name: 'AppTwo',
description: 'Second app',
category: 'video',
replaces: 'Zoom',
monthlyCost: 20,
monthlySavings: 10,
features: ['calls', 'screen share'],
recommended: false,
installed: false,
},
];

(getCatalogApps as unknown as jest.Mock).mockResolvedValue(apps);

render(<CatalogPage />);

// Wait for app names to appear
expect(await screen.findByText('AppOne')).toBeInTheDocument();
expect(screen.getByText('AppTwo')).toBeInTheDocument();

// Category buttons: All + chat + video
const chatBtn = screen.getByRole('button', { name: /chat/i });
await userEvent.click(chatBtn);
// After filtering, AppTwo (video) should not be visible
expect(screen.queryByText('AppTwo')).not.toBeInTheDocument();
expect(screen.getByText('AppOne')).toBeInTheDocument();

// Install button should be present and active
const installButtons = screen.getAllByRole('button', { name: /Ready to Install/i });
expect(installButtons.length).toBe(1);
});
});
119 changes: 119 additions & 0 deletions frontend/__tests__/ServiceWizard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import ServiceWizard, { WizardFieldConfig } from '../app/components/serviceWizard';

// Mock the WizardInputField to keep tests focused on ServiceWizard behavior
jest.mock('../app/components/wizardInputField', () => ({
__esModule: true,
default: ({
label, value, onChange, onEnter, error, placeholder,
}) => {
const inputId = label?.replace(/[^a-zA-Z0-9]/g, '-');
return (
<div>
<label htmlFor={inputId}>
{label}
<input
id={inputId}
aria-label={label}
value={value || ''}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onEnter();
}}
/>
</label>
{error && <div role="alert">{error}</div>}
</div>
);
},
}));

describe('ServiceWizard', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('renders and walks through the wizard to completion', async () => {
const user = userEvent.setup();
const onComplete = jest.fn();

const fields: WizardFieldConfig[] = [
{
key: 'publicUrl',
label: 'Jitsi Public URL',
type: 'url',
placeholder: 'https://meet.example.com',
description: 'The full URL of your Jitsi Meet instance',
required: true,
},
{
key: 'email',
label: 'Admin Email (SSL)',
type: 'email',
placeholder: 'admin@example.com',
description: "Email address used for Let's Encrypt SSL certificate registration",
required: true,
},
];

render(<ServiceWizard fields={fields} onComplete={onComplete} serviceName="TestService" />);

// initial render
expect(screen.getByText('TestService setup wizard')).toBeInTheDocument();
const getStepEl = () => screen.getByText((content) => content.includes('Step') && content.includes('of'));
expect(getStepEl()).toHaveTextContent(/1/);

// Try to proceed without entering required field -> shows validation
const nextBtn = screen.getByRole('button', { name: /Next/ });
await user.click(nextBtn);
expect(await screen.findByRole('alert')).toHaveTextContent('This field is required');

// Fill host and proceed
const hostInput = screen.getByLabelText('Jitsi Public URL');
await user.type(hostInput, 'https://example.com');
await user.click(nextBtn);

expect(getStepEl()).toHaveTextContent(/2/);

// Go back
const prevBtn = screen.getByRole('button', { name: /Previous/ });
await user.click(prevBtn);
expect(getStepEl()).toHaveTextContent(/1/);

// Move forward to step 2 again and fill email with invalid email first
await user.click(nextBtn); // step 2
const completeBtn = screen.getByRole('button', { name: /Complete Setup →/ });
const emailInput = screen.getByLabelText('Admin Email (SSL)');
await user.type(emailInput, 'not-an-email');
await user.click(completeBtn);
expect(await screen.findByRole('alert')).toHaveTextContent('Please enter a valid email address');

// Correct email and proceed to last step
await user.clear(emailInput);
await user.type(emailInput, 'admin@example.com');
await user.click(completeBtn);

expect(onComplete).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledWith({
publicUrl: 'https://example.com',
email: 'admin@example.com',
});
});

it('supports initialData and preserves values', async () => {
const user = userEvent.setup();
const onComplete = jest.fn();
const fields = [{ key: 'username', label: 'Username', required: true }];

render(<ServiceWizard fields={fields} onComplete={onComplete} initialData={{ username: 'alice' }} />);

expect(screen.getByLabelText('Username')).toHaveValue('alice');
const completeBtn2 = screen.getByRole('button', { name: /Complete Setup/ });
await user.click(completeBtn2);
expect(onComplete).toHaveBeenCalledWith({ username: 'alice' });
});
});
18 changes: 18 additions & 0 deletions frontend/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
clearMocks: true,
collectCoverage: true,
coverageDirectory: 'coverage',
coverageProvider: 'v8',
testMatch: ['**/__tests__/**/*.test.(ts|tsx|js)'],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'^@/(.*)$': '<rootDir>/$1',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
};
9 changes: 9 additions & 0 deletions frontend/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import '@testing-library/jest-dom';

// Provide a global mock for next/navigation used by client components in tests
jest.mock('next/navigation', () => ({
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), refresh: jest.fn() }),
usePathname: () => '/',
useSearchParams: () => ({ get: () => null }),
redirect: jest.fn(),
}));
Loading
Loading