Skip to content
Merged
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
44 changes: 43 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ name: CI

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

Expand Down Expand Up @@ -39,6 +38,49 @@ jobs:
- name: Build project
run: npm run build

e2e:
Comment thread
bburda marked this conversation as resolved.
runs-on: ubuntu-latest
timeout-minutes: 20

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Start gateway
run: docker compose -f e2e/docker-compose.yml up -d

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

- name: Run E2E tests
run: npm run test:e2e

- name: Upload Playwright artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report/
test-results/

- name: Dump gateway logs on failure
if: failure()
run: docker compose -f e2e/docker-compose.yml logs

- name: Stop gateway
if: always()
run: docker compose -f e2e/docker-compose.yml down -v

docker-build:
runs-on: ubuntu-latest
timeout-minutes: 15
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,8 @@ dist-ssr

# Serena
.serena/

# Playwright
playwright-report/
test-results/
e2e/.auth/
37 changes: 37 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,43 @@ Before opening or updating a Pull Request, you **must**:
npm run dev
```

> **Note:** `npm run typecheck` runs `tsc --noEmit` against the root `tsconfig.json`, which has no `files` of its own and therefore checks nothing. Type errors are actually caught by `npm run build` (`tsc -b`, which builds the referenced app, node and e2e project configs). Do not trust a green `typecheck` on its own; run `build` before opening a PR.

### Running the End-to-End Suite Locally

The Playwright suite in `e2e/` runs the real UI against a containerised gateway instead of mocks, so it needs Docker.

1. Start the gateway:

```bash
docker compose -f e2e/docker-compose.yml up -d
```

2. Run the suite:

```bash
npm run test:e2e
```

Use `npm run test:e2e:ui` instead to step through the tests with the Playwright UI.

3. Stop the gateway once you are done, dropping the uploads volume along with it:

```bash
docker compose -f e2e/docker-compose.yml down -v
```

`e2e/scripts.spec.ts` uploads, runs and deletes scripts against the shared gateway container, mutating its state as it goes, so it and the other specs that touch the live gateway are pinned to a single Playwright worker (see `playwright.config.ts`). Do not attempt to parallelize these specs or run them against a gateway instance you care about keeping in a known state.

If port 8080 or 5173 is already taken on your machine, override the gateway port and/or the dev server URL before starting the stack:

```bash
E2E_GATEWAY_PORT=8081 docker compose -f e2e/docker-compose.yml up -d
E2E_GATEWAY_PORT=8081 npm run test:e2e
```

`E2E_GATEWAY_PORT` is the only variable you need for the gateway side: `e2e/global-setup.ts` derives the full gateway URL from it, and the gateway's CORS configuration allows any origin so an overridden dev server port is never rejected. Set `E2E_APP_URL` instead (e.g. `E2E_APP_URL=http://localhost:5174`) if the dev server port needs to change; `playwright.config.ts` derives the dev server's port from it. The gateway container stays bound to `127.0.0.1` regardless of the port chosen.

### Pull Request Checklist

Before submitting your PR, ensure:
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ ros2_medkit_web_ui is a lightweight single-page application that connects to a S
- **Entity Tree Sidebar** - Browse the hierarchical structure of SOVD entities with lazy-loading, with a readiness lamp on app and component nodes (a green disc for ready, an amber ring for not ready, a grey square for a readiness the UI has not established). The lamp is re-read while the branch is open, so it tracks an entity that stops or comes back
- **Entity Detail Panel** - View raw JSON details of any selected entity
- **Entity Lifecycle Status Control** - View readiness and request lifecycle transitions (start, restart, force-restart, shutdown, force-shutdown) for apps and components, degrading gracefully on an entity with no lifecycle provider, without taking the entities that have one with it. Actions are gated by the current status (a transition the current status does not allow is marked unavailable and rejected, and stays focusable so the tooltip explaining why reaches a screen reader), and every destructive transition (all but Start) asks for confirmation before dispatch. A transition is only reported as requested when the gateway accepts it; because acceptance is not completion, the readiness is dropped and re-established by the refresh rather than read back straight away
- **Scripts Tab** - List the scripts available on an entity, run one with optional parameters, watch its live status while it executes, see the output once it completes, stop or force-kill a running execution, upload a new script (from a file or written directly in the browser), and delete scripts you no longer need

> **Note:** The Scripts tab only appears for entities whose gateway reports `capabilities.scripts` in `GET /`, and even then only for apps and components - areas and functions never show it regardless of the capability. The gateway sets this when either a script provider plugin is loaded or `scripts.scripts_dir` is configured; a plugin takes precedence over `scripts_dir`, and when one is loaded `scripts_dir` is ignored.

This tool is designed for developers and integrators working with SOVD-compatible systems who need a quick way to explore and debug the entity structure.

Expand Down Expand Up @@ -84,6 +87,13 @@ npm run test:ui
# Run tests with coverage
npm run test:coverage

# Run the end-to-end suite against a containerised gateway
docker compose -f e2e/docker-compose.yml up -d
npm run test:e2e

# Run the end-to-end suite with the Playwright UI
npm run test:e2e:ui

