Skip to content
Open
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
48 changes: 48 additions & 0 deletions .github/workflows/linttest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,53 @@ jobs:
- name: Install Dependencies
run: uv sync --extra test

- name: Check generated API schema is up to date
run: |
uv run python scripts/export_api_schema.py
git diff --exit-code frontend/src/api/generated/schema.json \
|| (echo "::error::schema.json is stale — run 'uv run python scripts/export_api_schema.py' and commit." && exit 1)

- name: Run Pytest
run: uv run pytest -n auto

# This will typecheck, lint and test the React frontend
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend

steps:
- name: Checkout
uses: actions/checkout@v6

- name: Install Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install Dependencies
run: npm ci

- name: Typecheck (tsc)
run: npm run typecheck

- name: Lint (eslint)
run: npm run lint

- name: Check generated API types are up to date
run: |
npm run generate:types
git diff --exit-code src/api/generated/types.ts \
|| (echo "::error::generated types.ts is stale — run 'npm run generate:types' and commit." && exit 1)

- name: Unit tests (vitest)
run: npm run test

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

- name: End-to-end tests (playwright)
run: npm run test:e2e
18 changes: 15 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
FROM node:24-slim AS frontend
WORKDIR /frontend/
COPY ./frontend/package.json ./frontend/package-lock.json /frontend/
RUN npm ci
COPY ./frontend /frontend
RUN npm run build

FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:0.11.16 /uv /bin/uv
WORKDIR /app/

