# Contributing to Multiforum This guide will help you contribute to the Multiforum codebase, with a focus on testing practices. This guide is for the **frontend** ([multiforum-nuxt](https://github.com/gennit-project/multiforum-nuxt)). The backend lives in [multiforum-backend](https://github.com/gennit-project/multiforum-backend); see its README for backend setup and architecture. ## Prerequisites - **Node.js 26** is the recommended development runtime (see `.nvmrc`). Node.js ≥ 24.13.0 remains supported while Vercel deploys on Node 24. Older majors are not supported. If you use `nvm`, run `nvm use` before installing or running scripts. - **npm** (bundled with Node). ## Environment Variables The application requires several environment variables to be set up. Copy `.env.example` to `.env` and update the values. ### Core Application Variables | Variable | Description | | -------------------------- | --------------------------------------------------- | | `GRAPHQL_URL_FOR_TYPES` | URL for GraphQL schema used in code generation | | `VITE_GRAPHQL_URL` | URL for GraphQL API endpoint | | `VITE_BASE_URL` | Base URL for the application | | `VITE_SERVER_NAME` | Internal server name (must match backend config) | | `VITE_SERVER_DISPLAY_NAME` | User-facing name of the server | | `VITE_ENVIRONMENT` | Environment name (development, production, etc.) | | `NITRO_PRESET` | Deployment preset for Nitro server (e.g., 'vercel') | ### Authentication Variables | Variable | Description | | -------------------------- | ------------------------------------ | | `VITE_AUTH0_DOMAIN` | Auth0 domain for authentication | | `VITE_AUTH0_CLIENT_ID` | Auth0 application client ID | | `VITE_AUTH0_CLIENT_SECRET` | Auth0 application client secret | | `VITE_AUTH0_AUDIENCE` | Auth0 API audience identifier | | `VITE_AUTH0_SCOPE` | Auth0 permission scopes | | `VITE_AUTH0_URL` | Auth0 token endpoint | | `VITE_AUTH0_CALLBACK_URL` | URL to redirect after authentication | | `VITE_LOGOUT_URL` | URL to redirect to after logout | ### External Services Variables | Variable | Description | | ---------------------------------- | ---------------------------------------------- | | `VITE_GOOGLE_MAPS_API_KEY` | API key for Google Maps integration | | `VITE_OPEN_CAGE_API_KEY` | API key for OpenCage geocoding service | | `VITE_OPEN_GRAPH_API_KEY` | API key for OpenGraph services | | `VITE_GOOGLE_CLOUD_STORAGE_BUCKET` | Google Cloud Storage bucket for file uploads | | `VITE_SENTRY_AUTH_TOKEN` | Sentry authentication token for error tracking | | `VITE_SENTRY_DSN` | Sentry Data Source Name for error reporting | ### Docker Compose Variables | Variable | Description | | --------------------------- | ------------------------------------------------- | | `NEO4J_AUTH` | Neo4j database authentication (username/password) | | `NEO4J_USERNAME` | Neo4j database username | | `NEO4J_PASSWORD` | Neo4j database password | | `GCS_BUCKET_NAME` | Google Cloud Storage bucket name | | `GOOGLE_CREDENTIALS_BASE64` | Base64-encoded Google Cloud credentials | | `SLACK_WEBHOOK_URL` | Slack webhook URL for notifications | | `AUTH0_DOMAIN` | Auth0 domain for server-side operations | | `AUTH0_CLIENT_ID` | Auth0 client ID for server-side operations | | `SERVER_CONFIG_NAME` | Server configuration name | ### Testing Variables | Variable | Description | | ----------------------------- | -------------------------------------------- | | `VITE_AUTH0_USERNAME` | Test username for Auth0 in integration tests | | `VITE_AUTH0_PASSWORD` | Test password for Auth0 in integration tests | | `VITE_AUTH0_USERNAME_2` | Secondary test username for Auth0 | | `VITE_AUTH0_PASSWORD_2` | Secondary test password for Auth0 | ## Getting Started 1. Clone the repository 2. Select the recommended Node 26 runtime (`nvm use`) 3. Install the repository's pinned pnpm version (Node 26 no longer bundles Corepack): `npm install --global pnpm@10.28.2` 4. Copy `.env.example` to `.env` and update the values 5. Install dependencies with `pnpm install` 6. Start the development server with `pnpm run dev` 7. Run unit tests with `pnpm run test:unit` (Vitest) and end-to-end tests with `pnpm run test:playwright` (Playwright, mocked GraphQL) 8. Before committing, run `pnpm run verify` (type-check + unit tests); the pre-commit hook runs this plus ESLint automatically ## Testing Guidelines ### Testing Patterns #### Unit Tests (Vitest) We use Vitest with Vue Test Utils for component and utility testing. Unit tests should: - Test one specific piece of functionality - Mock external dependencies - Focus on behavior, not implementation details - Be fast and deterministic **Component Test Structure:** ```typescript // Import utilities and component import { describe, it, expect } from 'vitest'; import { mount } from '@vue/test-utils'; import YourComponent from '@/components/YourComponent.vue'; describe('YourComponent', () => { // Test rendering it('renders correctly with default props', () => { const wrapper = mount(YourComponent); expect(wrapper.exists()).toBe(true); expect(wrapper.text()).toContain('Expected Text'); }); // Test props it('applies custom prop values correctly', () => { const wrapper = mount(YourComponent, { props: { title: 'Custom Title' }, }); expect(wrapper.text()).toContain('Custom Title'); }); // Test user interaction it('emits an event when button is clicked', async () => { const wrapper = mount(YourComponent); await wrapper.find('button').trigger('click'); expect(wrapper.emitted()).toHaveProperty('custom-event'); }); // Test conditional rendering it('shows error message when validation fails', async () => { const wrapper = mount(YourComponent); await wrapper.find('input').setValue('invalid'); await wrapper.find('form').trigger('submit'); expect(wrapper.text()).toContain('Error message'); }); }); ``` #### End-to-End Tests (Playwright) End-to-end tests use Playwright with **mocked GraphQL responses** (no backend required). Tests should: - Focus on user-centric workflows - Test feature interactions - Use `installMockAuth()` for programmatic authentication (faster and more reliable than driving the login UI) - Leverage `data-testid` attributes for stable selectors - Wait on network responses or UI state instead of arbitrary sleeps **End-to-End Test Structure:** ```typescript import { test, expect } from '@playwright/test'; import { installMockAuth } from '../support/auth'; test('completes a user workflow successfully', async ({ page }) => { await installMockAuth(page); await page.goto('/forums/cats'); await page.getByTestId('create-discussion-button').click(); await page.getByTestId('title-input').fill('Test Discussion'); await page.getByTestId('body-input').fill('Test Body'); // Wait for the actual GraphQL request to complete instead of a fixed timeout await Promise.all([ page.waitForResponse((res) => res.url().includes('/graphql')), page.getByTestId('save-button').click(), ]); await expect(page.getByTestId('discussion-title')).toContainText( 'Test Discussion' ); }); ``` Run a single mocked spec with: ```bash pnpm run test:playwright:mocked -- tests/playwright/mocked/discussions/createEditDeleteDiscussions.spec.ts ``` ### Best Practices #### Example: Testing a Form Component ```typescript // FormComponent.spec.ts import { describe, it, expect } from 'vitest'; import { mount } from '@vue/test-utils'; import FormComponent from '@/components/FormComponent.vue'; describe('FormComponent', () => { // Setup common test data const validInput = { name: 'Test User', email: 'test@example.com', }; const mountComponent = (props = {}) => { return mount(FormComponent, { props: { initialValues: {}, ...props, }, }); }; it('validates required fields', async () => { const wrapper = mountComponent(); // Submit empty form await wrapper.find('form').trigger('submit'); // Check for validation messages expect(wrapper.text()).toContain('Name is required'); expect(wrapper.text()).toContain('Email is required'); }); it('validates email format', async () => { const wrapper = mountComponent(); // Input invalid email await wrapper.find('[data-testid="name-input"]').setValue('Test User'); await wrapper.find('[data-testid="email-input"]').setValue('invalid-email'); await wrapper.find('form').trigger('submit'); // Check for validation message expect(wrapper.text()).toContain('Email format is invalid'); }); it('submits valid data and emits event', async () => { const wrapper = mountComponent(); // Fill form with valid data await wrapper.find('[data-testid="name-input"]').setValue(validInput.name); await wrapper .find('[data-testid="email-input"]') .setValue(validInput.email); // Submit form await wrapper.find('form').trigger('submit'); // Verify event emission expect(wrapper.emitted()).toHaveProperty('submit'); expect(wrapper.emitted().submit[0][0]).toEqual(validInput); }); }); ``` #### Example: Testing a Complex Workflow (Playwright) ```typescript // tests/playwright/mocked/discussions/createEditDeleteDiscussions.spec.ts import { test, expect } from '@playwright/test'; import { installMockAuth } from '../../support/auth'; test('creates, edits, and deletes a discussion', async ({ page }) => { await installMockAuth(page); // Create await page.goto('/forums/cats/discussions/create'); await page.getByTestId('title-input').fill('Test Discussion'); await page.getByTestId('body-input').fill('Initial content'); await Promise.all([ page.waitForResponse((res) => res.url().includes('/graphql')), page.getByTestId('save-button').click(), ]); await expect(page.getByTestId('discussion-title')).toContainText( 'Test Discussion' ); // Edit await page.getByTestId('discussion-menu-button').click(); await page.getByTestId('discussion-menu-button-item-Edit').click(); await page.getByTestId('body-input').fill('Updated content'); await Promise.all([ page.waitForResponse((res) => res.url().includes('/graphql')), page.getByTestId('save-button').click(), ]); await expect(page.getByTestId('discussion-body')).toContainText( 'Updated content' ); // Delete await page.getByTestId('discussion-menu-button').click(); await page.getByTestId('discussion-menu-button-item-Delete').click(); await page.getByTestId('confirm-delete-button').click(); await expect(page).toHaveURL(/\/forums\/cats\/discussions/); }); ``` ### Common Testing Pitfalls to Avoid 1. **Flaky Tests** - ❌ Don't use arbitrary timeouts: `await page.waitForTimeout(3000)` - ✅ Do wait for the actual network response or UI state: `page.waitForResponse(...)`, `await expect(locator).toBeVisible()` 2. **Brittle Selectors** - ❌ Don't use CSS classes that might change: `page.locator('.button-primary')` - ✅ Do use test ids: `page.getByTestId('save-button')` 3. **Testing Implementation Details** - ❌ Don't test internal component state: `expect(wrapper.vm.internalValue).toBe(true)` - ✅ Do test observable behavior: `expect(wrapper.text()).toContain('Error message')` 4. **Brittle Authentication Setup** - ❌ Don't drive the login UI in every test - ✅ Do authenticate programmatically with `installMockAuth(page)` 5. **Missing Network Request Handling** - ❌ Don't assume requests complete instantly - ✅ Do wait for the GraphQL response alongside the action that triggers it: ```typescript await Promise.all([ page.waitForResponse((res) => res.url().includes('/graphql')), page.getByTestId('save-button').click(), ]); ``` 6. **Overly Coupled Tests** - ❌ Don't create tests that depend on each other's state - ✅ Do create independent test cases with clear setup/teardown 7. **Testing Too Much in One Test** - ❌ Don't write lengthy tests covering multiple behaviors - ✅ Do create focused tests with clear assertions: ```typescript // Instead of one large test: it('validates email field'); it('validates password field'); it('allows submission with valid data'); ``` 8. **Inconsistent Mocking** - ❌ Don't partially mock dependencies - ✅ Do completely mock external dependencies or use real implementations ### Frontend testing patterns These conventions keep tests fast, meaningful, and consistent with the rest of the suite. - **Colocate specs.** Put a unit spec next to the file it tests (`Foo.vue` → `Foo.spec.ts`, `foo.ts` → `foo.spec.ts`). Specs under `tests/unit` are also picked up. - **Test real logic, not reimplementations.** When a component holds non-trivial logic (formatting, validation, derivation), extract it into a `utils/` function and unit-test that function directly. This is how the large components (`CreateEditEventFields`, `IssueDetail`, `CommentSection`, `Comment`) are tested — the pure rules live in tested utils and the component just calls them. - **Test pages through their child components.** Most pages are thin wrappers. Use `shallowMount` + `findComponent(Child)` and assert the props the page passes down (or the events it forwards), rather than rendering the whole tree: ```typescript const search = shallowMount(DiscussionsIndexPage, { global: { stubs: { NuxtLayout: SlotRenderingStub } }, }).findComponent(SearchDiscussions); expect(search.props('isForumScoped')).toBe(false); ``` - Stub layout/slot wrappers (`NuxtLayout`, `FormRow`) with a component that renders the relevant slot, otherwise `shallowMount` swallows slot content. - For pages that call `definePageMeta`, add `vi.stubGlobal('definePageMeta', vi.fn())`. - **Keep `vi.mock` at the module top level.** Vitest hoists `vi.mock` to the top of the file, so calling it inside `beforeEach`/`describe` is misleading and now triggers a deprecation warning. If the mock factory references local variables, prefix their names with `mock` (e.g. `mockThemeRef`) so Vitest allows the hoisted reference. - **One assertion per `it`.** Prefer multiple focused `it` blocks or a single structured `expect` over several assertions in one test. - **Mount the actual component, never a hand-written stand-in.** A spec that defines `const FooTest = { name: 'Foo', template: '...' }` and mounts *that* contributes **zero** coverage to `Foo.vue` and silently drifts from the real component. If a component is hard to mount, mock its dependencies (below) — do not reimplement it in the spec. (`CommentSection.spec.ts` was rewritten for exactly this reason.) - **Mock Apollo at the module level for components that use it.** `mountWithDefaults` does not wire Apollo, so mock `@vue/apollo-composable` in the spec. To exercise a component's mutation callbacks, return a `mutate()` that synchronously fires its registered `onDone` handlers; return an empty `useQuery` result so query-backed composables resolve to their default state: ```typescript vi.mock('@vue/apollo-composable', async () => { const { ref } = await import('vue'); return { useMutation: () => { const done: Array<(p: unknown) => void> = []; return { mutate: vi.fn(() => { done.forEach((cb) => cb({ data: {} })); }), onDone: (cb: (p: unknown) => void) => done.push(cb), onError: vi.fn(), loading: ref(false), error: ref(null), }; }, useQuery: () => ({ result: ref(null), loading: ref(false), error: ref(null), onResult: vi.fn(), onError: vi.fn() }), }; }); ``` - **Seed hoisted mock factories with the *full* return shape.** A mock typed by its initial implementation narrows the return type, so a later `mockReturnValue` with extra fields fails the typecheck. CI's `vue-tsc` job catches this even when the test passes locally, so seed every field up front: ```typescript // ❌ inferred return type is { valid: boolean } — a later // mockReturnValue({ valid: false, message }) fails vue-tsc (TS2353) const isFileSizeValid = vi.fn(() => ({ valid: true })); // ✅ seed the full shape const isFileSizeValid = vi.fn(() => ({ valid: true, message: '' })); ``` - **`@/utils` and `@/utils/index` are the same module.** If you `vi.mock` one, mock both with every export the code under test imports — otherwise the second mock silently overrides the first and drops exports. - **Teleported UI renders into `document.body`, not the wrapper.** Tooltips and modals using `` are outside the component subtree; assert with `document.body.querySelector(...)` rather than `wrapper.find(...)`. - **Mocked GraphQL entities need `__typename`.** Apollo normalizes by `__typename` + key field; a mock entity without `__typename` reaches the component as just its key field (e.g. a channel arrives with only `uniqueName`). This applies to both Apollo unit mocks and the Playwright GraphQL fixtures. ## Additional Resources - [Vitest Documentation](https://vitest.dev/) - [Vue Test Utils Documentation](https://test-utils.vuejs.org/) - [Playwright Documentation](https://playwright.dev/) - Check out existing tests in the codebase for practical examples