# Format code
npm run format

Expand Down Expand Up @@ -114,7 +124,8 @@ npm run lint
- **shadcn/ui** - UI components
- **Zustand** - State management
- **lucide-react** - Icons
- **Vitest** - Testing framework
- **Vitest** - Unit and component testing framework
- **Playwright** - End-to-end testing against a containerised gateway
- **Prettier** - Code formatting
- **Husky** - Git hooks

Expand Down
44 changes: 44 additions & 0 deletions e2e/dialog-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright 2026 bburda
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { expect, type Dialog, type Page } from '@playwright/test';

/**
* Clicks the (already-visible, uniquely-matched) Delete button for
* `scriptName` and accepts the native `confirm()` dialog that ScriptRow's
* handleDelete requires before it calls deleteScript.
*
* The listener is registered before the click and accepts inline as soon as
* the dialog opens, rather than after awaiting the click: `window.confirm`
* blocks the page's JS (and, with it, the click action itself) until the
* dialog is resolved, so anything that awaits the click before calling
* `dialog.accept()` would deadlock - the click can never settle first.
*
* Asserts the dialog actually appeared, with the expected message, instead
* of accepting whatever dialog (if any) shows up - a bare accept-everything
* handler would still pass the day someone removes the confirmation guard by
* accident.
*/
export async function clickDeleteAndConfirm(page: Page, scriptName: string): Promise<void> {
let seenDialog: Dialog | undefined;
page.once('dialog', async (dialog) => {
seenDialog = dialog;
await dialog.accept();
});

await page.getByRole('button', { name: 'Delete' }).click();

expect(seenDialog?.type()).toBe('confirm');
expect(seenDialog?.message()).toBe(`Delete script "${scriptName}"? This cannot be undone.`);
}
83 changes: 83 additions & 0 deletions e2e/docker-compose.rosbag.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Stack for the rosbag-history specs: a gateway AND a fault manager, so a fault
# can actually own black-box recordings. docker-compose.yml next to this file
# runs a manifest-only gateway with no fault manager at all, which cannot
# produce a single bag; the two scenarios are kept apart rather than merged so
# neither has to carry the other's configuration.

# Its own project: both compose files live in e2e/, so without this they share
# the default project name "e2e" and their `gateway` services replace each other
# instead of running side by side.
name: e2e-rosbag

services:
gateway:
# Overridable because the recording-id contract these specs assert on
# (ros2_medkit#620) is newer than any published tag: point this at a
# locally built image to run them before that lands. Once it is
# published, pin a digest here the way docker-compose.yml does.
image: ${E2E_ROSBAG_GATEWAY_IMAGE:-ghcr.io/selfpatch/ros2_medkit-jazzy:latest}
ports:
# Loopback only, and on its own port so this stack can run alongside
# the scripts one without either stealing the other's.
- '127.0.0.1:${E2E_ROSBAG_GATEWAY_PORT:-8081}:8080'
volumes:
- ./gateway/rosbag-params.yaml:/e2e/params.yaml:ro
- ./gateway/seed_recordings.py:/e2e/seed_recordings.py:ro
- e2e-bags:/e2e-bags
# PID 1 reaps children and forwards signals; without it bash -lc keeps
# PID 1 for itself and `docker compose down` waits out the whole grace
# period before SIGKILLing a fault manager mid-write.
init: true
# Overriding the entrypoint skips /entrypoint.sh, which is what sources
# ROS and exports the RMW default - both have to be restored here.
environment:
RMW_IMPLEMENTATION: ${RMW_IMPLEMENTATION:-rmw_fastrtps_cpp}
entrypoint: ['/bin/bash', '-lc']
# Fault manager, the seeder and the gateway in one container. Not three
# services sharing a network: the default DDS transport uses /dev/shm,
# which is per container, so the seeder's service calls would never
# complete even though discovery says the service is there.
#
# Sourced ONCE in the parent shell, then every process runs as a WATCHED
# background job: `&` binds looser than `&&`, so the earlier
# `source && source && fault_manager & gateway` form left the gateway in
# an unsourced shell ("ros2: command not found", exit 127). `wait -n`
# returns when the FIRST job dies, so a fault manager that cannot open
# its DB or a seeder that raises SystemExit takes the container down
# with its exit code instead of leaving a healthy-looking stack whose
# specs skip. The trap makes SIGTERM stop the children before the shell
# exits.
command:
- >
source /opt/ros/jazzy/setup.bash;
source /home/medkit/ws/install/setup.bash;
ros2 run ros2_medkit_fault_manager fault_manager_node
--ros-args --params-file /e2e/params.yaml &
FM=$!;
python3 /e2e/seed_recordings.py &
SEED=$!;
ros2 run ros2_medkit_gateway gateway_node
--ros-args --params-file /e2e/params.yaml &
GW=$!;
trap 'kill $FM $SEED $GW 2>/dev/null' TERM INT;
wait -n $FM $SEED $GW;
exit $?
depends_on:
init-bags:
condition: service_completed_successfully
# The gateway image runs as uid 999 and a fresh named volume is root-owned,
# so the fault manager could not write a bag into it. Same one-shot chown
# the scripts stack does for its upload volume.
init-bags:
image: ${E2E_ROSBAG_GATEWAY_IMAGE:-ghcr.io/selfpatch/ros2_medkit-jazzy:latest}
user: root
volumes:
- e2e-bags:/e2e-bags
entrypoint: ['chown', '-R', '999:999', '/e2e-bags']

