Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
**/.ruff_cache
**/.mypy_cache
backend/tests
**/.coverage
**/htmlcov
**/coverage
backend/pytest.xml
backend/pytest-coverage.txt
**/*.log
**/.DS_Store
.env
Expand Down
28 changes: 28 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ on:
- 'backend/alembic.ini'
- 'backend/pyproject.toml'
- 'backend/uv.lock'
- 'frontend/src/**'
- 'frontend/package.json'
- 'frontend/bun.lock'
- 'frontend/vitest.config.ts'
- 'frontend/tsconfig*.json'
- 'docs/api/openapi.yml'
- '.github/workflows/test.yml'
push:
branches: [main]
Expand Down Expand Up @@ -78,3 +84,25 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
- name: OpenAPI spec is in sync with FastAPI app
run: uv run python -m scripts.dump_openapi --check

frontend-vitest:
name: Frontend vitest
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- name: Run vitest with coverage
# CI=true switches reporters to include junit
run: bunx vitest run --coverage
- name: Coverage report
if: always()
uses: davelosert/vitest-coverage-report-action@v2
with:
working-directory: frontend
name: Frontend coverage
# default 'pr' no-ops on push, but be explicit to mirror backend job
comment-on: ${{ github.event_name == 'pull_request' && 'pr' || 'none' }}
10 changes: 10 additions & 0 deletions frontend/.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ dist
.env
.env.*
coverage
junit.xml
*.log
.DS_Store
.vite

# tests never reach the runtime nginx image, exclude from build context too
src/test
**/__tests__
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
vitest.config.ts
3 changes: 3 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ dist
dist-ssr
*.local

coverage
junit.xml

