The e2e suite names its own origins, and refuses a live instance
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s

`tests/utils/api.ts` took the API origin from `VITE_API_URL`, which
`tests/config.ts` loads out of `app/.env`. In a checkout configured for a
deployment that names the deployment — so the browser went to the local stack
while every setup and teardown call, `deleteAll` included, went to the live
one. `privateApi.ts` had the same reading, and it creates users.

Both origins now come from one place: `PLAYWRIGHT_BASE_URL`, with the API
derived from it (`app.<domain>` → `api.<domain>`) or named outright by
`PLAYWRIGHT_API_URL`, which is what CI and the compose service set. Nothing in
the suite reads `VITE_API_URL` any more.

Belt and braces, since a stack served under a real domain answers to the same
names its production instance does: a global setup resolves both origins and
refuses anything that is not loopback or a private range, before a test runs.
`PLAYWRIGHT_ALLOW_PUBLIC=1` says you meant it.

`make test-frontend` is now that safe run — the Playwright image on the proxy
network with both names mapped onto Traefik by address, as the host user so it
does not leave root-owned results behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUb8YpL2s3gmN9WTACTt4q
This commit is contained in:
2026-08-20 20:46:04 +02:00
co-authored by Claude Opus 5
parent 032c2e3ae6
commit a2e11b61cf
10 changed files with 142 additions and 12 deletions
+24 -2
View File
@@ -123,8 +123,30 @@ DB_HOST = $(shell docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAdd
test-backend: ## Run backend tests (pytest + coverage) test-backend: ## Run backend tests (pytest + coverage)
cd backend && $(if $(DB_HOST),POSTGRES_SERVER=$(DB_HOST)) uv run bash scripts/tests-start.sh cd backend && $(if $(DB_HOST),POSTGRES_SERVER=$(DB_HOST)) uv run bash scripts/tests-start.sh
test-frontend: ## Run frontend tests (Playwright e2e) # The hostname the stack is served under. A checkout configured for a
cd frontend && bunx playwright test # deployment carries the deployment's domain, and *.fluksio.com resolves to the
# live instance from here — which is why the run below maps both names onto the
# local Traefik by address and never lets DNS decide.
DOMAIN = $(shell sed -n 's/^DOMAIN=//p' $(COMPOSE_ROOT)/.env | head -1)
PW_VERSION = $(shell sed -n 's/.*"@playwright\/test": "[^0-9]*\([0-9.]*\)".*/\1/p' frontend/package.json | head -1)
test-frontend: ## Run frontend tests (Playwright e2e) against the local stack
@ip=$$(docker network inspect proxy \
--format '{{range .Containers}}{{if eq .Name "fluksio-app-proxy-1"}}{{.IPv4Address}}{{end}}{{end}}' \
2>/dev/null | cut -d/ -f1); \
[ -n "$$ip" ] || { echo " ✗ proxy network or Traefik container not found — is the stack up?"; exit 1; }; \
docker run --rm --network proxy --ipc=host \
--user $$(id -u):$$(id -g) -e HOME=/tmp \
--add-host app.$(DOMAIN):$$ip --add-host api.$(DOMAIN):$$ip \
-v $(COMPOSE_ROOT):/app -w /app/frontend \
-e PLAYWRIGHT_BASE_URL=http://app.$(DOMAIN) \
-e PLAYWRIGHT_API_URL=http://api.$(DOMAIN) \
-e HOST_RESOLVER_RULES="MAP app.$(DOMAIN) $$ip, MAP api.$(DOMAIN) $$ip" \
-e CI=$${CI:-1} \
-e FIRST_SUPERUSER="$$(sed -n 's/^FIRST_SUPERUSER=//p' $(COMPOSE_ROOT)/.env | head -1)" \
-e FIRST_SUPERUSER_PASSWORD="$$(sed -n 's/^FIRST_SUPERUSER_PASSWORD=//p' $(COMPOSE_ROOT)/.env | head -1)" \
mcr.microsoft.com/playwright:v$(PW_VERSION)-noble \
npx playwright test $(PLAYWRIGHT_ARGS)
# Load and chaos against a *running* stack, never part of `make test`: it # Load and chaos against a *running* stack, never part of `make test`: it
# restarts this stack's containers. `SOAK_ARGS="--dry-run"` only looks. # restarts this stack's containers. `SOAK_ARGS="--dry-run"` only looks.
+6 -3
View File
@@ -12,6 +12,9 @@ Deferring because out of scope is fine, but don't mention deferring than.
### To be sorted ### To be sorted
- FEAT/UI center a node when being selected, so that the panel does not overdraw the node
- FEAT/UI add (multi-)select to the flows and dashboards view to allow deleting (multiple) items; long press to select -> "Add" button should change into "Trash" icon button
- BUG/UI remove the zoom in/zoom out buttons from the toolbar; either we have a mouse or use touch gesture for that
- BUG/UI in the brain view: make the chasing circle animation running entirely in the gap between the ring and the node (using the full width) - BUG/UI in the brain view: make the chasing circle animation running entirely in the gap between the ring and the node (using the full width)
- FEAT/UI add animation to widgets; i.e. status of bars, gauges etc. should fade from one state to another. Multi-buttons (like "Mode" in the "Home" dashboard of the demo) should transition from one state to another; use inspiration for animations based on the google material guidelines - FEAT/UI add animation to widgets; i.e. status of bars, gauges etc. should fade from one state to another. Multi-buttons (like "Mode" in the "Home" dashboard of the demo) should transition from one state to another; use inspiration for animations based on the google material guidelines
- BUG when clicking "edit" in the "Home" dashboard of the demo on hub.fluksio.com, most of the panels disappear (only a handfull is left for actual edit) - BUG when clicking "edit" in the "Home" dashboard of the demo on hub.fluksio.com, most of the panels disappear (only a handfull is left for actual edit)
@@ -20,14 +23,13 @@ Deferring because out of scope is fine, but don't mention deferring than.
- FEAT/UI: an input endpoint opens the flow panel, which is right for editing but not for reading one value. A panel of its own — the declaration, the current value, its history — is what clicking a label wants to give. - FEAT/UI: an input endpoint opens the flow panel, which is right for editing but not for reading one value. A panel of its own — the declaration, the current value, its history — is what clicking a label wants to give.
- FEAT/UI: sync between the header of the python function and the node configuration. The config→header half exists for ports *and* settings — `scaffoldFor` writes `def process(<ports>, <settings>)` and `editNode` keeps it in step — but only while the source is still exactly the generated scaffold (`SCAFFOLD_SHAPE`), and never for shared code. What is missing is the same for code someone has edited, and the reverse direction: nothing parses a `def process(...)` header back into ports and settings. - FEAT/UI: sync between the header of the python function and the node configuration. The config→header half exists for ports *and* settings — `scaffoldFor` writes `def process(<ports>, <settings>)` and `editNode` keeps it in step — but only while the source is still exactly the generated scaffold (`SCAFFOLD_SHAPE`), and never for shared code. What is missing is the same for code someone has edited, and the reverse direction: nothing parses a `def process(...)` header back into ports and settings.
- BUG/UI on flows like "House history" where the widget sets the range for the "draw the window" node to generate some data, the edges overlap the nodes. We should adjust the flow visualization to account for these cyclic behaviors - BUG/UI on flows like "House history" where the widget sets the range for the "draw the window" node to generate some data, the edges overlap the nodes. We should adjust the flow visualization to account for these cyclic behaviors
- BUG/UI when enlarging the code editor of a node, the code editor should enlarge to the left (node settings remain on the right) so that the code editor fills the center of the screen with the node properties available next to it - BUG/UI when enlarging the code editor of a node, the code editor should enlarge to the left (node settings remain on the right) so that the code editor fills the center of the screen with the node properties available next to it (still in the same panel, essentially just a second column). Note that this overdraws the toolbar and flow name box which should move out of the screen towards the bottom and top respectively
- BUG/UX the console/log panel should be moved up slightly (to separate from the toolbar) and kept open (console button becomes stateful) - BUG/UX the console/log panel should be moved up slightly (to separate from the toolbar) and kept open (console button becomes stateful) and center over the toolbar
- CHORE/UI: loop lag on Home reads a real number with no flows, and that is right — `LoopWatchdog` times how late `asyncio.sleep(1.0)` wakes on the API's event loop and is started unconditionally, so it measures the engine process rather than any flow, and it is what turns the health badge `degraded`. Nothing to fix; recorded so it is not reopened. - CHORE/UI: loop lag on Home reads a real number with no flows, and that is right — `LoopWatchdog` times how late `asyncio.sleep(1.0)` wakes on the API's event loop and is started unconditionally, so it measures the engine process rather than any flow, and it is what turns the health badge `degraded`. Nothing to fix; recorded so it is not reopened.
- BUG/UI auto node placement on flows should be improved in regards to least crossing edges and a more vertical layout on mobile devices - BUG/UI auto node placement on flows should be improved in regards to least crossing edges and a more vertical layout on mobile devices
- FEAT/UI we should highlight failing nodes accordingly in the flow view to facilitate easier tracking of mis-configurations - FEAT/UI we should highlight failing nodes accordingly in the flow view to facilitate easier tracking of mis-configurations
- INFRA: ensure that all the packages/ dependencies needed to run fluksio are available on arm to make this software runnable on e.g. raspbian - INFRA: ensure that all the packages/ dependencies needed to run fluksio are available on arm to make this software runnable on e.g. raspbian
- INFRA: merge the philosophy statement at the beginning of vision.md into the rest of the document. Dissolve the decision dates and fold the decisions into a clean structure - INFRA: merge the philosophy statement at the beginning of vision.md into the rest of the document. Dissolve the decision dates and fold the decisions into a clean structure
- CHORE/INFRA: `bunx playwright test` from the host is unsafe against this stack — `app.fluksio.com` and `api.fluksio.com` resolve to production, and `--host-resolver-rules` steers only Chromium while `page.request.*` resolves through Node, so setup and teardown write to the live instance. Run the specs in a container on the `proxy` network with `--add-host` pointing both names at Traefik, and override `VITE_API_URL` to the `http://` form: `tests/utils/api.ts` takes it from `app/.env`, where it is `https://`, and Traefik has no cert the container trusts, so every `api()` call fails the TLS handshake before it reaches a request. The lasting fix is for the suite to name its own origin rather than inherit the deployment's.
- CHORE/INFRA: `make test-backend` reads `app/.env`, so a checkout configured for a deployment fails two tests that assume the development defaults — `test_private.py::test_create_user` (the `/private` router is off unless `ENVIRONMENT=local`) and `test_mcp_http.py::test_an_agent_can_list_and_call_tools` (the MCP host allow-list is built from `DOMAIN` while the test speaks to `api.localhost`). Both pass under `ENVIRONMENT=local DOMAIN=localhost`; the suite should pin its own environment rather than inherit the deployment's. - CHORE/INFRA: `make test-backend` reads `app/.env`, so a checkout configured for a deployment fails two tests that assume the development defaults — `test_private.py::test_create_user` (the `/private` router is off unless `ENVIRONMENT=local`) and `test_mcp_http.py::test_an_agent_can_list_and_call_tools` (the MCP host allow-list is built from `DOMAIN` while the test speaks to `api.localhost`). Both pass under `ENVIRONMENT=local DOMAIN=localhost`; the suite should pin its own environment rather than inherit the deployment's.
- CHORE/DOCS: `app/development.md` is still the upstream template's text — `compose.override.yml`, `localhost.tiangolo.com`, `docker compose watch` as the dev flow — so it disagrees with the Makefile about how the stack is started and says nothing about the Playwright suite. The containerised run above is what it should carry. - CHORE/DOCS: `app/development.md` is still the upstream template's text — `compose.override.yml`, `localhost.tiangolo.com`, `docker compose watch` as the dev flow — so it disagrees with the Makefile about how the stack is started and says nothing about the Playwright suite. The containerised run above is what it should carry.
- FEAT/UI add a loading animation for the initial app load and when loading individual pages; make sure that elements e.g. in the home dashboard load independently to ensure a fast loading of the initial site but figures charts, tables, graph etc. follow after that - FEAT/UI add a loading animation for the initial app load and when loading individual pages; make sure that elements e.g. in the home dashboard load independently to ensure a fast loading of the initial site but figures charts, tables, graph etc. follow after that
@@ -122,6 +124,7 @@ Decisions taken up front, because most items below depend on them:
- Nothing e-ink-specific in the widgets. Panel access is a credential problem - Nothing e-ink-specific in the widgets. Panel access is a credential problem
(see below); the display's demands are a rendering profile, deferred. (see below); the display's demands are a rendering profile, deferred.
- BUG/UI multiple entries in the bar diagram should draw stacked instad of drawn over another
- CHORE/UI: identical in-flight chart requests are deduplicated per browser tab, - CHORE/UI: identical in-flight chart requests are deduplicated per browser tab,
so two wall panels showing the same tile still run the query twice. An so two wall panels showing the same tile still run the query twice. An
`interval` on the request port is the backstop, and it belongs to the flow `interval` on the request port is the backstop, and it belongs to the flow
+1 -1
View File
@@ -29,7 +29,7 @@ make dev-frontend # Vite on :5173
``` ```
```sh ```sh
make test # pytest + Playwright make test # pytest + Playwright (the e2e half needs the stack up)
make lint # ruff + mypy + biome make lint # ruff + mypy + biome
make generate-client # regenerate the frontend SDK from the OpenAPI schema make generate-client # regenerate the frontend SDK from the OpenAPI schema
``` ```
+3
View File
@@ -151,6 +151,9 @@ services:
- ../.env - ../.env
environment: environment:
- VITE_API_URL=http://backend:8000 - VITE_API_URL=http://backend:8000
# The suite takes its own origins from these, never from VITE_API_URL —
# see frontend/tests/config.ts.
- PLAYWRIGHT_API_URL=http://backend:8000
- MAILCATCHER_HOST=http://mailcatcher:1080 - MAILCATCHER_HOST=http://mailcatcher:1080
- PLAYWRIGHT_HTML_HOST=0.0.0.0 - PLAYWRIGHT_HTML_HOST=0.0.0.0
- CI=${CI} - CI=${CI}
+13 -2
View File
@@ -1,5 +1,6 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
import 'dotenv/config' import 'dotenv/config'
import { appUrl } from './tests/config.ts'
/** /**
* Read environment variables from file. * Read environment variables from file.
@@ -21,12 +22,22 @@ export default defineConfig({
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: process.env.CI ? 'blob' : 'html', reporter: process.env.CI ? 'blob' : 'html',
/* Nothing runs until the target is known not to be a live instance. */
globalSetup: './tests/guard.ts',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. Defaults to the /* Base URL to use in actions like `await page.goto('/')`. Defaults to the
integrated stack (`make dev`), the only origin the API allows CORS from. integrated stack (`make dev`), the only origin the API allows CORS from.
Point PLAYWRIGHT_BASE_URL elsewhere to test another running server. */ Point PLAYWRIGHT_BASE_URL elsewhere to test another running server; the
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://app.localhost', API origin follows it (see tests/config.ts). */
baseURL: appUrl,
/* Chromium pins *.localhost to loopback whatever /etc/hosts says, so a
containerised run maps the names here as well as through --add-host. */
launchOptions: process.env.HOST_RESOLVER_RULES
? { args: [`--host-resolver-rules=${process.env.HOST_RESOLVER_RULES}`] }
: {},
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: 'on-first-retry',
+28
View File
@@ -17,3 +17,31 @@ function getEnvVar(name: string): string {
export const firstSuperuser = getEnvVar("FIRST_SUPERUSER") export const firstSuperuser = getEnvVar("FIRST_SUPERUSER")
export const firstSuperuserPassword = getEnvVar("FIRST_SUPERUSER_PASSWORD") export const firstSuperuserPassword = getEnvVar("FIRST_SUPERUSER_PASSWORD")
/**
* The two origins a run talks to: the app in the browser, and the API for the
* setup and teardown around a spec.
*
* Deliberately *not* taken from `VITE_API_URL`, even though `../../.env` above
* has one. That variable belongs to the app build, and in a checkout configured
* for a deployment it names the deployment — so a suite reading it would drive
* a browser at the local stack while sending its `DELETE`s to the live
* instance. Both origins come from the same place instead: whatever the browser
* is pointed at is what teardown may write to.
*/
export const appUrl = process.env.PLAYWRIGHT_BASE_URL || "http://app.localhost"
export const apiUrl = process.env.PLAYWRIGHT_API_URL || apiOrigin(appUrl)
/** `app.<domain>` serves the SPA, `api.<domain>` serves its API. */
function apiOrigin(app: string): string {
const url = new URL(app)
if (!url.hostname.startsWith("app.")) {
throw new Error(
`Cannot derive the API origin from PLAYWRIGHT_BASE_URL=${app}. ` +
"Set PLAYWRIGHT_API_URL to name it.",
)
}
url.hostname = `api.${url.hostname.slice("app.".length)}`
return url.origin
}
+1 -2
View File
@@ -1,4 +1,5 @@
import { expect, type Page, test } from "@playwright/test" import { expect, type Page, test } from "@playwright/test"
import { apiUrl } from "./config.ts"
import { deleteAll } from "./utils/api" import { deleteAll } from "./utils/api"
/** /**
@@ -17,8 +18,6 @@ test.afterAll(async ({ browser }) => {
await deleteAll(browser, [`/flows/${flowName}`]) await deleteAll(browser, [`/flows/${flowName}`])
}) })
const apiUrl = process.env.VITE_API_URL || "http://api.localhost"
/** Write a node's source through the API; typing code is not what we test. */ /** Write a node's source through the API; typing code is not what we test. */
async function setNodeSource(page: Page, nodeId: string, code: string) { async function setNodeSource(page: Page, nodeId: string, code: string) {
const token = await page.evaluate(() => localStorage.getItem("access_token")) const token = await page.evaluate(() => localStorage.getItem("access_token"))
+60
View File
@@ -0,0 +1,60 @@
import { lookup } from "node:dns/promises"
import { apiUrl, appUrl } from "./config.ts"
/**
* Refuse to run against a machine on the internet.
*
* The suite creates flows, dashboards and users and deletes them again, so the
* cost of pointing it at the wrong instance is somebody's data. That is not a
* hypothetical: a stack served under a real domain answers to the same names
* its production instance does, and the only thing telling them apart is which
* address those names resolve to here.
*
* So that is what is checked. Loopback and the private ranges are a stack on
* this machine or on its docker network; anything else is refused by name and
* address, before a single test runs. `PLAYWRIGHT_ALLOW_PUBLIC=1` is the way
* to say you meant it.
*/
const PRIVATE_V4 = [
/^127\./,
/^10\./,
/^192\.168\./,
/^169\.254\./,
/^172\.(1[6-9]|2\d|3[01])\./,
]
function isPrivate(address: string): boolean {
if (address === "::1" || address === "::") return true
// ::ffff:172.18.0.2 and friends.
const mapped = address.replace(/^::ffff:/i, "")
if (mapped !== address) return isPrivate(mapped)
if (/^f[cd][0-9a-f]{2}:/i.test(address)) return true // unique-local
if (/^fe80:/i.test(address)) return true // link-local
return PRIVATE_V4.some((range) => range.test(address))
}
export default async function guardTheTarget(): Promise<void> {
if (process.env.PLAYWRIGHT_ALLOW_PUBLIC === "1") return
for (const target of new Set([appUrl, apiUrl])) {
const { hostname } = new URL(target)
let address: string
try {
address = (await lookup(hostname)).address
} catch {
// Unresolvable is the run's own problem to report; it cannot be a live
// instance, which is all this guard is here for.
continue
}
if (isPrivate(address)) continue
throw new Error(
`Refusing to run: ${target} resolves to ${address}, which is not this ` +
"machine or its docker network. This suite creates and deletes flows, " +
"dashboards and users.\n" +
"Run it against the local stack — `make -C app test-frontend` maps " +
"the names onto Traefik — or set PLAYWRIGHT_ALLOW_PUBLIC=1 if you " +
"really mean this one.",
)
}
}
+2 -1
View File
@@ -8,8 +8,9 @@ import type { Browser, Page } from "@playwright/test"
* database. What a spec leaves behind is in someone's flow list tomorrow. * database. What a spec leaves behind is in someone's flow list tomorrow.
*/ */
import { apiUrl } from "../config.ts"
const authFile = "playwright/.auth/user.json" const authFile = "playwright/.auth/user.json"
const apiUrl = process.env.VITE_API_URL || "http://api.localhost"
/** Call the API as the logged-in user of *page*. */ /** Call the API as the logged-in user of *page*. */
export async function api( export async function api(
+4 -1
View File
@@ -1,8 +1,11 @@
// Note: the `PrivateService` is only available when generating the client // Note: the `PrivateService` is only available when generating the client
// for local environments // for local environments
import { OpenAPI, PrivateService } from "../../src/client" import { OpenAPI, PrivateService } from "../../src/client"
import { apiUrl } from "../config.ts"
OpenAPI.BASE = `${process.env.VITE_API_URL}` // The same origin the rest of the suite writes to, for the same reason: this
// one creates users.
OpenAPI.BASE = apiUrl
export const createUser = async ({ export const createUser = async ({
email, email,