volumes:
# Holds the bags AND faults.db, and it outlives `docker compose down`.
# Re-seed from a clean slate with `down -v` first: on a reused volume the
# fault is already CONFIRMED, the first confirm captures nothing, and the
# suite sees three recordings instead of two.
e2e-bags:
40 changes: 40 additions & 0 deletions e2e/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
services:
# Docker creates a fresh named volume owned by root:root, but the gateway
# image runs as the unprivileged `medkit` user (uid 999) and cannot create
# script subdirectories under a root-owned mount. This one-shot service
# chowns the volume before the gateway starts; it reuses the pinned
# gateway image (which already has chown) instead of pulling another one.
init-uploads:
# ghcr.io/selfpatch/ros2_medkit-jazzy:sha-7939c94
image: ghcr.io/selfpatch/ros2_medkit-jazzy@sha256:565db07e1e972b31684bf864fbaad7e8a70aacabf2ef0cd4510fdbd8e3281831
user: root
volumes:
- e2e-uploads:/e2e-uploads
entrypoint: ['chown', '-R', '999:999', '/e2e-uploads']
gateway:
# Pinned on purpose: :latest is overwritten on every push to the gateway
# main branch, which would let unrelated changes turn this repo CI red.
# Pinned by digest, not by the sha-7939c94 tag alone: tags on this
# registry are mutable and a re-run of the publishing workflow on the
# same commit would move one. ghcr.io/selfpatch/ros2_medkit-jazzy:sha-7939c94
image: ghcr.io/selfpatch/ros2_medkit-jazzy@sha256:565db07e1e972b31684bf864fbaad7e8a70aacabf2ef0cd4510fdbd8e3281831
ports:
# Bound to loopback only, on purpose: this gateway has uploads enabled
# and executes uploaded shell scripts without authentication.
# CONTRIBUTING has developers bring this stack up and leave it running,
# so publishing it on every interface would let anyone else on the same
# network or Wi-Fi execute arbitrary shell on this machine for as long
# as the container is up. Do not drop the `127.0.0.1:` prefix to
# "simplify" this - that reintroduces the exposure.
- '127.0.0.1:${E2E_GATEWAY_PORT:-8080}:8080'
volumes:
- ./gateway/params.yaml:/e2e/params.yaml:ro
- ./gateway/manifest.yaml:/e2e/manifest.yaml:ro
- ./gateway/scripts:/e2e-scripts:ro
- e2e-uploads:/e2e-uploads
command: ['--ros-args', '--params-file', '/e2e/params.yaml']
depends_on:
init-uploads:
condition: service_completed_successfully
volumes:
e2e-uploads:
16 changes: 16 additions & 0 deletions e2e/fixtures/uploaded-script.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Copyright 2026 bburda
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
set -eu
echo "uploaded script executed"
36 changes: 36 additions & 0 deletions e2e/gateway/manifest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
manifest_version: '1.0'
components:
- id: 'ecu'
name: 'Test ECU'
apps:
- id: 'talker'
name: 'Talker'
is_located_on: 'ecu'
scripts:
- id: 'hello'
name: 'Hello'
description: 'Echoes the parameters it receives on stdin'
path: '/e2e-scripts/hello.sh'
format: 'bash'
timeout_sec: 30
entity_filter:
- 'ecu'
- 'talker'
- id: 'failing'
name: 'Failing'
description: 'Exits with a non-zero code'
path: '/e2e-scripts/fail.sh'
format: 'bash'
timeout_sec: 30
entity_filter:
- 'ecu'
- id: 'sleeper'
name: 'Sleeper'
description: 'Runs long enough to be stopped'
path: '/e2e-scripts/sleep.sh'
format: 'bash'
# Matches sleep.sh's own sleep duration - see the comment there for why
# this is kept short rather than generously long.
timeout_sec: 30
entity_filter:
- 'ecu'
23 changes: 23 additions & 0 deletions e2e/gateway/params.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**:
ros__parameters:
server:
host: '0.0.0.0'
port: 8080
cors:
# '*' rather than a hardcoded 'http://localhost:5173': playwright.config.ts
# and e2e/global-setup.ts both derive the dev server origin from
# E2E_APP_URL, so overriding that variable (e.g. to dodge a busy port)
# would otherwise leave the browser talking to an origin this gateway
# never allowed, failing every request with no
# Access-Control-Allow-Origin header and no mention of CORS anywhere
# in the symptom. This is a throwaway local/CI fixture with
# allow_credentials left at its default false, so a wildcard origin
# carries none of the risk it would in a real deployment.
allowed_origins:
- '*'
discovery:
mode: 'manifest_only'
manifest_path: '/e2e/manifest.yaml'
scripts:
scripts_dir: '/e2e-uploads'
allow_uploads: true
Loading
Loading