From c684e3f51fa0820f195791b470229177d48a4719 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 21 Oct 2026 18:58:14 +1100 Subject: [PATCH 1/3] fix(auth): use the root redirect_uri on any localhost dev server The `/backoffice` route was only dropped on localhost:4200. On another port the redirect_uri pointed under `/backoffice/`, which the dev proxy sends to the backend, so sign-in failed. Co-Authored-By: Claude Opus 5.5 --- src/app/common/placeos.ts | 4 ++- src/tests/common/placeos.spec.ts | 44 +++++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/app/common/placeos.ts b/src/app/common/placeos.ts index 1eac5faab..8e45145c5 100644 --- a/src/app/common/placeos.ts +++ b/src/app/common/placeos.ts @@ -39,8 +39,10 @@ export async function setupPlace(settings: PlaceSettings): Promise { const url = settings.use_domain ? `${protocol}//${host}:${port}` : location.origin; + // A local dev server serves the app at the root, on any port const route = - host.includes('localhost') && port === '4200' + host.includes('localhost') && + !location.pathname.startsWith(settings.route) ? '' : settings.route || ''; const mock = diff --git a/src/tests/common/placeos.spec.ts b/src/tests/common/placeos.spec.ts index 0ce85f764..5a6b02279 100644 --- a/src/tests/common/placeos.spec.ts +++ b/src/tests/common/placeos.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock @angular/core signal vi.mock('@angular/core', () => ({ @@ -17,13 +17,13 @@ vi.mock('@placeos/ts-client', () => ({ setup: vi.fn().mockResolvedValue(undefined), })); +import { setup } from '@placeos/ts-client'; import { getLoadingMessage, + PlaceSettings, setLoadingMessage, setupPlace, - PlaceSettings, } from '../../app/common/placeos'; -import { setup } from '@placeos/ts-client'; describe('placeos.ts', () => { beforeEach(() => { @@ -175,6 +175,44 @@ describe('placeos.ts', () => { ); }); + describe('on localhost', () => { + const settings: PlaceSettings = { + protocol: 'http:', + domain: '', + port: 4216, + route: '/backoffice', + use_domain: false, + local_login: false, + mock: false, + }; + + afterEach(() => window.history.pushState({}, '', '/')); + + it('drops the route when a dev server serves the app at the root', async () => { + window.history.pushState({}, '', '/'); + + await setupPlace(settings); + + expect(setup).toHaveBeenCalledWith( + expect.objectContaining({ + redirect_uri: `${location.origin}/oauth-resp.html`, + }), + ); + }); + + it('keeps the route when the app is served under it', async () => { + window.history.pushState({}, '', '/backoffice/'); + + await setupPlace(settings); + + expect(setup).toHaveBeenCalledWith( + expect.objectContaining({ + redirect_uri: `${location.origin}/backoffice/oauth-resp.html`, + }), + ); + }); + }); + it('should return a promise', () => { const settings: PlaceSettings = { protocol: 'https:', From 4d6c311a3eb2bde5bf6c9514c0cd03680a625f20 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 21 Oct 2026 18:58:14 +1100 Subject: [PATCH 2/3] test(e2e): run a suite against a local PlaceOS stack Adds an isolated compose stack (e2e/stack, project backoffice-e2e on 9543/9180), an idempotent API seed, headless PKCE sign-in, and specs for login, admin access and zone CRUD checked through the API. Adapted from the PlaceOS/user-interfaces e2e stack. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 + CLAUDE.md | 5 +- e2e/.env.example | 8 + e2e/README.md | 148 ++++++++++++++++++- e2e/playwright.config.ts | 6 +- e2e/playwright.local.config.ts | 87 +++++++++++ e2e/src/local/access.spec.ts | 17 +++ e2e/src/local/login.spec.ts | 29 ++++ e2e/src/local/zones.spec.ts | 85 +++++++++++ e2e/stack/.gitignore | 2 + e2e/stack/docker-compose.yml | 260 +++++++++++++++++++++++++++++++++ e2e/stack/down.sh | 5 + e2e/stack/up.sh | 126 ++++++++++++++++ e2e/support/api.ts | 55 +++++++ e2e/support/auth.ts | 142 ++++++++++++++++++ e2e/support/env.ts | 75 ++++++++++ e2e/support/fixtures.ts | 44 ++++++ e2e/support/seed.ts | 142 ++++++++++++++++++ e2e/support/stack.setup.ts | 51 +++++++ package.json | 3 +- 20 files changed, 1285 insertions(+), 9 deletions(-) create mode 100644 e2e/playwright.local.config.ts create mode 100644 e2e/src/local/access.spec.ts create mode 100644 e2e/src/local/login.spec.ts create mode 100644 e2e/src/local/zones.spec.ts create mode 100644 e2e/stack/.gitignore create mode 100644 e2e/stack/docker-compose.yml create mode 100755 e2e/stack/down.sh create mode 100755 e2e/stack/up.sh create mode 100644 e2e/support/api.ts create mode 100644 e2e/support/auth.ts create mode 100644 e2e/support/env.ts create mode 100644 e2e/support/fixtures.ts create mode 100644 e2e/support/seed.ts create mode 100644 e2e/support/stack.setup.ts diff --git a/.gitignore b/.gitignore index fa6e5454b..cb1a3d3a4 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,7 @@ vitest.config.*.timestamp* migrations.json src/env/version.ts .nx/migrate-runs + +# E2E auth artifacts: bearer tokens. Never commit. +e2e/.auth +e2e/.env diff --git a/CLAUDE.md b/CLAUDE.md index 63b00785d..98a060a76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,10 @@ bun run build # Production build bun run test # Unit tests (Vitest) bun run test:ci # Tests sequentially (CI mode) bunx playwright install --with-deps -bun run e2e # Playwright E2E tests +bun run e2e # Playwright E2E tests (mock backend) +e2e/stack/up.sh # Local PlaceOS stack in Docker (https://localhost:9543) +bun run e2e:local # Playwright E2E tests against the local stack +e2e/stack/down.sh # Stop the stack (--volumes to remove its data) # Linting bun run lint # ESLint diff --git a/e2e/.env.example b/e2e/.env.example index 15266fe93..d899898e6 100644 --- a/e2e/.env.example +++ b/e2e/.env.example @@ -15,3 +15,11 @@ USE_MOCK=true # Test user credentials for live environment # TEST_USER_EMAIL=admin@example.com # TEST_USER_PASSWORD=password + +# Local backend suite (`bun run e2e:local`). Both URLs must be on a loopback +# host: the suite refuses any other host. +# E2E_BACKEND_URL=https://localhost:9543 +# E2E_APP_URL=http://localhost:4216 +# E2E_ADMIN_EMAIL=support@place.tech +# E2E_ADMIN_PASSWORD=development +# E2E_STAFF_PASSWORD=e2e-staff-development diff --git a/e2e/README.md b/e2e/README.md index 44c743188..d1831f2af 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,6 +1,13 @@ # E2E Tests for PlaceOS Backoffice -These Playwright tests run the app against the mock backend in +There are two suites: + +- **Mock** (`src/*.spec.ts`): the app runs against the mock backend. CI runs + this suite. Most of this document is about it. +- **Local backend** (`src/local/*.spec.ts`): the app runs against a real + PlaceOS stack in Docker. See [Local backend](#local-backend). + +The mock tests run the app against the mock backend in `src/app/mocks/`. Each test gets a new browser context, so it starts with fresh mock data. The mock backend keeps creates, edits and deletes in session storage, so a change stays after `page.reload()` in the same test. @@ -26,9 +33,9 @@ The config starts the dev server, or uses the server that runs on port 4200. CI runs `chromium` and `mobile-chrome` for pull requests to `develop`, and all projects on other branches. -`USE_MOCK=false` stops the tests from switching on the mock backend. There is -no sign-in flow for a live server, and most tests expect the mock data, so -use this only to debug. +`USE_MOCK=false` stops the tests from switching on the mock backend. Most +tests expect the mock data, so use this only to debug. To test against a real +backend, use the [local backend](#local-backend) suite. ## Coverage @@ -91,3 +98,136 @@ Rules: assertions (`toHaveText`, `toHaveURL`, `toHaveCount`) wait for the app. 6. The item list is virtual and shows only part of a long list. Search for an item before you look for it in a long list. + +## Local backend + +These specs run against a PlaceOS stack on your computer. They make, change +and delete real data, so the config refuses a backend that is not on a +loopback host. The stack and the suite are adapted from the +`PlaceOS/user-interfaces` e2e stack. + +You need Docker with about 2 GB of free memory for the stack. + +```bash +e2e/stack/up.sh # start the stack (--fresh: remove its data first, --pull: pull new images) +bun run e2e:local # run the specs +bun run e2e:local -- zones.spec # run one spec file +bun run e2e:local -- --ui # UI mode +e2e/stack/down.sh # stop the stack (--volumes: remove its data) +``` + +### The stack + +`e2e/stack/` is its own compose project (`backoffice-e2e`) with its own +network, volumes and secrets. It uses ports 9543 (HTTPS) and 9180 (HTTP). Thus +it can run at the same time as PlaceOS/local (8443) and the user-interfaces +e2e stack (9443). + +It has: postgres, elasticsearch, redis, search-ingest, frontend-loader, auth, +rest-api, staff-api, nginx and init. It does not have core, so no driver +compiles and no module starts. Add core from the user-interfaces stack when a +spec needs it. + +`up.sh` makes the secrets in `e2e/stack/.secrets/` (not in git), starts the +services, waits for the API and the login page, and runs `init`. `init` makes +the authority, the admin user (`support@place.tech` / `development`) and a +zone tree. + +`frontend-loader` is necessary. It copies `PlaceOS/www-core` into the `www` +volume, and the platform `/login` page comes from there. Thus the first +start needs access to GitHub. + +### How a run works + +1. Playwright starts the dev server on port 4216, with its proxy + (`config/proxy.conf.js`) pointed at the stack. It does not use port 4200, + because a dev server there can proxy to a shared deployment. +2. The `setup` project (`support/stack.setup.ts`) checks that the stack + answers, then seeds it (`support/seed.ts`). The seed is idempotent: + - It registers an OAuth app for `http://localhost:4216/oauth-resp.html`. + ts-client uses `Md5(redirect_uri)` as its client id, and the sign-in + fails if no app has that id. + - It makes the authority's `login_url` absolute. A new authority has a + relative `login_url`, and ts-client then sends the browser to + `http://localhost/login`, which has no port. + - It makes a user that is not an admin (`e2e-staff@place.tech`). +3. The setup signs in as each user through the API (OAuth code + PKCE) and + writes a storageState to `e2e/.auth/` (not in git). These files hold + bearer tokens. +4. The `local` project runs `src/local/*.spec.ts`. `page` is signed in as + the admin. + +### CI + +`.github/workflows/e2e-local.yml` runs this suite on a self-hosted runner +with the label `placeos-e2e`. It runs: + +- on a push to `develop`, `release/**`, `rc/**` and `e2e/**` +- every day at 01:40 UTC +- from **Run workflow** + +It has no `pull_request` trigger. This repository is public, so a fork pull +request could run any code on the runner. Do not add the trigger, except if +the job moves to `ubuntu-latest`. + +The job is advisory. Do not make it a required check until it has a record of +runs with no false failures. + +Each run removes what an earlier run left, pulls the images, starts the stack +with no data, runs the suite and removes the stack and its volumes. The step +summary shows the image ids and the flaky specs. On failure, the artifacts +have the traces, the videos and the log of each service. + +To set up the runner machine, follow +[`SELF_HOSTED_RUNNER.md`](https://github.com/PlaceOS/user-interfaces/blob/develop/e2e/stack/SELF_HOSTED_RUNNER.md) +in `PlaceOS/user-interfaces`, with these changes: + +- Register the runner for `PlaceOS/backoffice` (a repository runner serves + one repository), or use an organisation runner for both repositories. +- The suite uses ports 9543, 9180 and 4216, and the user-interfaces suite uses + 9443, 9080 and 4214. Thus the two jobs can run on one machine at the same + time if it has the memory. + +### Write a local spec + +```typescript +import { ENGINE_API, uniqueName } from '../../support/api'; +import { expect, test } from '../../support/fixtures'; +import { FeaturePage } from '../pages'; + +test('AC-ZON-004-5: a renamed zone is saved', async ({ page, api }) => { + const res = await api.post(`${ENGINE_API}/zones`, { + data: { name: uniqueName('E2E Zone') }, + }); + const zone = await res.json(); + try { + // ...change the zone in the UI with FeaturePage... + // ...then check the result through `api`, not only on the page + } finally { + await api.delete(`${ENGINE_API}/zones/${zone.id}`); + } +}); +``` + +Rules: + +1. Import `test` and `expect` from `support/fixtures`. The fixtures are: + `page` (signed in as the admin), `staffPage` (signed in as a user that is + not an admin) and `api` (an API context with an admin bearer). +2. Each spec makes its own data with `uniqueName()` and removes it in a + `finally`. Specs run in parallel on the same stack. +3. Check the result through the API. The page can show a change that the + backend did not save. +4. Check access rules as `staffPage`. A sys_admin passes every check, so the + same test as the admin proves nothing. +5. A spec about sign-in starts with no token: + `test.use({ storageState: { cookies: [], origins: [] } })`. See + `src/local/login.spec.ts`. +6. Make the new assertion fail one time, and make sure that it fails for the + correct reason. + +| Spec | Covers | +| ---------------------- | ---------------------------------------------------- | +| `local/login.spec.ts` | Sign-in through the platform login page | +| `local/access.spec.ts` | An admin opens domains; other users get the 403 page | +| `local/zones.spec.ts` | Create, rename and delete a zone, checked on the API | diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index faaa0cd94..aac8ef0c8 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -87,19 +87,19 @@ export default defineConfig({ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, - testIgnore: '**/*.mobile.spec.ts', + testIgnore: ['**/*.mobile.spec.ts', '**/local/**'], }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, - testIgnore: '**/*.mobile.spec.ts', + testIgnore: ['**/*.mobile.spec.ts', '**/local/**'], }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, - testIgnore: '**/*.mobile.spec.ts', + testIgnore: ['**/*.mobile.spec.ts', '**/local/**'], }, // Mobile browsers - use dedicated mobile test files diff --git a/e2e/playwright.local.config.ts b/e2e/playwright.local.config.ts new file mode 100644 index 000000000..c357b9834 --- /dev/null +++ b/e2e/playwright.local.config.ts @@ -0,0 +1,87 @@ +import { workspaceRoot } from '@nx/devkit'; +import { defineConfig, devices } from '@playwright/test'; +import { APP_URL, BACKEND_URL, assertLocalOnly, roleFor } from './support/env'; + +/** + * E2E tests against the local PlaceOS stack in `e2e/stack`. See + * `e2e/README.md`. + * + * e2e/stack/up.sh + * bun run e2e:local + */ + +// Refuse any backend that is not local before a browser launches. The specs +// create and delete real data. +assertLocalOnly(BACKEND_URL, APP_URL); + +// The page objects in src/pages build their URLs from these +process.env['BASE_URL'] = APP_URL; +process.env['USE_MOCK'] = 'false'; + +const CI = !!process.env['CI']; +const backend = new URL(BACKEND_URL); +const output = `${workspaceRoot}/dist/.playwright/e2e-local`; + +export default defineConfig({ + outputDir: `${output}/test-output`, + timeout: 60_000, + expect: { timeout: 15_000 }, + fullyParallel: true, + // Specs share one admin and make uniquely named data, so they can run in + // parallel. Two workers leave room for the stack and the dev server. + workers: 2, + forbidOnly: CI, + retries: CI ? 2 : 0, + reporter: [ + ['list'], + ['html', { outputFolder: `${output}/report`, open: 'never' }], + // e2e/stack/ci-summary.mjs reads the JSON report + ...(CI + ? ([ + ['json', { outputFile: `${output}/results.json` }], + ['github'], + ] as const) + : []), + ], + use: { + baseURL: APP_URL, + // The login page is on the stack, which has a self-signed cert + ignoreHTTPSErrors: true, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + actionTimeout: 15_000, + navigationTimeout: 30_000, + }, + projects: [ + { + name: 'setup', + testDir: './support', + testMatch: /stack\.setup\.ts$/, + }, + { + name: 'local', + testDir: './src/local', + dependencies: ['setup'], + use: { + ...devices['Desktop Chrome'], + storageState: roleFor('admin').storage_path, + }, + }, + ], + webServer: { + // Not port 4200: a developer's own dev server there proxies to a + // shared deployment, and must never be reused for this suite. + command: `bunx nx run backoffice:serve --port=${new URL(APP_URL).port}`, + url: APP_URL, + reuseExistingServer: !CI, + cwd: workspaceRoot, + timeout: 180_000, + // Point the dev server proxy (config/proxy.conf.js) at the stack. Its + // default target is the shared dev deployment. + env: { + PLACEOS_DOMAIN: backend.host, + PLACEOS_INSECURE: String(backend.protocol !== 'https:'), + }, + }, +}); diff --git a/e2e/src/local/access.spec.ts b/e2e/src/local/access.spec.ts new file mode 100644 index 000000000..9850c3409 --- /dev/null +++ b/e2e/src/local/access.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '../../support/fixtures'; + +test('an admin can open the domains section', async ({ page }) => { + await page.goto('/#/domains'); + + await expect(page).toHaveURL(/#\/domains/); + await expect(page.locator('item-sidebar')).toBeVisible(); +}); + +test('a user who is not an admin is sent to the 403 page', async ({ + staffPage, +}) => { + await staffPage.goto('/#/domains'); + + await expect(staffPage).toHaveURL(/#\/unauthorised/); + await expect(staffPage.getByRole('heading', { name: '403' })).toBeVisible(); +}); diff --git a/e2e/src/local/login.spec.ts b/e2e/src/local/login.spec.ts new file mode 100644 index 000000000..b1503f269 --- /dev/null +++ b/e2e/src/local/login.spec.ts @@ -0,0 +1,29 @@ +import { ENGINE_API } from '../../support/api'; +import { roleFor } from '../../support/env'; +import { expect, test } from '../../support/fixtures'; + +// Signs in through the real form, so it starts with no stored token +test.use({ storageState: { cookies: [], origins: [] } }); + +test('signs in through the platform login page', async ({ page }) => { + const admin = roleFor('admin'); + const current_user = page.waitForResponse( + (res) => + res.url().endsWith(`${ENGINE_API}/users/current`) && + res.status() === 200, + ); + + await page.goto('/#/systems'); + + // The app sends the browser to the authority's login page + await expect(page.getByPlaceholder('Email')).toBeVisible(); + await page.getByPlaceholder('Email').fill(admin.email); + await page.getByPlaceholder('Password').fill(admin.password); + await page.getByRole('button', { name: 'Log In' }).click(); + + // ...and back into the app, signed in as that user + const user = await (await current_user).json(); + expect(user.email).toBe(admin.email); + await expect(page).toHaveURL(/#\/systems/); + await expect(page.locator('sidebar-menu')).toBeVisible(); +}); diff --git a/e2e/src/local/zones.spec.ts b/e2e/src/local/zones.spec.ts new file mode 100644 index 000000000..eb3767d79 --- /dev/null +++ b/e2e/src/local/zones.spec.ts @@ -0,0 +1,85 @@ +import { APIRequestContext } from '@playwright/test'; +import { ENGINE_API, uniqueName } from '../../support/api'; +import { expect, test } from '../../support/fixtures'; +import { FeaturePage } from '../pages'; + +interface Zone { + id: string; + name: string; +} + +/** Make a zone through the API. The spec that makes it removes it */ +async function createZone(api: APIRequestContext): Promise { + const res = await api.post(`${ENGINE_API}/zones`, { + data: { name: uniqueName('E2E Zone') }, + }); + expect(res.ok(), await res.text()).toBe(true); + return res.json(); +} + +async function deleteZone(api: APIRequestContext, id: string) { + await api.delete(`${ENGINE_API}/zones/${encodeURIComponent(id)}`); +} + +test.describe('Zones on the local backend', () => { + test('AC-ZON-003-5: a zone made in the form is saved', async ({ + page, + api, + }) => { + const zones = new FeaturePage(page, 'zones'); + const name = uniqueName('E2E Zone'); + await zones.goto(); + + const id = await zones.createWith(async () => { + await zones.dialog + .getByRole('textbox', { name: 'Name*', exact: true }) + .fill(name); + }); + try { + const res = await api.get(`${ENGINE_API}/zones/${id}`); + expect(res.status()).toBe(200); + expect((await res.json()).name).toBe(name); + } finally { + await deleteZone(api, id); + } + }); + + test('AC-ZON-004-5: a renamed zone is saved', async ({ page, api }) => { + const zone = await createZone(api); + const zones = new FeaturePage(page, 'zones'); + const name = uniqueName('E2E Renamed'); + try { + await zones.gotoItem(zone.id); + await zones.openEditModal(); + await zones.dialog + .getByRole('textbox', { name: 'Name*', exact: true }) + .fill(name); + await zones.saveButton.click(); + await expect(zones.dialog).toBeHidden(); + + await expect(zones.itemName).toHaveText(name); + await expect + .poll(async () => { + const res = await api.get(`${ENGINE_API}/zones/${zone.id}`); + return (await res.json()).name; + }) + .toBe(name); + } finally { + await deleteZone(api, zone.id); + } + }); + + test('AC-ZON-005-3: a deleted zone is removed', async ({ page, api }) => { + const zone = await createZone(api); + const zones = new FeaturePage(page, 'zones'); + try { + await zones.gotoItem(zone.id); + await zones.deleteSelected(); + + const res = await api.get(`${ENGINE_API}/zones/${zone.id}`); + expect(res.status()).toBe(404); + } finally { + await deleteZone(api, zone.id); + } + }); +}); diff --git a/e2e/stack/.gitignore b/e2e/stack/.gitignore new file mode 100644 index 000000000..75e4db62d --- /dev/null +++ b/e2e/stack/.gitignore @@ -0,0 +1,2 @@ +# Generated by up.sh. Never commit. +.secrets/ diff --git a/e2e/stack/docker-compose.yml b/e2e/stack/docker-compose.yml new file mode 100644 index 000000000..b157c7d5a --- /dev/null +++ b/e2e/stack/docker-compose.yml @@ -0,0 +1,260 @@ +# Isolated PlaceOS stack for the backoffice e2e suite. +# +# Adapted from PlaceOS/user-interfaces `e2e/stack`. It is NOT PlaceOS/local: +# everything here is project-scoped (no `container_name`, no fixed network), on +# its own ports, so it runs beside a developer's own stack and beside the +# user-interfaces e2e stack (9443/9080) without touching either. +# +# Trimmed to what the backoffice suite exercises: the engine REST API, auth, +# staff-api (tenants) and the platform login page. Dropped: core, source, +# mosquitto, triggers, dispatch, edge, influx, chronograf, minio. Without core +# no driver compiles and no module starts; add it from the user-interfaces +# stack when a spec needs that. +# +# frontend-loader IS required, even though the dev server serves the SPA. On +# startup it clones PlaceOS/www-core into the `www` volume, and that is where +# the platform /login page comes from. Bring-up needs network access to GitHub +# one time to fill the volume. +# +# Bring it up with ./up.sh. It makes the secrets and runs `init` first. + +volumes: + postgres-data: + elastic-data: + redis-data: + nginx-data: + www: + +x-deployment-env: &deployment-env + PLACE_LOG_FORMAT: JSON + LOG_LEVEL: ${LOG_LEVEL:-warn} + ENV: development + SG_ENV: development + TZ: ${TZ:-UTC} + +x-postgresdb-client-env: &postgresdb-client-env + PG_HOST: postgres + PG_PORT: 5432 + PG_DB: placeos + PG_USER: placeos + PG_PASSWORD: development + PG_DATABASE: placeos + PG_DATABASE_URL: postgresql://placeos:development@postgres:5432/placeos + +x-elastic-client-env: &elastic-client-env + ELASTIC_HOST: elastic + ELASTIC_PORT: 9200 + ES_HOST: elastic + ES_PORT: 9200 + +x-redis-client-env: &redis-client-env + REDIS_URL: redis://redis:6379 + +x-search-ingest-client-env: &search-ingest-client-env + PLACE_SEARCH_INGEST_URI: http://search-ingest:3000 + +x-logging: &std-logging + logging: + driver: json-file + options: + max-size: 25m + +services: + postgres: + image: postgres:18.6-alpine # 18: migrations use uuidv7(), a PG18 builtin + hostname: postgres + restart: unless-stopped + <<: *std-logging + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U placeos -d placeos'] + interval: 5s + timeout: 5s + retries: 20 + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + <<: *postgresdb-client-env + PGDATA: /var/lib/postgresql/data/pgdata + POSTGRES_USER: placeos + POSTGRES_PASSWORD: development + POSTGRES_DB: placeos + TZ: ${TZ:-UTC} + + elastic: + # The `latest` release of rest-api still searches through Elasticsearch. + # Remove this only when the image that ${PLACEOS_TAG:-latest} resolves to + # searches through Postgres (PPT-2644). The user-interfaces compose file + # has the check to run. + # + # A late 7.17 patch: 7.17.6 bundles a JDK that fails to start on cgroup v2. + image: elasticsearch:${E2E_ELASTIC_VERSION:-7.17.28} + hostname: elastic + restart: always + <<: *std-logging + healthcheck: + test: curl --silent --fail localhost:9200/_cat/health + interval: 10s + start_period: 60s + retries: 20 + volumes: + - elastic-data:/usr/share/elasticsearch/data + # Memory locking is off: it needs an unlimited `memlock` rlimit, which not + # every Docker host gives, and a test stack does not need it. + ulimits: + memlock: + soft: -1 + hard: -1 + nofile: + soft: 65536 + hard: 65536 + environment: + bootstrap.memory_lock: 'false' + cluster.routing.allocation.disk.threshold_enabled: 'false' + discovery.type: single-node + ES_JAVA_OPTS: -Xms512m -Xmx512m + TZ: ${TZ:-UTC} + + redis: + # Pinned by digest to the multi-arch manifest list. keydb is not under + # test, so a moving tag can only add noise. + image: eqalpha/keydb@sha256:6537505c42355ca1f571276bddf83f5b750f760f07b2a185a676481791e388ac + hostname: redis + restart: always + <<: *std-logging + healthcheck: + test: keydb-cli ping + interval: 5s + volumes: + - redis-data:/data + environment: + TZ: ${TZ:-UTC} + + search-ingest: + image: placeos/search-ingest:${PLACEOS_TAG:-latest} + hostname: search-ingest + restart: always + <<: *std-logging + depends_on: + elastic: { condition: service_healthy } + postgres: { condition: service_healthy } + environment: + <<: [*deployment-env, *postgresdb-client-env, *elastic-client-env] + + # Fills the `www` volume from PlaceOS/www-core: the platform /login page. + frontend-loader: + image: placeos/frontend-loader:${PLACEOS_TAG:-latest} + hostname: frontend-loader + restart: always + <<: *std-logging + depends_on: + postgres: { condition: service_healthy } + volumes: + - www:/app/www + env_file: + - .secrets/.env.secret_key + environment: + <<: [*deployment-env, *postgresdb-client-env] + PLACE_LOADER_WWW: www + + auth: + image: placeos/auth:${PLACEOS_TAG:-latest} + hostname: auth + restart: always + <<: *std-logging + depends_on: + postgres: { condition: service_healthy } + redis: { condition: service_started } + env_file: + - .secrets/.env.secret_key + environment: + <<: [*postgresdb-client-env, *redis-client-env] + COAUTH_NO_SSL: 'true' + TZ: ${TZ:-UTC} + PLACE_URI: https://${E2E_DOMAIN:-localhost:9543} + + rest-api: + image: placeos/rest-api:${PLACEOS_TAG:-latest} + hostname: api # load-bearing: the nginx upstream is `api` + restart: always + <<: *std-logging + depends_on: + postgres: { condition: service_healthy } + elastic: { condition: service_healthy } + redis: { condition: service_started } + env_file: + - .secrets/.env.public_key + - .secrets/.env.secret_key + environment: + <<: + [ + *deployment-env, + *postgresdb-client-env, + *elastic-client-env, + *redis-client-env, + *search-ingest-client-env, + ] + + # Serves /api/staff/v1, which the admin tenants section and the tenant + # expiry banner read. + staff-api: + image: placeos/staff-api:${PLACEOS_TAG:-latest} + hostname: staff # load-bearing: the nginx upstream is `staff` + restart: unless-stopped + <<: *std-logging + depends_on: + postgres: { condition: service_healthy } + env_file: + - .secrets/.env.public_key + - .secrets/.env.secret_key + environment: + <<: [*postgresdb-client-env, *redis-client-env] + SG_ENV: production + STAFF_TIME_ZONE: ${TZ:-UTC} + PLACE_URI: 'https://nginx' + SSL_VERIFY_NONE: 'true' + + nginx: + image: placeos/nginx:${PLACEOS_TAG:-latest} + hostname: nginx + restart: always + <<: *std-logging + ports: + - '${E2E_HTTP_PORT:-9180}:80' + - '${E2E_HTTPS_PORT:-9543}:443' + healthcheck: + test: "bash -c ': &>/dev/null &2; exit 64 ;; + esac +done + +if [[ "$fresh" == true ]]; then + step "tearing down (volumes included)" + dc down --volumes --remove-orphans || true +fi + +# Compose refuses to load the project while an env_file is missing, so make +# them empty first. The real values are generated below. +mkdir -p .secrets +touch .secrets/.env.secret_key .secrets/.env.public_key + +if [[ "$pull" == true ]]; then + step "pulling images" + # An image that fails to pull is reported, and the stack then runs on the + # image already present + dc pull --ignore-pull-failures + if [[ "$pull_only" == true ]]; then exit 0; fi +fi + +# The init image's generator is the one PlaceOS/local uses. Existing values are +# kept, not rotated. +step "secrets" +if ! grep -q 'JWT_SECRET' .secrets/.env.secret_key; then + # The generator reads the admin credentials from .env + printf 'PLACE_EMAIL=%s\nPLACE_PASSWORD=%s\n' "$ADMIN_EMAIL" "$ADMIN_PASSWORD" > .secrets/.env + docker run --rm -w /tmp/secrets -v "$PWD/.secrets:/tmp/secrets" \ + -e PLACE_EMAIL="$ADMIN_EMAIL" -e PLACE_PASSWORD="$ADMIN_PASSWORD" \ + "placeos/init:${PLACEOS_TAG:-latest}" generate-secrets >/dev/null + # SECRET_KEY_BASE must be exactly 30 characters: nginx's lua HMAC and + # auth.cr both key off it, and they disagree on a longer value. + awk -F'=' '{ if ($1 == "SECRET_KEY_BASE") print $1"="substr($2,1,30); else print }' \ + .secrets/.env.secret_key > .secrets/.tmp && mv .secrets/.tmp .secrets/.env.secret_key + echo " generated" +else + echo " present (not rotated)" +fi + +# `compose --wait` reports only "container X is unhealthy". Print the state +# and logs of every service so the output alone explains a failure. +diagnose() { + echo + echo "=== compose ps ===" + dc ps --all || true + echo + echo "=== logs (all services, last 60 lines each) ===" + dc logs --tail 60 || true +} +trap 'rc=$?; [[ $rc -ne 0 ]] && diagnose; exit $rc' ERR + +# Poll a URL until it answers with a status that matches `want` (a regex). +# Bounded: 60 tries, 2 seconds apart. +wait_for() { + local url=$1 want=$2 code="" + for _ in $(seq 1 60); do + code=$(curl -sk -o /dev/null -w '%{http_code}' "$url" || true) + [[ "$code" =~ $want ]] && { echo " $url -> $code"; return 0; } + sleep 2 + done + echo " $url never answered as expected (last: ${code:-none})" >&2 + return 1 +} + +step "starting services" +dc up -d --wait --wait-timeout 300 postgres elastic redis +dc up -d search-ingest frontend-loader auth rest-api staff-api nginx + +step "waiting for the API" +wait_for "https://localhost:${HTTPS_PORT}/api/engine/v2/" '^[1-4][0-9][0-9]$' + +# frontend-loader clones PlaceOS/www-core into the `www` volume on startup. +# Until it lands, nginx answers 404 at /login. +step "waiting for the platform login page" +wait_for "https://localhost:${HTTPS_PORT}/login" '^200$' + +# Creates the authority, the admin user, the `backoffice` OAuth app and a +# placeholder zone tree. Safe to run again. +step "seeding platform entities (init)" +dc run --rm init start + +printf '\n\033[32mstack ready\033[0m backend=https://localhost:%s project=%s\n' "$HTTPS_PORT" "$PROJECT" +printf 'run the suite with:\n bun run e2e:local\n' diff --git a/e2e/support/api.ts b/e2e/support/api.ts new file mode 100644 index 000000000..3a729728c --- /dev/null +++ b/e2e/support/api.ts @@ -0,0 +1,55 @@ +/** + * Backend helpers. Specs make and remove their own data through these, and + * check results against the API, not only the page. + */ +import { APIRequestContext, request as pw_request } from '@playwright/test'; +import { mintToken } from './auth'; +import { APP_URL, BACKEND_URL, RoleName, roleFor } from './env'; + +export const ENGINE_API = '/api/engine/v2'; +export const STAFF_API = '/api/staff/v1'; + +/** The rows of a listing, which is a bare array or `{ results }` */ +export function asList(body: unknown): T[] { + if (Array.isArray(body)) return body as T[]; + return (body as { results?: T[] } | null)?.results ?? []; +} + +/** GET a JSON body, or throw with the status and body */ +export async function getJson( + api: APIRequestContext, + path: string, + params?: Record, +): Promise { + const res = await api.get(path, { params }); + if (!res.ok()) { + throw new Error( + `GET ${path} failed: HTTP ${res.status()} ${await res.text()}`, + ); + } + return res.json() as Promise; +} + +/** An API context with a bearer for a role, for the app at `app_url` */ +export async function apiFor( + role: RoleName, + app_url = APP_URL, +): Promise { + const { email, password } = roleFor(role); + const { access_token } = await mintToken( + BACKEND_URL, + app_url, + email, + password, + ); + return pw_request.newContext({ + baseURL: BACKEND_URL, + ignoreHTTPSErrors: true, + extraHTTPHeaders: { Authorization: `Bearer ${access_token}` }, + }); +} + +/** A name no other spec or run uses */ +export function uniqueName(prefix: string): string { + return `${prefix} ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; +} diff --git a/e2e/support/auth.ts b/e2e/support/auth.ts new file mode 100644 index 000000000..e47365fa3 --- /dev/null +++ b/e2e/support/auth.ts @@ -0,0 +1,142 @@ +/** + * Headless sign-in for the e2e suite. + * + * Replays the OAuth2 authorization-code + PKCE flow that @placeos/ts-client + * runs in the browser, then writes the result as a Playwright storageState, so + * the SPA starts signed in without driving the login form. + * + * ts-client keeps its bearer in `localStorage[${cid}_access_token]` and + * `_expires_at`, where `cid = Md5(redirect_uri)`. The `_expires_at` we write + * must be in the future, or ts-client starts a new sign-in mid-test. + */ +import { request as pw_request } from '@playwright/test'; +import { createHash, randomBytes } from 'crypto'; + +/** client_id as ts-client computes it: Md5(redirect_uri) */ +export function clientId(redirect_uri: string): string { + return createHash('md5').update(redirect_uri).digest('hex'); +} + +/** + * The redirect_uri the app gives ts-client (`setupPlace` in + * src/app/common/placeos.ts): `${origin}${route}/oauth-resp.html`. On a dev + * server the app is at the root; a stack-served build is at `/backoffice/`. + */ +export function redirectUriFor(app_url: string): string { + const { origin, pathname } = new URL(app_url); + return `${origin}${pathname.replace(/\/$/, '')}/oauth-resp.html`; +} + +function base64url(buf: Buffer): string { + return buf + .toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +} + +export interface MintResult { + access_token: string; + refresh_token?: string; + expires_in: number; + client_id: string; +} + +/** + * Sign in as a local user and get a bearer for the app at `app_url`: + * POST /auth/signin -> 202 and a session cookie + * GET /auth/oauth/authorize -> 302 with ?code= (not followed) + * POST /auth/oauth/token -> access_token + */ +export async function mintToken( + backend_url: string, + app_url: string, + email: string, + password: string, +): Promise { + const redirect_uri = redirectUriFor(app_url); + const client_id = clientId(redirect_uri); + const verifier = base64url(randomBytes(32)); + const challenge = base64url(createHash('sha256').update(verifier).digest()); + + // Its own cookie jar holds the session between the three requests + const ctx = await pw_request.newContext({ + baseURL: backend_url, + ignoreHTTPSErrors: true, // the stack uses a self-signed cert + }); + try { + const signin = await ctx.post('/auth/signin', { + data: { email, password }, + }); + if (signin.status() !== 202) { + throw new Error( + `signin failed for ${email}: HTTP ${signin.status()}. ` + + `Is the stack up (e2e/stack/up.sh)?`, + ); + } + + const authorize = await ctx.get('/auth/oauth/authorize', { + params: { + response_type: 'code', + client_id, + redirect_uri, + scope: 'public', + state: 'e2e', + code_challenge: challenge, + code_challenge_method: 'S256', + }, + maxRedirects: 0, + }); + const location = authorize.headers()['location'] ?? ''; + const code = new URL(location, backend_url).searchParams.get('code'); + if (!code) { + throw new Error( + `authorize gave no code for ${email}: HTTP ${authorize.status()} ` + + `location=${location}. Is there an OAuth app for ${redirect_uri}? ` + + `seed.ts registers it.`, + ); + } + + const token = await ctx.post('/auth/oauth/token', { + form: { + grant_type: 'authorization_code', + client_id, + redirect_uri, + code, + code_verifier: verifier, + }, + }); + if (!token.ok()) { + throw new Error( + `token exchange failed for ${email}: HTTP ${token.status()} ${await token.text()}`, + ); + } + const body = (await token.json()) as Omit; + return { ...body, expires_in: body.expires_in ?? 3600, client_id }; + } finally { + await ctx.dispose(); + } +} + +/** A storageState that holds ts-client's token keys on the app origin */ +export function buildStorageState(mint: MintResult, app_url: string) { + const local_storage = [ + { name: `${mint.client_id}_access_token`, value: mint.access_token }, + { + name: `${mint.client_id}_expires_at`, + value: String(Date.now() + mint.expires_in * 1000), + }, + ]; + if (mint.refresh_token) { + local_storage.push({ + name: `${mint.client_id}_refresh_token`, + value: mint.refresh_token, + }); + } + return { + cookies: [], + origins: [ + { origin: new URL(app_url).origin, localStorage: local_storage }, + ], + }; +} diff --git a/e2e/support/env.ts b/e2e/support/env.ts new file mode 100644 index 000000000..95701c4a1 --- /dev/null +++ b/e2e/support/env.ts @@ -0,0 +1,75 @@ +/** + * Configuration for the local-backend e2e suite. + * + * This suite runs against a LOCAL backend only. `assertLocalOnly()` refuses + * any host that is not loopback before a browser launches: specs create and + * delete real data. + * + * Two origins, which are not the same under the dev server: + * BACKEND_URL the PlaceOS stack (nginx TLS entrypoint) + * APP_URL the dev server. It proxies /api, /auth and /login through to + * BACKEND_URL, so the browser only sees APP_URL, and the token + * lives in APP_URL's localStorage. + */ +import * as path from 'path'; + +// Load e2e/.env before any value below is read +try { + require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') }); +} catch { + // dotenv not available, continue with the process environment +} + +/** Auth artifacts. Resolved from this file, because the cwd changes with the runner */ +export const AUTH_DIR = path.resolve(__dirname, '..', '.auth'); + +const LOCAL_HOSTS = ['localhost', '127.0.0.1', '::1', '[::1]']; + +/** The PlaceOS stack under test (`e2e/stack/up.sh`) */ +export const BACKEND_URL = + process.env['E2E_BACKEND_URL'] ?? 'https://localhost:9543'; + +/** + * Where the dev server serves the SPA. Not 4200, so that a developer's own dev + * server, which proxies to a shared deployment, is never reused by mistake. + */ +export const APP_URL = process.env['E2E_APP_URL'] ?? 'http://localhost:4216'; + +/** Throw if any URL is not on a loopback host */ +export function assertLocalOnly(...urls: string[]): void { + for (const url of urls) { + const hostname = new URL(url).hostname; + if (!LOCAL_HOSTS.includes(hostname)) { + throw new Error( + `E2E refused: "${url}" is not a local backend (host "${hostname}").\n` + + `This suite creates and deletes real data. Point E2E_BACKEND_URL and ` + + `E2E_APP_URL at a loopback host and run e2e/stack/up.sh.`, + ); + } + } +} + +export type RoleName = 'admin' | 'staff'; + +export interface Role { + name: RoleName; + email: string; + password: string; + /** Playwright storageState file of this role */ + storage_path: string; +} + +/** The sys_admin that `init` creates, and a non-admin that seed.ts creates */ +export function roleFor(name: RoleName): Role { + const admin = name === 'admin'; + return { + name, + email: admin + ? (process.env['E2E_ADMIN_EMAIL'] ?? 'support@place.tech') + : 'e2e-staff@place.tech', + password: admin + ? (process.env['E2E_ADMIN_PASSWORD'] ?? 'development') + : (process.env['E2E_STAFF_PASSWORD'] ?? 'e2e-staff-development'), + storage_path: path.join(AUTH_DIR, `${name}.json`), + }; +} diff --git a/e2e/support/fixtures.ts b/e2e/support/fixtures.ts new file mode 100644 index 000000000..82e07ef51 --- /dev/null +++ b/e2e/support/fixtures.ts @@ -0,0 +1,44 @@ +/** + * Fixtures for the local-backend specs. Import `test` and `expect` from here. + * + * `page` is signed in as the admin (the project's default storageState). + * Specs about sign-in itself opt out with + * `test.use({ storageState: { cookies: [], origins: [] } })`. + */ +import { APIRequestContext, Page, test as base } from '@playwright/test'; +import { apiFor } from './api'; +import { APP_URL, roleFor } from './env'; + +interface TestFixtures { + /** A page signed in as the non-admin user */ + staffPage: Page; +} + +interface WorkerFixtures { + /** An API context with an admin bearer, to make and check data */ + api: APIRequestContext; +} + +export const test = base.extend({ + api: [ + // Playwright requires the object pattern, even with no dependencies + // eslint-disable-next-line no-empty-pattern + async ({}, use) => { + const api = await apiFor('admin'); + await use(api); + await api.dispose(); + }, + { scope: 'worker' }, + ], + + staffPage: async ({ browser }, use) => { + const ctx = await browser.newContext({ + baseURL: APP_URL, + storageState: roleFor('staff').storage_path, + }); + await use(await ctx.newPage()); + await ctx.close(); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/e2e/support/seed.ts b/e2e/support/seed.ts new file mode 100644 index 000000000..ddc454dca --- /dev/null +++ b/e2e/support/seed.ts @@ -0,0 +1,142 @@ +/** + * Idempotent, API-driven seeding of the local stack. Every step is an upsert + * keyed on a natural identifier, so it is safe to run before every suite. + * + * It goes through the API, not SQL: rows written straight into Postgres skip + * model validation and derived columns (e.g. users are found by + * `email_digest`), and look right while they behave wrong. + * + * To mint a token we need an OAuth app, and to register an app we need a + * token. So we sign in through the `backoffice` app that `init` always makes + * (redirect_uri `${BACKEND_URL}/backoffice/oauth-resp.html`). + */ +import { APIRequestContext } from '@playwright/test'; +import { apiFor, asList, ENGINE_API, getJson } from './api'; +import { clientId, redirectUriFor } from './auth'; +import { APP_URL, BACKEND_URL, roleFor } from './env'; + +interface Authority { + id: string; + login_url?: string; +} + +/** + * The authority of this stack. Polls, because /domains is served from + * Elasticsearch: on a cold stack the row is in Postgres before search-ingest + * has indexed it. + */ +async function authority(api: APIRequestContext): Promise { + for (let i = 0; i < 45; i++) { + const [found] = asList( + await getJson(api, `${ENGINE_API}/domains`), + ); + if (found) return found; + await new Promise((r) => setTimeout(r, 2000)); + } + throw new Error( + `no authority at ${ENGINE_API}/domains after 90s. \`init start\` makes it; ` + + `check search-ingest: docker compose -p backoffice-e2e logs search-ingest`, + ); +} + +/** Rejections that mean "the row is already there" */ +function alreadyExists(body: string): boolean { + return /already (exists|taken)|has already been taken|must be unique|duplicate/i.test( + body, + ); +} + +/** + * An OAuth app for the dev server's redirect_uri. ts-client uses + * `Md5(redirect_uri)` as its client_id, and authorize refuses a client_id + * with no app. + */ +async function ensureOAuthApp(api: APIRequestContext, owner_id: string) { + const redirect_uri = redirectUriFor(APP_URL); + const uid = clientId(redirect_uri); + const apps = asList<{ uid: string }>( + await getJson(api, `${ENGINE_API}/oauth_apps`, { limit: '500' }), + ); + if (apps.some((a) => a.uid === uid)) return 'present'; + const res = await api.post(`${ENGINE_API}/oauth_apps`, { + data: { + name: 'backoffice (e2e dev server)', + redirect_uri, + scopes: 'public', + owner_id, + confidential: false, + }, + }); + const body = await res.text(); + // The list reads from Elasticsearch and can lag a row Postgres has + if (!res.ok() && !alreadyExists(body)) { + throw new Error( + `create oauth_app failed: HTTP ${res.status()} ${body}`, + ); + } + return 'created'; +} + +/** + * A fresh authority has a relative `/login?continue={{url}}`. ts-client + * resolves it against the authority's `domain`, a host with no port, so the + * browser goes to `http://localhost/login` and dead-ends. Make it absolute. + */ +async function ensureAbsoluteLoginUrl(api: APIRequestContext, auth: Authority) { + if (/^https?:\/\//i.test(auth.login_url ?? '')) return 'ok'; + const res = await api.patch(`${ENGINE_API}/domains/${auth.id}`, { + data: { login_url: `${BACKEND_URL}/login?continue={{url}}` }, + }); + if (!res.ok()) { + throw new Error( + `patch login_url failed: HTTP ${res.status()} ${await res.text()}`, + ); + } + return 'patched'; +} + +/** + * A non-admin user. Access rules must be checked as a non-admin, because a + * sys_admin passes every check. An existing user gets the password again, so + * a changed E2E_STAFF_PASSWORD still signs in. + */ +async function ensureStaffUser(api: APIRequestContext, authority_id: string) { + const role = roleFor('staff'); + const data = { + name: 'E2E Staff (non-admin)', + password: role.password, + sys_admin: false, + support: false, + }; + const users = asList<{ id: string; email: string }>( + await getJson(api, `${ENGINE_API}/users`, { q: role.email }), + ); + const user = users.find((u) => u.email === role.email); + const res = user + ? await api.patch(`${ENGINE_API}/users/${user.id}`, { data }) + : await api.post(`${ENGINE_API}/users`, { + data: { ...data, email: role.email, authority_id }, + }); + const body = await res.text(); + if (!res.ok() && !alreadyExists(body)) { + throw new Error( + `upsert ${role.email} failed: HTTP ${res.status()} ${body}`, + ); + } + return user ? 'updated' : 'created'; +} + +/** Seed the fixtures the suite needs. Returns a line per step for the log */ +export async function seed(): Promise { + const api = await apiFor('admin', `${BACKEND_URL}/backoffice/`); + try { + const auth = await authority(api); + return [ + `oauth app ${await ensureOAuthApp(api, auth.id)} ${redirectUriFor(APP_URL)}`, + `login_url ${await ensureAbsoluteLoginUrl(api, auth)}`, + `staff user ${await ensureStaffUser(api, auth.id)} ${roleFor('staff').email}`, + ]; + } finally { + await api.dispose(); + } +} diff --git a/e2e/support/stack.setup.ts b/e2e/support/stack.setup.ts new file mode 100644 index 000000000..cabaef8e0 --- /dev/null +++ b/e2e/support/stack.setup.ts @@ -0,0 +1,51 @@ +/** + * Setup project. The `local` project depends on it, so it runs once before + * the specs: it checks the stack is up, seeds it and writes a storageState + * per role into `.auth/`. + * + * The stack is a precondition, not something Playwright starts. This fails + * in seconds with what to do, not after a minute of navigation timeouts. + */ +import { request, test as setup } from '@playwright/test'; +import * as fs from 'fs'; +import { buildStorageState, mintToken } from './auth'; +import { APP_URL, AUTH_DIR, BACKEND_URL, RoleName, roleFor } from './env'; +import { seed } from './seed'; + +setup('the local stack is up and seeded', async () => { + setup.setTimeout(120_000); + + const ctx = await request.newContext({ + baseURL: BACKEND_URL, + ignoreHTTPSErrors: true, + timeout: 10_000, + }); + const status = await ctx + .get('/api/engine/v2/') + .then((res) => res.status()) + .catch((e: Error) => e.message) + .finally(() => ctx.dispose()); + if (typeof status !== 'number' || status >= 500) { + throw new Error( + `The local PlaceOS stack is not reachable at ${BACKEND_URL} (${status}).\n` + + `Start it with: e2e/stack/up.sh`, + ); + } + + for (const line of await seed()) console.log(` ${line}`); + + fs.mkdirSync(AUTH_DIR, { recursive: true }); + const roles: RoleName[] = ['admin', 'staff']; + for (const role of roles.map(roleFor)) { + const mint = await mintToken( + BACKEND_URL, + APP_URL, + role.email, + role.password, + ); + fs.writeFileSync( + role.storage_path, + JSON.stringify(buildStorageState(mint, APP_URL), null, 2), + ); + } +}); diff --git a/package.json b/package.json index b9c8bece4..76afba9bd 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "test": "nx test", "test:ci": "nx test backoffice", "lint": "nx lint backoffice", - "e2e": "nx e2e backoffice" + "e2e": "nx e2e backoffice", + "e2e:local": "playwright test --config e2e/playwright.local.config.ts" }, "private": true, "dependencies": { From bc3bd3cd0e16b54c21263d49deb21e56ee03e5b5 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 21 Oct 2026 18:58:14 +1100 Subject: [PATCH 3/3] ci(e2e): run the local stack suite on a self-hosted runner Advisory only. No pull_request trigger, because the repository is public and the runner is self-hosted. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/e2e-local.yml | 201 ++++++++++++++++++++++++++++++++ e2e/stack/ci-summary.mjs | 79 +++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 .github/workflows/e2e-local.yml create mode 100644 e2e/stack/ci-summary.mjs diff --git a/.github/workflows/e2e-local.yml b/.github/workflows/e2e-local.yml new file mode 100644 index 000000000..b4a746b0f --- /dev/null +++ b/.github/workflows/e2e-local.yml @@ -0,0 +1,201 @@ +name: E2E LOCAL (advisory) + +# Runs the local-backend e2e suite (e2e/src/local) against the isolated +# PlaceOS stack in e2e/stack, on a self-hosted runner. See "CI" in +# e2e/README.md. +# +# ADVISORY: do not add this job to branch protection or the required checks +# until the suite has a track record (no false failures, a known flake rate). +# That is a repository setting; nothing in this file can make it required. +# +# There is NO `pull_request` trigger, on purpose. This repository is public and +# the runner is self-hosted, so a fork pull request could run any code on a +# machine in an internal network. Every trigger here needs write access to the +# repository. Do not add `pull_request` without a move to `ubuntu-latest`. +on: + workflow_dispatch: + push: + branches: + # The branches BUILD deploys from + - develop + - 'release/**' + - 'rc/**' + # To work on this suite and this workflow + - 'e2e/**' + schedule: + # Daily. Scheduled runs use the default branch only. 30 minutes after + # the user-interfaces nightly, which can share the runner. + - cron: '40 1 * * *' + +# The event is in the group, so a push to develop does not cancel a nightly +# run on the same ref. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +# The job only reads the code. The runner machine is persistent, so it must not +# hold a token that can push. +permissions: + contents: read + +jobs: + e2e-local: + name: 'local e2e (advisory, does not block builds)' + # Targeted by its custom label, so the machine can change without an + # edit here. The runner must be registered for this repository. + runs-on: [self-hosted, placeos-e2e] + timeout-minutes: 30 + env: + E2E_BACKEND_URL: https://localhost:9543 + E2E_APP_URL: http://localhost:4216 + TZ: 'Etc/UTC' + steps: + # Checkout otherwise writes the token to .git/config, which stays on + # this machine + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # No actions/cache and no setup-node: the workspace and toolchain + # stay on this machine, and an upload to the remote cache costs more + # than it saves. no-cache keeps bun out of the GitHub cache. Keep the + # version the same as test.yml and build.yml. + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.11' + no-cache: true + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Setup version + run: bun run postinstall + + # This machine is not a fresh VM. An aborted run can leave the + # stack up or the dev server port held. + - name: Reclaim the machine from an earlier run + run: | + e2e/stack/down.sh --volumes || true + lsof -nP -iTCP:4216 -sTCP:LISTEN -t 2>/dev/null | xargs kill -9 2>/dev/null || true + docker image prune -f || true + + - name: Install Playwright browser + run: bunx playwright install chromium + + # Without a pull, `latest` stays the image of the day the runner was + # set up. A pull that fails or stalls must not fail the run: the + # stack then starts on the images that are present, and the next + # step records which. + - name: Pull the current backend images + timeout-minutes: 15 + continue-on-error: true + run: e2e/stack/up.sh --pull-only + + # Needs GitHub (frontend-loader clones PlaceOS/www-core for the + # /login page) and Docker Hub. + - name: Bring up the local PlaceOS stack + timeout-minutes: 10 + run: e2e/stack/up.sh + + # The PlaceOS images track ${PLACEOS_TAG:-latest}, so a result can + # change with no frontend commit. Record the image ids, so a changed + # nightly can be explained. Set PLACEOS_TAG in `env` to pin them. + - name: Record backend inputs + run: | + { + echo '### Backend inputs' + echo + echo '| image | id |' + echo '|---|---|' + docker compose -p backoffice-e2e -f e2e/stack/docker-compose.yml images \ + --format json 2>/dev/null \ + | node -e ' + let raw = ""; + process.stdin.on("data", (d) => (raw += d)); + process.stdin.on("end", () => { + let rows = []; + try { rows = JSON.parse(raw); } catch { return; } + for (const r of rows) { + console.log(`| ${r.Repository}:${r.Tag} | \`${(r.ID || "").slice(0, 19)}\` |`); + } + }); + ' || true + echo + echo "www-core HEAD: \`$(git ls-remote https://github.com/PlaceOS/www-core HEAD | cut -f1)\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Run local e2e + run: bun run e2e:local + + - name: Summarise results + if: ${{ always() }} + run: node e2e/stack/ci-summary.mjs >> "$GITHUB_STEP_SUMMARY" + + # Every service, not a selected list: the log that explains a + # failure is often from a service nobody expected. + - name: Collect backend logs + if: ${{ failure() }} + working-directory: e2e/stack + run: | + mkdir -p ../../e2e-logs + docker compose -p backoffice-e2e ps --all > ../../e2e-logs/_ps.txt 2>&1 || true + for svc in $(docker compose -p backoffice-e2e config --services 2>/dev/null); do + docker compose -p backoffice-e2e logs --no-color "$svc" \ + > "../../e2e-logs/$svc.log" 2>&1 || true + done + + - name: Upload Playwright report + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report-local + path: | + dist/.playwright/e2e-local/report + dist/.playwright/e2e-local/results.json + retention-days: 14 + if-no-files-found: warn + + - name: Upload failure artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-failures-local + path: | + dist/.playwright/e2e-local/test-output + e2e-logs + retention-days: 14 + if-no-files-found: warn + + # The volumes go too: every run starts from a cold stack + - name: Tear down the stack + if: ${{ always() }} + run: e2e/stack/down.sh --volumes + + # Only on failure, so a green run stays quiet. Plain curl, not a + # third-party action at a mutable ref: this runs on a persistent + # machine. The secrets go through `env`, so they are not on the + # command line. + - name: Notify on failure + if: ${{ failure() }} + continue-on-error: true + env: + STATUS_URL: ${{ secrets.STATUS_URL }} + CHAT_URL: ${{ secrets.CHAT_URL }} + REPO: ${{ github.repository }} + COMMIT: ${{ github.sha }} + BRANCH: ${{ github.ref_name }} + RUN_ID: ${{ github.run_id }} + run: | + node -e ' + process.stdout.write(JSON.stringify({ + chat_url: process.env.CHAT_URL, + name: process.env.REPO, + commit: process.env.COMMIT, + branch: process.env.BRANCH, + url: `https://github.com/${process.env.REPO}/actions/runs/${process.env.RUN_ID}`, + pipeline_name: "backoffice e2e local (advisory) FAILED", + status: "failure", + })); + ' | curl --silent --show-error --fail --max-time 30 \ + --header 'Content-Type: application/json' --data @- "$STATUS_URL" diff --git a/e2e/stack/ci-summary.mjs b/e2e/stack/ci-summary.mjs new file mode 100644 index 000000000..5760c26e8 --- /dev/null +++ b/e2e/stack/ci-summary.mjs @@ -0,0 +1,79 @@ +/** + * Write the GitHub step summary from Playwright's JSON report. + * + * It shows the flaky specs (passed on retry) on their own. While the suite is + * advisory, they are the signal that it cannot be trusted yet, and "all + * green" hides them. + * + * Always exits 0: the test step decides pass or fail. + * + * node e2e/stack/ci-summary.mjs >> "$GITHUB_STEP_SUMMARY" + */ +import { readFileSync } from 'fs'; + +const REPORT = 'dist/.playwright/e2e-local/results.json'; +const out = (s = '') => process.stdout.write(`${s}\n`); + +let report; +try { + report = JSON.parse(readFileSync(REPORT, 'utf8')); +} catch (e) { + out('## backoffice e2e (local backend)'); + out(); + out( + `No JSON report at \`${REPORT}\`. The run stopped before Playwright started.`, + ); + out(); + out(`> ${e.message}`); + process.exit(0); +} + +/** Every spec in a suite tree */ +function* specsOf(suite) { + yield* suite.specs ?? []; + for (const child of suite.suites ?? []) yield* specsOf(child); +} + +/** Playwright's test status: expected, unexpected, flaky or skipped */ +const STATE = { + expected: 'passed', + unexpected: 'failed', + flaky: 'flaky', + skipped: 'skipped', +}; +const rows = (report.suites ?? []) + .flatMap((suite) => [...specsOf(suite)]) + .flatMap((spec) => + spec.tests.map((test) => ({ + name: `\`${spec.file}\`: ${spec.title} [${test.projectName}]`, + state: STATE[test.status] ?? test.status, + })), + ); +const named = (state) => rows.filter((r) => r.state === state); + +out('## backoffice e2e (local backend), advisory'); +out(); +out('| result | count |'); +out('|---|---|'); +for (const state of ['passed', 'flaky', 'failed', 'skipped']) { + out(`| ${state} | ${named(state).length} |`); +} +out(); +out(`Duration: ${((report.stats?.duration ?? 0) / 1000).toFixed(1)}s`); +out(); +for (const [state, note] of [ + [ + 'flaky', + 'These passed on a retry. Find the cause before the suite gates merges.', + ], + ['failed', 'The traces, videos and backend logs are in the run artifacts.'], +]) { + if (!named(state).length) continue; + out(`### ${state[0].toUpperCase()}${state.slice(1)}`); + out(); + out(note); + out(); + for (const r of named(state)) out(`- ${r.name}`); + out(); +} +out('This check is advisory. It does not gate merges.');