RUN apt update && apt install --no-install-recommends -y git && rm -rf /var/lib/apt/lists/*
Expand All @@ -9,13 +17,17 @@ RUN --mount=type=secret,id=github_pat,uid=50000 git config --global url."https:/
# python conf
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0

# Copy src
COPY ./src /app/src
COPY ./pyproject.toml /app/pyproject.toml
COPY ./pyproject.toml ./uv.lock ./README.md /app/
COPY --from=frontend /frontend/dist /app/frontend/dist

# Install deps
RUN pip install --no-cache-dir -e /app && pip install --no-cache-dir uvicorn
# Install deps from the lockfile (reproducible — honours uv.lock pins)
RUN uv sync --locked && uv pip install uvicorn
ENV PATH="/app/.venv/bin:$PATH"

# Entrypoint
CMD ["uvicorn", "--host", "0.0.0.0", "--port", "8080", "--workers", "1", "--interface", "wsgi", "cactus_ui.server:app"]
61 changes: 55 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,62 @@
# cactus-ui
User interface for the csip-aus test harness

# Local development
Create a conda/virtual environment and run `uv sync --all-extras`
User interface for the CSIP-Aus test harness.

ssh port forward the cactus orchestrator machine (match with CACTUS_ORCHESTRATOR_BASEURL .env)
A Vite + React 19 + TypeScript SPA (`frontend/`) served by a Flask backend-for-frontend
(`src/cactus_ui/`). Flask owns auth/session and exposes JSON under `/api/...`; React
handles everything the user sees.

run the server.py file to host the UI flask server on your local machine, using the real orchestrator VM
## Frontend

All commands run from `frontend/`.

```bash
cd frontend
npm install # first time, and after package.json changes
```

### Look at the UI locally

```bash
# No backend needed — MSW serves checked-in fixtures
npm run dev:mock # http://localhost:5173

# Against the real Flask BFF — needs the cactus-ui-dev service + orchestrator tunnel up.
npm run dev # http://localhost:5173, proxies /api etc. to Flask :3000
```
uv run flask -A cactus_ui.server:app run

### Run tests

```bash
npm run test # Vitest component tests (one-shot)
npm run test:watch # Vitest in watch mode while developing
npm run test:e2e # Playwright end-to-end (real browser)
```

### Checks before pushing

```bash
npm run typecheck # tsc -b
npm run lint # eslint
npm run test
```

### Production build

Outputs static files to `frontend/dist/`, which Flask serves (this is what the Dockerfile
runs):

```bash
npm run build # tsc -b && vite build
npm run preview # optional: serve the built dist/ to sanity-check it
```

## Backend
In normal use the Flask app runs as the `cactus-ui-dev` systemd service — you don't need
to start it by hand. For Python work:

```bash
uv sync --all-extras # from the repo root
uv run pytest tests/unit/... # run only the relevant test files
uv run ruff check && uv run ty check
```
6 changes: 6 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
dist
playwright-report
test-results
e2e/screenshots
*.tsbuildinfo
7 changes: 7 additions & 0 deletions frontend/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
dist
node_modules
public/mockServiceWorker.js
playwright-report
test-results
package-lock.json
src/api/generated
6 changes: 6 additions & 0 deletions frontend/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"printWidth": 100,
"singleQuote": true,
"semi": true,
"trailingComma": "es5"
}
19 changes: 19 additions & 0 deletions frontend/e2e/home.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { expect, test } from '@playwright/test';

// Runs against `npm run dev:mock` (MSW serving checked-in fixtures, no backend).

test('home page renders for a logged-in session', async ({ page }) => {
await page.goto('/');

await expect(page.getByRole('heading', { name: 'Welcome to CACTUS' })).toBeVisible();
await expect(page.getByText('User: Test User')).toBeVisible();
for (const link of ['Procedures', 'Runs', 'Playlists', 'Config']) {
await expect(page.getByRole('link', { name: link, exact: true })).toBeVisible();
}
await expect(page.getByRole('link', { name: 'Logout' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Admin', exact: true })).not.toBeVisible();
await expect(page.getByRole('heading', { name: 'Getting Started' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Common Issues' })).toBeVisible();

await page.screenshot({ path: 'e2e/screenshots/home.png', fullPage: true });
});
55 changes: 55 additions & 0 deletions frontend/e2e/playlists.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { expect, test } from '@playwright/test';

// Runs against `npm run dev:mock` (MSW serving checked-in fixtures, no backend).

test('/playlists redirects to the first run group and shows the builder', async ({ page }) => {
await page.goto('/playlists');

await expect(page).toHaveURL('/group/1/playlists');
await expect(page).toHaveTitle('Playlists - CACTUS');
await expect(page.getByRole('button', { name: 'Battery Mk1' })).toBeVisible();
await expect(page.getByText('Test Library')).toBeVisible();
await expect(page.getByText('No tests selected.')).toBeVisible();
await expect(page.getByRole('button', { name: 'Start Playlist' })).toBeDisabled();

await page.screenshot({ path: 'e2e/screenshots/playlists.png', fullPage: true });
});

test('building a queue enables Start Playlist', async ({ page }) => {
await page.goto('/group/1/playlists');

await page.getByRole('button', { name: /ALL-01/ }).first().click();

await expect(page.getByRole('button', { name: /Remove ALL-01/ })).toBeVisible();
await expect(page.getByRole('button', { name: 'Start Playlist' })).toBeEnabled();

await page.screenshot({ path: 'e2e/screenshots/playlists-queue.png', fullPage: true });
});

test('shows active and past playlist sessions', async ({ page }) => {
await page.goto('/group/1/playlists');

await expect(page.getByText('Active Playlist')).toBeVisible();
await expect(page.getByRole('link', { name: 'active-e' })).toHaveAttribute('href', '/run/201');
await expect(page.getByRole('link', { name: /Go to run/ })).toHaveAttribute('href', '/run/202');

await expect(page.getByText('Past Sessions')).toBeVisible();
await expect(page.getByRole('link', { name: 'past-exe' })).toHaveAttribute('href', '/run/150');
});

test('NavBar navigates to playlists client-side', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Welcome to CACTUS' })).toBeVisible();

let sawNavigationRequest = false;
page.on('request', (request) => {
if (request.isNavigationRequest() && request.url().includes('/playlists')) {
sawNavigationRequest = true;
}
});

await page.getByRole('link', { name: 'Playlists', exact: true }).click();

await expect(page.getByRole('button', { name: 'Battery Mk1' })).toBeVisible();
expect(sawNavigationRequest).toBe(false);
});
42 changes: 42 additions & 0 deletions frontend/e2e/procedure-yaml.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { expect, test } from '@playwright/test';
import proceduresFixture from '../fixtures/procedures.json' with { type: 'json' };

// Runs against `npm run dev:mock` (MSW serving checked-in fixtures, no backend).

test('procedure yaml page renders the highlighted definition', async ({ page }) => {
await page.goto('/procedure/ALL-01');

await expect(page.getByRole('heading', { name: 'Test Procedure ALL-01' })).toBeVisible();
await expect(page).toHaveTitle('Procedures - CACTUS');

await expect(page.getByRole('link', { name: 'CACTUS Test Definitions' })).toHaveAttribute(
'href',
'https://github.com/bsgip/cactus-test-definitions'
);

const code = page.locator('code.language-yaml');
await expect(code).toContainText('Discovery with Out-of-Band Registration');
// highlight.js produced markup
await expect(code.locator('.hljs-attr').first()).toBeVisible();

await page.screenshot({ path: 'e2e/screenshots/procedure-yaml.png', fullPage: true });
});

test('procedures table links to the yaml page client-side', async ({ page }) => {
await page.goto('/procedures');
const first = proceduresFixture.procedures[0];

let sawNavigationRequest = false;
page.on('request', (request) => {
if (request.isNavigationRequest() && request.url().includes('/procedure/')) {
sawNavigationRequest = true;
}
});

await page.getByRole('link', { name: first.test_procedure_id, exact: true }).click();

await expect(
page.getByRole('heading', { name: `Test Procedure ${first.test_procedure_id}` })
).toBeVisible();
expect(sawNavigationRequest).toBe(false);
});
39 changes: 39 additions & 0 deletions frontend/e2e/procedures.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { expect, test } from '@playwright/test';
import proceduresFixture from '../fixtures/procedures.json' with { type: 'json' };

// Runs against `npm run dev:mock` (MSW serving checked-in fixtures, no backend).

test('procedures page lists every procedure from the fixture', async ({ page }) => {
await page.goto('/procedures');

await expect(page.getByRole('heading', { name: 'Test Procedures' })).toBeVisible();
await expect(page).toHaveTitle('Procedures - CACTUS');

// Header row + one row per procedure
await expect(page.getByRole('row')).toHaveCount(proceduresFixture.procedures.length + 1);

const first = proceduresFixture.procedures[0];
await expect(
page.getByRole('link', { name: first.test_procedure_id, exact: true })
).toHaveAttribute('href', `/procedure/${first.test_procedure_id}`);

await page.screenshot({ path: 'e2e/screenshots/procedures.png', fullPage: true });
});

test('NavBar navigates to procedures client-side', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Welcome to CACTUS' })).toBeVisible();

// A client-side navigation must not trigger a document load
let sawNavigationRequest = false;
page.on('request', (request) => {
if (request.isNavigationRequest() && request.url().includes('/procedures')) {
sawNavigationRequest = true;
}
});

await page.getByRole('link', { name: 'Procedures', exact: true }).click();

await expect(page.getByRole('heading', { name: 'Test Procedures' })).toBeVisible();
expect(sawNavigationRequest).toBe(false);
});
49 changes: 49 additions & 0 deletions frontend/e2e/run-status.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect, test } from '@playwright/test';

// Runs against `npm run dev:mock` (MSW serving checked-in fixtures, no backend). The default
// run-status fixtures describe a live "started" run with timeline data, so the chart renders
// against a real canvas here (jsdom can't, so this is the only place the canvas is exercised).

test('live run status page renders the header, panels and timeline chart', async ({ page }) => {
await page.goto('/run/123');

await expect(page).toHaveTitle('Run Status 123 - CACTUS');
await expect(page.getByRole('heading', { name: 'Run 123 (started)' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Finalise' })).toBeEnabled();

// Live status panels.
await expect(page.getByText('Precondition Checks')).toBeVisible();
await expect(page.getByText('Current Criteria')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Timeline' })).toBeVisible();

// The timeline renders two real <canvas> elements (main chart + activity strip).
await expect(page.locator('canvas')).toHaveCount(2);
await expect(page.locator('canvas').first()).toBeVisible();

await page.screenshot({ path: 'e2e/screenshots/run-status-live.png', fullPage: true });
});

test('admin run status view disables the finalise control', async ({ page }) => {
await page.goto('/admin/run/123');

await expect(page.getByRole('heading', { name: 'Run 123 (started)' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Finalise' })).toBeDisabled();
await expect(page.getByRole('heading', { name: 'Timeline' })).toBeVisible();
});

test('runs table links navigate to the run status page client-side', async ({ page }) => {
await page.goto('/group/1/runs');

let sawNavigationRequest = false;
page.on('request', (request) => {
if (request.isNavigationRequest() && request.url().includes('/run/')) {
sawNavigationRequest = true;
}
});

await page.getByRole('link', { name: '123', exact: true }).click();

await expect(page).toHaveURL('/run/123');
await expect(page.getByRole('heading', { name: 'Run 123 (started)' })).toBeVisible();
expect(sawNavigationRequest).toBe(false);
});
Loading
Loading