diff --git a/AGENTS.md b/AGENTS.md
index 6f61439..339a4d8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,6 +12,7 @@
| Area | Choice |
| -------------------- | ------------------------------------------------------------------------- |
+| Runtime | Node.js 22 or newer (`engines` in package.json) |
| Frontend | React, TypeScript, Vite, React Router |
| API | GraphQL (custom backend, Node + TypeScript, Apollo Server) |
| GraphQL client | Apollo Client |
@@ -52,9 +53,13 @@ npm run lint
# Format
npm run format:check
-# Run unit/component tests (Vitest, watch mode)
+# Run all tests once: frontend (Vitest) and backend (node --test), as CI does.
+# No running backend or database is needed; component tests mock Apollo.
npm run test
+# Re-run frontend tests on every file change while developing
+npm run test:watch
+
# Run end-to-end tests
# TODO: e2e tool not chosen yet
```
diff --git a/backend/package.json b/backend/package.json
index 4501772..dcb0cc0 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -8,7 +8,7 @@
"build": "tsc -p .",
"start": "node --env-file-if-exists=.env dist/index.js",
"lint": "eslint .",
- "test": "node --test \"src/**/*.test.ts\""
+ "test": "tsx --test \"src/**/*.test.ts\""
},
"devDependencies": {
"@eslint/js": "^10.0.1",
diff --git a/frontend/package.json b/frontend/package.json
index 2200f48..ca3111f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -8,7 +8,8 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
- "test": "vitest"
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
"@apollo/client": "^4.3.2",
diff --git a/frontend/src/pages/SubmitSightingPage.tsx b/frontend/src/pages/SubmitSightingPage.tsx
index 13de282..9491a77 100644
--- a/frontend/src/pages/SubmitSightingPage.tsx
+++ b/frontend/src/pages/SubmitSightingPage.tsx
@@ -95,6 +95,9 @@ function SubmitSightingPage() {
const [submittedId, setSubmittedId] = useState
Please correct the highlighted fields.
diff --git a/frontend/src/tests/SearchPage.test.tsx b/frontend/src/tests/SearchPage.test.tsx index 9e4715e..ea23cf1 100644 --- a/frontend/src/tests/SearchPage.test.tsx +++ b/frontend/src/tests/SearchPage.test.tsx @@ -2,7 +2,7 @@ import { ApolloClient, ApolloLink } from '@apollo/client' import { ApolloProvider } from '@apollo/client/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter } from 'react-router' -import { of, throwError } from 'rxjs' +import { delay, of, throwError } from 'rxjs' import { describe, expect, it, vi } from 'vitest' import { mockSightings } from './mock-sightings' import { createCache } from '../graphql/client' @@ -22,13 +22,17 @@ vi.mock('../components/SightingMap', () => ({ * Like the real backend, the cursor is the offset of the next row. Pages * hold 5 sightings regardless of `first`, so the 12 mock sightings span * three pages. `failRequest` makes that request (1 = the first) fail like - * an unreachable backend. + * an unreachable backend. `latencyMs` delays every answer like a real + * network, so states that only exist while waiting can be seen. */ const FAKE_PAGE_SIZE = 5 function renderSearchPage( initialEntry = '/', - { failRequest }: { failRequest?: number } = {}, + { + failRequest, + latencyMs = 0, + }: { failRequest?: number; latencyMs?: number } = {}, ) { const requests: SightingsQueryVariables[] = [] const link = new ApolloLink((operation) => { @@ -61,7 +65,7 @@ function renderSearchPage( totalCount: matches.length, }, }, - }) + }).pipe(delay(latencyMs)) }) const client = new ApolloClient({ link, cache: createCache() }) @@ -208,4 +212,56 @@ describe('SearchPage', () => { ).toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) + + describe('result states with a slow backend', () => { + const LATENCY_MS = 100 + + it('shows a loading message until the first results arrive', async () => { + renderSearchPage('/', { latencyMs: LATENCY_MS }) + + expect(screen.getByText('Loading sightings…')).toBeInTheDocument() + // The header is a live region, so screen readers hear it too. + expect(screen.getByText('Loading…')).toBeInTheDocument() + expect(screen.queryByText(/No sightings match/)).not.toBeInTheDocument() + + expect( + await screen.findByText('Showing 5 of 12 results'), + ).toBeInTheDocument() + expect(screen.queryByText('Loading sightings…')).not.toBeInTheDocument() + }) + + it('explains when a search has no matches', async () => { + renderSearchPage('/?q=atlantis', { latencyMs: LATENCY_MS }) + + expect( + await screen.findByText(/No sightings match your search/), + ).toBeInTheDocument() + expect(screen.getByText('0 results')).toBeInTheDocument() + expect(screen.queryByRole('list')).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Load more' }), + ).not.toBeInTheDocument() + }) + + it('keeps the previous results visible but busy while a new search loads', async () => { + renderSearchPage('/', { latencyMs: LATENCY_MS }) + await screen.findByText('Showing 5 of 12 results') + + fireEvent.change(screen.getByRole('searchbox'), { + target: { value: 'oslo' }, + }) + + // After the debounce the new request is in flight: the old list stays + // instead of flashing to a loading message. + await waitFor(() => + expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'true'), + ) + expect(screen.getAllByRole('link')).toHaveLength(5) + expect(screen.queryByText('Loading sightings…')).not.toBeInTheDocument() + + expect(await screen.findByText('1 result')).toBeInTheDocument() + expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'false') + expect(screen.getAllByRole('link')).toHaveLength(1) + }) + }) }) diff --git a/frontend/src/tests/SubmitSightingPage.test.tsx b/frontend/src/tests/SubmitSightingPage.test.tsx index 4c84245..b7080be 100644 --- a/frontend/src/tests/SubmitSightingPage.test.tsx +++ b/frontend/src/tests/SubmitSightingPage.test.tsx @@ -1,20 +1,29 @@ +import { ApolloProvider } from '@apollo/client/react' import { MockedProvider, type MockedProviderProps, } from '@apollo/client/testing/react' -import { InMemoryCache } from '@apollo/client' +import { ApolloClient, ApolloLink, InMemoryCache } from '@apollo/client' import { GraphQLError } from 'graphql' -import { fireEvent, render, screen } from '@testing-library/react' -import { MemoryRouter } from 'react-router' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router' +import { delay, of } from 'rxjs' import { describe, expect, it, vi } from 'vitest' +import Banner from '../components/Banner' +import { createCache } from '../graphql/client' import { CREATE_SIGHTING_MUTATION } from '../graphql/mutations' import { SIGHTINGS_QUERY } from '../graphql/queries' +import SearchPage from '../pages/SearchPage' import SubmitSightingPage from '../pages/SubmitSightingPage' +import { mockSightings } from './mock-sightings' -// Leaflet needs a real browser layout, and the map is not under test here. +// Leaflet needs a real browser layout, and the maps are not under test here. vi.mock('../components/LocationPicker', () => ({ default: () => , })) +vi.mock('../components/SightingMap', () => ({ + default: () => , +})) const input = { datetime: '2024-03-10T21:15', @@ -57,6 +66,11 @@ function renderPage( } function fillForm() { + fillFields() + fireEvent.click(screen.getByRole('button', { name: 'Submit sighting' })) +} + +function fillFields() { const fields: [string, string][] = [ ['Date and time *', input.datetime], ['City *', input.city], @@ -70,7 +84,62 @@ function fillForm() { for (const [label, value] of fields) { fireEvent.change(screen.getByLabelText(label), { target: { value } }) } - fireEvent.click(screen.getByRole('button', { name: 'Submit sighting' })) +} + +/** + * A fake backend that behaves like the database: createSighting stores + * the sighting, and later `sightings` queries return it first. It records + * the name of every request, so tests can count them. `latencyMs` delays + * every answer like a real network. + */ +function renderWithFakeDatabase( + initialEntry = '/submit', + { latencyMs = 0 } = {}, +) { + const operations: string[] = [] + const database = [...mockSightings] + + const link = new ApolloLink((operation) => { + operations.push(operation.operationName ?? 'unknown') + + if (operation.operationName === 'CreateSighting') { + database.unshift(created) + return of({ data: { createSighting: created } }).pipe(delay(latencyMs)) + } + + return of({ + data: { + sightings: { + __typename: 'SightingConnection', + edges: database.map((node) => ({ + __typename: 'SightingEdge', + cursor: node.id, + node: { __typename: 'Sighting', ...node }, + })), + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + endCursor: null, + }, + totalCount: database.length, + }, + }, + }).pipe(delay(latencyMs)) + }) + const client = new ApolloClient({ link, cache: createCache() }) + + render( +