# Editor directories and files
.vscode/*
!.vscode/extensions.json
Expand Down
39 changes: 36 additions & 3 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,47 @@ Starts a Prism mock server from the OpenAPI spec and proxies to it automatically

## Frontend Tests

Frontend smoke tests use Vitest, React Testing Library, and Prism.
Frontend tests use Vitest, React Testing Library, and stubbed `fetch`/`EventSource`.
The structure mirrors the backend test suite: shared fixtures and factories live
alongside the test files and exercise the same flows the running app does.

The test command starts a Prism mock API from the OpenAPI specification at `docs/api/openapi.yml`, then runs the frontend test suite.
| Layer | Backend equivalent | Frontend equivalent |
| ------------ | --------------------------------- | --------------------------------------------------------- |
| Test runner | `pytest` | `vitest run` |
| HTTP harness | `httpx.AsyncClient` over ASGI app | `mockFetch` helper + `@testing-library/react` |
| Streams | aiomqtt test stubs | `FakeEventSource` mock with `emit`/`emitOpen`/`emitError` |
| Fixtures | `tests/conftest.py` | `src/test/setup.ts`, `src/test/helpers.tsx` |
| Factories | `tests/_helpers.py` | `makeBerth`, `makeUser`, `makeEvent`, `buildAuthContext` |
| Coverage | `pytest-cov` (`fail_under = 80`) | `@vitest/coverage-v8` (`thresholds.lines = 80`) |

Tests live next to the source they exercise:

```
src/
__tests__/ app + HarborMap entry tests
components/__tests__/ UI panels + layout chrome
hooks/__tests__/ data hooks (fake timers + mocked fetch/EventSource)
lib/__tests__/ pure helpers
pages/__tests__/ page components (Dashboard, Settings)
test/ shared setup, factories, EventSource mock
```

### Run tests locally

```bash
cd frontend
bun install
bun run test
bun run test # full suite (boots Prism alongside vitest, mirrors CI)
bun run test:watch # vitest watch mode, no Prism
bun run test:cov # full suite with v8 coverage report
```

### Writing new tests

- Reach for `renderWithAuthLayout` whenever a component uses `useOutletContext`.
- Use `mockFetch((url, init) => ...)` to replace the network layer; combine with
`jsonResponse` / `errorResponse` from `src/test/helpers.tsx`.
- For SSE-driven hooks, drive the `FakeEventSource` via `getLastEventSource()`
and call `emit("berth.update", payload)`.
- Keep factory builders in sync with `src/api-types.ts` — when a schema changes,
the type checker catches stale fixtures.
31 changes: 30 additions & 1 deletion frontend/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"format": "biome format --write .",
"gen:api": "openapi-typescript ../docs/api/openapi.yml -o src/api-types.ts",
"test": "bunx concurrently -k -s first -n prism,vitest \"bunx @stoplight/prism-cli mock ../docs/api/openapi.yml -p 4010\" \"vitest run\"",
"test:watch": "vitest"
"test:watch": "vitest",
"test:cov": "bunx concurrently -k -s first -n prism,vitest \"bunx @stoplight/prism-cli mock ../docs/api/openapi.yml -p 4010\" \"vitest run --coverage\""
},
"dependencies": {
"@fontsource-variable/jost": "^5.2.8",
Expand Down Expand Up @@ -46,6 +47,7 @@
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"concurrently": "^9.2.1",
"jsdom": "^29.0.2",
"openapi-typescript": "^7.13.0",
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import { App } from "../App";

vi.mock("../components/layout/MainLayout", () => ({
MainLayout: () => <div data-testid="main-layout" />,
}));

describe("App", () => {
test("renders MainLayout for a marina route", async () => {
window.history.pushState({}, "", "/saltsjobaden");
render(<App />);
expect(await screen.findByTestId("main-layout")).toBeInTheDocument();
});

test("redirects root path to /saltsjobaden", async () => {
window.history.pushState({}, "", "/");
render(<App />);
await screen.findByTestId("main-layout");
expect(window.location.pathname).toBe("/saltsjobaden");
});
});
83 changes: 83 additions & 0 deletions frontend/src/__tests__/HarborMap.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Outlet, Route, Routes } from "react-router-dom";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
buildAuthContext,
errorResponse,
jsonResponse,
makeBerth,
makeUser,
mockFetch,
} from "../test/helpers";

vi.mock("../svgMap", () => ({
SvgMap: ({ onBerthClickCB }: { onBerthClickCB?: (id: string) => void }) => (
<button
type="button"
data-testid="svg-map"
onClick={() => onBerthClickCB?.("B1")}
>
svg
</button>
),
}));

vi.mock("../svg", () => ({
mapBerthIds: new Set(["B1", "B2"]),
berthSlots: {},
}));

import { DashboardLayoutProvider } from "../components/layout/DashboardLayoutContext";
import { HarborMap } from "../HarborMap";

function renderHarborMap(
authOverrides: Parameters<typeof buildAuthContext>[0] = {},
) {
const ctx = buildAuthContext(authOverrides);
return render(
<MemoryRouter initialEntries={["/saltsjobaden"]}>
<DashboardLayoutProvider userRole={authOverrides.user?.role}>
<Routes>
<Route path="/:marinaSlug" element={<Outlet context={ctx} />}>
<Route index element={<HarborMap />} />
</Route>
</Routes>
</DashboardLayoutProvider>
</MemoryRouter>,
);
}

describe("HarborMap", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

test("visitor view renders HarborOverview with snapshot data", async () => {
mockFetch(() => jsonResponse([makeBerth({ berth_id: "B1" })]));
renderHarborMap();
expect(await screen.findByText("Harbor Overview")).toBeInTheDocument();
});

test("harbormaster view renders Harbor Master HUD", async () => {
mockFetch(() => jsonResponse([makeBerth({ berth_id: "B1" })]));
renderHarborMap({ user: makeUser({ role: "harbormaster" }) });
expect(await screen.findByText("Harbor Master HUD")).toBeInTheDocument();
});

test("clicking a berth opens the detail panel", async () => {
mockFetch((url) => {
if (url.endsWith("/api/berths"))
return jsonResponse([makeBerth({ berth_id: "B1" })]);
if (url.includes("/api/berths/B1"))
return jsonResponse(makeBerth({ berth_id: "B1", label: "1" }));
return errorResponse(404);
});
const user = userEvent.setup();
renderHarborMap({ user: makeUser({ role: "boat_owner" }) });
await user.click(await screen.findByTestId("svg-map"));
await waitFor(() =>
expect(screen.getByText("Berth Detail")).toBeInTheDocument(),
);
});
});
Loading
Loading