diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8ce6a58 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Copy to .env and adjust. Non-secret config (images, host paths, ports, users). +# Secrets (license, cloud storage creds) go in .env.secret — see .env.secret.example. + +# --- Lakekeeper images --- +LK_IMAGE_OSS=quay.io/lakekeeper/catalog:latest-main +LK_IMAGE_PLUS=quay.io/vakamo/lakekeeper-plus:latest + +# --- Host paths (point these at your local checkouts / CI workspace) --- +KEYCLOAK_REALM_FILE=../lakekeeper/examples/access-control-simple/keycloak/realm.json +# Self-contained Cedar policy dir shipped with this repo (the cedar access-control +# test mutates policies.cedar live). Don't point it at a real policy dir. +CEDAR_POLICY_DIR=./cedar + +# --- App locations + ports (the apps that consume this framework) --- +CONSOLE_DIR=../console +CONSOLE_PLUS_DIR=../console-plus +# Run the apps on :3001 — the AWS demo bucket's CORS allows that origin, so the +# in-browser LoQE (DuckDB-WASM) Iceberg reads/writes work. A different port breaks +# the storage CORS (the CORS test proves :3002 is blocked). +APP_PORT=3001 + +# --- Test users (from the iceberg realm; peter is the instance admin) --- +TEST_USERNAME=peter +TEST_PASSWORD=iceberg +TEST_USER_EMAIL=peter@example.com +TEST_ADMIN_USERNAME=peter +TEST_ADMIN_PASSWORD=iceberg +TEST_ADMIN_EMAIL=peter@example.com diff --git a/.env.secret.example b/.env.secret.example new file mode 100644 index 0000000..f35916a --- /dev/null +++ b/.env.secret.example @@ -0,0 +1,46 @@ +# Copy to .env.secret (gitignored). Local SeaweedFS S3 needs nothing here. +# Each cloud backend activates only when ALL its vars are set; otherwise skipped. +# In GitHub these come from Actions secrets. + +# --- cedar mode (Plus) --- +LAKEKEEPER__LICENSE__KEY= +NPM_GITHUB_TOKEN= + +# --- AWS S3 (use a throwaway, least-privilege key — never a real prod key) --- +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_S3_BUCKET=vk-lakekeeper-demo +AWS_REGION=us-east-1 +# optional (STS / remote-signing profile) +AWS_KEY_PREFIX= +AWS_STS_ROLE_ARN= + +# --- Cloudflare R2 --- +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_ACCOUNT_ID= +R2_BUCKET= + +# --- Azure ADLS --- +ADLS_ACCOUNT_NAME= +ADLS_FILESYSTEM= +ADLS_CLIENT_ID= +ADLS_CLIENT_SECRET= +ADLS_TENANT_ID= + +# --- Microsoft OneLake --- +ONELAKE_ACCOUNT_NAME= +ONELAKE_FILESYSTEM= +ONELAKE_CLIENT_ID= +ONELAKE_CLIENT_SECRET= +ONELAKE_TENANT_ID= + +# --- Google GCS --- +GCS_BUCKET= +GCS_SERVICE_ACCOUNT_KEY= + +# --- Local SeaweedFS S3 (defaults; override only if you change s3.json) --- +# S3_LOCAL_ACCESS_KEY=lakekeeper +# S3_LOCAL_SECRET_KEY=lakekeeper-secret +# S3_LOCAL_BUCKET=lakekeeper-test +# S3_LOCAL_ENDPOINT=http://seaweedfs:8333 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f817d45 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: ci + +# Self-CI for the test framework itself — keeps console-e2e's own code valid. +# It does NOT run the matrix (that needs a Lakekeeper stack, the Plus image, and +# secrets — that's the separate release gate). This is fast (~1 min), no stack, +# no secrets: typecheck, syntax-check the orchestration, and confirm the specs + +# dynamic Playwright config load for both webServer branches. + +on: + push: + branches: ['**'] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install + run: npm ci + + - name: Typecheck (specs + playwright.config) + run: npx tsc --noEmit -p tsconfig.json + + - name: Syntax-check orchestration scripts + run: node --check run.mjs && node --check dashboard.mjs && node --check catalog.mjs && node --check reporters/current.mjs + + - name: Specs + config load (authn = dual webServer, cedar = premium path) + run: | + APP=console TEST_MODE=authn npx playwright test --list + APP=console-plus TEST_MODE=cedar npx playwright test --list diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d7d8573 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +node_modules/ +.env +.env.secret +test-results/ +playwright-report/ +blob-report/ +reports/ +results/ +history/ +TEST-CATALOG.html +DASHBOARD.html +.cache/ +coverage/ +monocart-report/ +*.log +loqe-debug.png +TEST-CATALOG.md +.raw/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c8193fe --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,83 @@ +# Agent Instructions — console-e2e + +E2E test engine for the Lakekeeper consoles (`console`, `console-plus`). It brings up +a real Lakekeeper stack via **podman compose**, serves the app with Playwright's +`webServer`, and runs browser journeys across a matrix of **app × auth-mode × +browser**. Read [README.md](README.md) first for the user-facing overview. + +## Architecture (where things live) + +- **`run.mjs`** — the orchestrator. Loops `app × mode`; per combo: `compose down -v` + → up infra → `migrate` → serve lakekeeper → poll `/health` → `runPlaywright()` → + cross-browser passes → archive + rebuild dashboard → teardown. Also runs the + component **unit tests** once up front (`runUnitTests()`). +- **`docker-compose.yml`** — Postgres, Keycloak (`:30080`), OpenFGA, SeaweedFS + (`:8333`) + bucket-init, Lakekeeper (`:8181`). Image + `modes/.env` swapped + per combo. Postgres/OpenFGA publish **no host ports** (avoid clashes). +- **`modes/.env`** — backend `LAKEKEEPER__*` (container) + `VITE_*` (build-time + app flags). `noauth`/`authn`/`authz`(OpenFGA)/`cedar`(Cedar, premium). +- **`playwright.config.ts`** — dynamic from `APP`/`TEST_MODE`/`BROWSER` env. Loads the + mode's `VITE_*` into the dev-server env, grep-filters specs by `@` tag, + launches the app on `APP_PORT`. A **second `:3002` webServer** starts only in + `authn` (for the CORS test). `reuseExistingServer:false` + `--strictPort`. +- **`specs/`** — `_data/` (storage backends), `_fixtures/` (auth + coverage), + `_utils/` (login, warehouse, loqe, permissions, cedar, app helpers), and the + journeys by area. +- **`dashboard.mjs` / `catalog.mjs`** — build `DASHBOARD.html` / `TEST-CATALOG`. + `reporters/current.mjs` writes `results/current.json` (live test name for the + dashboard banner). + +## Conventions + +- **Tags drive selection.** Every `test.describe` is tagged with the modes it applies + to: `@noauth @authn @authz @cedar`. `run.mjs`/config grep by `@`. Untagged → + never runs. `@smoke` is the cross-browser (firefox/webkit) subset. Access-control is + `@authz` (OpenFGA, UI grant) and a separate `@cedar` block (policy-file grant). +- **Fixtures**: use `bootstrappedPage` (logged in + server bootstrapped, peter) from + `_fixtures/auth.fixture.ts`. A second user `anna` (non-admin) is `TEST_USER_2`. +- **Helpers, not inline flows**: warehouse create/open/namespace → `_utils/warehouse.ts`; + LoQE attach/exec/create+read → `_utils/loqe.ts`; grants → `_utils/permissions.ts` + (FGA UI) / `_utils/cedar.ts` (policy file); recover from the false offline page → + `_utils/app.ts`. Seeding helpers are **idempotent** (combos share backend state). +- **Robust selectors**: prefer `getByRole`/`getByText`. The Vuetify Permissions v-tab + resets while data loads — click until `aria-selected=true`. A restricted user's + first calls 401 during token hydration — reload until the warehouse appears. +- **Per-action timeout** is capped globally (`use.actionTimeout`) so a stuck click + fails fast and retries instead of eating the test timeout. + +## Hard-won gotchas (don't relearn these) + +- **podman, not docker** — `docker` is a shell alias invisible to `spawn`; `run.mjs` + uses `podman compose`. +- **App must run on `:3001`** — the AWS demo bucket's CORS allows that origin; LoQE + browser→S3 reads/writes fail on any other port. `--strictPort` enforces it. +- **AWS LoQE writes need an STS-enabled warehouse** (`sts-enabled` + `sts-role-arn` + + key-prefix). Plain access-key creds write to the bucket root and 404. A green + `CREATE` is not proof — assert the read-back. +- **Split-horizon SeaweedFS** — single host-LAN-IP endpoint for browser + container. +- **CORS error wording is browser-specific** — chromium says "…CORS/404", firefox says + "Cannot read N bytes from memory buffer". `console-components` LoQEEngine maps both + to a friendly message; the CORS test asserts the friendly message (don't tighten to + a chromium-only string). +- **Multi-statement SQL** makes one result tab per statement — run one statement at a + time and settle on the "Running query…" spinner disappearing. + +## Adding things + +- **A test**: new `specs//.spec.ts`, tag the describe with applicable + modes, reuse `_utils` helpers + `bootstrappedPage`. Add its path to `SPEC_ORDER` in + both `dashboard.mjs` and `catalog.mjs` for journey ordering. +- **A mode**: add `modes/.env` + wire it in `run.mjs` (`ALL_MODES`, `APP_MODES`, + `SERVICES`) and `dashboard.mjs` (`MODE_LABEL`). +- **A storage backend**: add an entry to `specs/_data/storage-backends.ts` + (skip-if-absent via its env creds; `deepFlows` gates browser-reachable flows). + +## Rules + +- **Never commit secrets.** `.env` / `.env.secret` are git-ignored; `*.example` files + hold placeholders only. Generated artifacts (`coverage/`, `results/`, `reports/`, + `DASHBOARD.html`, `TEST-CATALOG.*`) are ignored — don't commit them. +- **Validate before a matrix run**: `node --check run.mjs dashboard.mjs`, and + `npx playwright test --list` to confirm the config + reporters load. +- Changes to `console-components` (a sibling repo) go via its **PR workflow**, not a + direct push, and need the `BEGIN_COMMIT_OVERRIDE` block. diff --git a/README.md b/README.md new file mode 100644 index 0000000..43536e8 --- /dev/null +++ b/README.md @@ -0,0 +1,284 @@ +# console-e2e — Lakekeeper Console test engine + +End-to-end test matrix for the Lakekeeper web consoles. One command spins up a full +Lakekeeper stack, serves the app, drives real user journeys in a browser, and +produces a visual pass/fail **dashboard** — across both apps, every auth mode, and +multiple browsers. + +- **`console`** — the open-source console +- **`console-plus`** — the premium console (adds Cedar authorization) + +> New here? Read **[Quick start](#quick-start)**, run `just test-one console authn`, +> then open the dashboard. The rest of this doc explains the matrix, the modes, and +> the gotchas. + +--- + +## What gets tested — the matrix + +Every cell is a full stack brought up from scratch, the app served, and the journeys +run end to end: + +| app | `noauth` | `authn` | `authz` (OpenFGA) | `cedar` | +| --- | :---: | :---: | :---: | :---: | +| **console** | ✅ | ✅ | ✅ | — | +| **console-plus** | ✅ | ✅ | ✅ | ✅ | + +…and each combo runs on **chromium** (full suite), plus **firefox** (full parity) +and **webkit** (smoke) as a third dimension. Cedar is premium-only, so it only +appears for `console-plus`. + +**The modes** (this is the whole point — Lakekeeper behaves very differently): + +| mode | authN | authZ | notes | +| --- | --- | --- | --- | +| `noauth` | off | off | anonymous; anyone can do anything | +| `authn` | on | off | must log in; no permission gate | +| `authz` | on | **OpenFGA** | relationship-based grants (roles, per-object grants) | +| `cedar` | on | **Cedar** | policy-file based; **premium only**; no roles | + +--- + +## What the journeys cover + +Each spec is a realistic user flow, not a unit test (see [`specs/`](specs/)): + +| spec | what it proves | +| --- | --- | +| `bootstrap/` | first-run server bootstrap (the stepper) | +| `auth/login`, `auth/logout`, `auth/noauth-access` | the auth lifecycle per mode | +| `flows/warehouse-lifecycle` | create warehouse → see it in the tree → open it → add a namespace (one journey **per storage backend**) | +| `flows/loqe` | the in-browser **DuckDB-WASM** engine: initializes, then **creates + queries an Iceberg table** end to end (writes parquet to S3 from the browser, reads `1` back) | +| `flows/role` | role CRUD (authz/cedar) | +| `perms/access-control` | **permission enforcement**: a non-admin (`anna`) is *denied* reading a table until an admin (`peter`) grants her — via the **Permissions UI** (OpenFGA) or a **policy edit** (Cedar) | +| `storage/cors` | the storage-CORS gate: a real LoQE `SELECT *` **works on `:3001`** (the bucket's allowed origin) but is **blocked on `:3002`** — with screenshots/video of the in-app error | +| `smoke/route-smoke` | major routes render (the cross-browser smoke subset, tagged `@smoke`) | + +Component **unit tests** (Vitest, in the component repos) run once per matrix and +show up on the dashboard too — see [Unit tests](#unit-tests). + +--- + +## Quick start + +### Prerequisites + +- **podman** with `podman compose` (this engine drives podman, not docker — see + [Why podman](#why-podman-not-docker)). Verify: `podman compose version`. +- **Node 20+** and **[just](https://github.com/casey/just)** (`brew install just`). +- The sibling app + component repos checked out next to this one: + ``` + / + console-e2e/ ← you are here + console/ ← OSS app + console-plus/ ← premium app + console-components/ ← shared component library + lakekeeper/ ← for the Keycloak realm + (optional) policies + ``` + +### 1. Configure + +```bash +cp .env.example .env # non-secret config (paths, ports, images) +cp .env.secret.example .env.secret # secrets (Cedar license, cloud creds) +``` +Edit both. `.env` and `.env.secret` are **git-ignored** — never commit them. See +[Configuration](#configuration). + +### 2. Install + +```bash +just test-setup # npm install + playwright browser download (chromium) +npx playwright install firefox webkit # only if you want the cross-browser dims +``` + +### 3. Run + +```bash +just test-one console authn # one combo (~3 min) — best first run +just test-matrix # the whole matrix (~35 min) — the release gate +just test-dashboard # open the visual matrix in a browser +``` + +That's it. `just --list` shows every recipe. + +--- + +## Commands + +All recipes are namespaced `test-`: + +| command | what it does | +| --- | --- | +| `just test-setup` | install deps + chromium | +| `just test-one ` | one combo, e.g. `just test-one console-plus cedar` | +| `just test-app ` | one app, all its modes | +| `just test-mode ` | one mode, both apps | +| `just test-matrix` | **the full matrix** (both apps × all modes × browsers) | +| `just test-unit` | component-repo Vitest unit tests (standalone) | +| `just test-coverage` | e2e code coverage on the chromium combos (see [Coverage](#coverage)) | +| `just test-dashboard` | build + serve the matrix dashboard | +| `just test-catalog` | list every defined test case (planning view, no run) | +| `just test-report` | open the merged Playwright report (traces/video/screenshots) | +| `just test-report-combo ` | open one combo's report | +| `just test-up [app]` | bring the stack up and **leave it running** (for `test-ui`) | +| `just test-ui [app] [mode]` | Playwright UI to browse/run tests (needs `test-up` first) | +| `just test-history` | list archived runs (timestamped, kept until you delete them) | +| `just test-history-keep ` | prune to the newest N archives | +| `just test-down` | tear the stack down | + +--- + +## The dashboard & reports + +- **`just test-dashboard`** → `DASHBOARD.html`: every test × every app·mode·browser + with the latest pass/fail. Frozen header + frozen first column, scrollable. While a + run is active it shows an **in-progress** banner with the **live test name**. It + *accumulates* across runs (a partial run only updates its own columns). +- **`just test-report`** → the merged Playwright report: traces, video, and a + screenshot of every step (e.g. the CORS test's `:3001` result vs `:3002` error box). +- **`just test-catalog`** → `TEST-CATALOG.md`/`.html`: what test cases exist, ordered + by journey — a planning view that needs no run. +- **History**: every full run is archived under `history/__` and + kept until you delete it (`just test-history`, `test-history-keep`). + +--- + +## Configuration + +Two files, both git-ignored: + +**`.env`** — non-secret: image tags, host paths to the sibling repos, `APP_PORT`, +test usernames. Key values: +- `APP_PORT=3001` — run the apps on `:3001`. The AWS demo bucket's CORS allows that + origin, so the in-browser LoQE Iceberg reads/writes pass. A different port breaks + storage CORS (the `storage/cors` test proves `:3002` is blocked). +- `CEDAR_POLICY_DIR=./cedar` — a **self-contained** policy dir shipped in this repo. + The Cedar access-control test mutates `policies.cedar` live; don't point this at a + real policy dir. + +**`.env.secret`** — secrets: the Cedar **license key** (premium image), and cloud +storage credentials (AWS/R2/ADLS/…). Storage backends are **skip-if-absent**: a +backend only runs when its creds are present. ⚠️ Use throwaway, least-privilege keys +— never a prod key. + +--- + +## Storage backends + +The warehouse + LoQE journeys run **per enabled backend** (see +[`specs/_data/storage-backends.ts`](specs/_data/storage-backends.ts)): + +- **SeaweedFS** — a local S3, started by the stack; always on. Reachable from both the + browser and the lakekeeper container via the host LAN IP (auto-detected by + `run.mjs` to solve the [split-horizon](#split-horizon) problem). Create+verify only + (no deep browser flows by default). +- **AWS S3** — enabled when `AWS_*` creds are set; runs the **deep flows** (open + detail, LoQE table create/read). Requires an **STS-enabled** warehouse + a bucket + CORS that allows `http://localhost:3001` — see [LoQE & CORS](#loqe--storage-cors). +- **R2 / ADLS / OneLake / GCS** — enabled when their creds are set; otherwise skipped. + +--- + +## Unit tests + +Component-repo **Vitest** tests (e.g. `console-components/src/plugins/authToken.test.ts`, +which guards the token-hydration fix) are browser-independent, so `run.mjs` runs them +**once per matrix** and shows the result on the dashboard. Run standalone with +`just test-unit`. Disable in a matrix run with `NO_UNIT=1`. + +--- + +## Coverage + +`just test-coverage` collects **V8 code coverage** during the chromium combos +(coverage is chromium-only) and maps it back to source via sourcemaps, producing a +report per combo under `coverage/`. It builds `console-components` with sourcemaps +first so coverage maps to real source. (Component-library mapping additionally needs +the source-alias — see [troubleshooting](#troubleshooting).) E2E coverage is +broad/integration-level; for line coverage of the library use the Vitest unit tests. + +--- + +## How it works + +- **`run.mjs`** — the orchestrator. For each `app × mode`: `compose down -v` → + bring up infra → migrate → serve lakekeeper → wait for health → run Playwright → + (cross-browser passes) → archive + rebuild the dashboard → tear down. +- **`docker-compose.yml`** — Postgres, Keycloak, OpenFGA, SeaweedFS (+ bucket-init), + and Lakekeeper. The Lakekeeper image and `modes/.env` are swapped per combo. +- **`modes/.env`** — the per-mode knobs: backend `LAKEKEEPER__*` (container) + + `VITE_*` (build-time app flags, e.g. `VITE_ENABLE_AUTHENTICATION`). +- **`playwright.config.ts`** — dynamic: reads `APP`/`TEST_MODE`/`BROWSER`, loads that + mode's `VITE_*` into the dev-server env, tags-filters specs by mode, and launches + the app on `APP_PORT`. +- **`specs/`** — the journeys + shared helpers (`_utils/`, `_fixtures/`, `_data/`). + +--- + +## Troubleshooting + +#### Why podman, not docker +`docker` on this setup is a shell alias to podman (invisible to Node's `spawn`), and a +standalone `docker-compose` binary targets the wrong socket. `run.mjs` drives +`podman compose` so it wires podman's own socket. Override with `COMPOSE_BIN` / +`COMPOSE_SUBCMD` for a real docker install. + +#### LoQE & storage CORS +The in-browser DuckDB-WASM engine reads/writes Iceberg data **straight from the +browser to S3**. That only works when (a) the app origin is **`:3001`** and the +bucket's CORS allows it, and (b) for AWS, the warehouse is **STS-enabled** (plain +access-key creds write to the bucket root and 404). The catalog API is CORS-`*`, so +the *tree* loads on any origin — but *data* reads need bucket CORS. The `storage/cors` +test demonstrates `:3001` works / `:3002` is blocked. + +#### Split-horizon +Local SeaweedFS must be reachable from **both** the browser (host) and the lakekeeper +container, which see different hostnames. `run.mjs` auto-detects the host LAN IP and +uses it as the single endpoint so the SigV4 signature matches on both sides. + +#### "Lakekeeper Unreachable" / bootstrap timeouts +If the app flashes a **"Lakekeeper Unreachable / Check status"** page, lakekeeper is +usually *not* down — it's a token-hydration race (an early request goes out before the +token is ready). The helpers recover from it (`_utils/app.ts`). It traces back to a +real console-components bug worth fixing (the request interceptor should not attach +`Bearer undefined`). + +#### Ports +`run.mjs` frees `:3001`/`:3002` and **waits** for release before each combo +(`--strictPort` means a half-released port would otherwise fail the server with +"address already in use"). If a run dies mid-combo, `just test-down` cleans up. + +#### DuckDB-WASM version +LoQE is pinned to DuckDB-WASM **1.4.x**; 1.5.x regressed Iceberg writes. + +--- + +## When to run (cadence) + +Match the scope to the change: + +- **While coding** — the relevant unit test (`vitest`) or one spec via `just test-ui`. +- **Before a PR** — `just test-one console authn` (~3 min) + the unit tests. +- **Before a release** — `just test-matrix` (the gate). Ideally in CI so it doesn't + block your machine. + +--- + +## Layout + +``` +run.mjs orchestrator (the matrix loop) +docker-compose.yml the Lakekeeper stack +modes/*.env per-mode backend + VITE_* config +cedar/policies.cedar self-contained Cedar base policy (test mutates it) +playwright.config.ts dynamic per app/mode/browser +dashboard.mjs builds DASHBOARD.html +catalog.mjs builds TEST-CATALOG +reporters/current.mjs live "now-running test" marker for the dashboard +specs/ + _data/ storage backends + _fixtures/ auth + coverage fixtures + _utils/ login, warehouse, loqe, permissions, cedar, app helpers + /*.spec.ts the journeys +``` diff --git a/catalog.mjs b/catalog.mjs new file mode 100644 index 0000000..3ba0954 --- /dev/null +++ b/catalog.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +/** + * Test catalog generator — a CENTRAL, static overview of which test cases exist + * (for planning), independent of any run. Scans spec files across the + * distributed locations and emits a feature × mode matrix + the full case list. + * + * Usage: + * node catalog.mjs # scans the dirs in SCAN below + * node catalog.mjs > TEST-CATALOG.md + * + * Add more roots (e.g. the private premium specs, or component-test dirs) to SCAN. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); + +// Locations to scan. Each: { label, root, glob, layer }. Missing roots are skipped, +// so this works even before the other repos are checked out alongside. +const SCAN = [ + { label: 'e2e (shared, public)', root: path.join(dir, 'specs'), match: /\.spec\.ts$/, layer: 'e2e' }, + { label: 'e2e premium (console-plus, private)', root: path.resolve(dir, '../console-plus/e2e/specs'), match: /\.spec\.ts$/, layer: 'e2e' }, + { label: 'component (console-components, public)', root: path.resolve(dir, '../console-components/src'), match: /\.(test|spec)\.ts$/, layer: 'component' }, + { label: 'component (console-plus-components, private)', root: path.resolve(dir, '../console-plus-components/src'), match: /\.(test|spec)\.ts$/, layer: 'component' }, +]; + +const MODES = ['noauth', 'authn', 'authz', 'cedar']; + +function walk(root, match, acc = []) { + if (!fs.existsSync(root)) return acc; + for (const e of fs.readdirSync(root, { withFileTypes: true })) { + if (e.name === 'node_modules' || e.name.startsWith('.')) continue; + const p = path.join(root, e.name); + if (e.isDirectory()) walk(p, match, acc); + else if (match.test(e.name)) acc.push(p); + } + return acc; +} + +// Extract test cases: title, tags (@mode), fixme/skip status, and source line. +function parseFile(file) { + const src = fs.readFileSync(file, 'utf8'); + const lines = src.split('\n'); + const cases = []; + const re = /\b(?:test|it)(\.fixme|\.skip)?\s*\(\s*[`'"](.+?)[`'"]/; + // nearest enclosing describe tags (simple: last describe seen above) + let describeTags = []; + lines.forEach((line, i) => { + const d = line.match(/describe\s*\(\s*[`'"](.+?)[`'"]/); + if (d) describeTags = [...(d[1].match(/@\w+/g) || [])]; + const m = line.match(re); + if (m) { + const title = m[2]; + const tags = [...new Set([...describeTags, ...(title.match(/@\w+/g) || [])])]; + cases.push({ + title: title.replace(/@\w+/g, '').replace(/\$\{.*?\}/g, '*').trim(), + tags, + status: m[1] ? m[1].slice(1) : 'active', // fixme | skip | active + line: i + 1, + }); + } + }); + return cases; +} + +// Logical journey order for spec files (a user's real flow), not alphabetical. +const SPEC_ORDER = [ + 'bootstrap/bootstrap', + 'auth/login', + 'auth/noauth-access', + 'auth/logout', + 'smoke/route-smoke', + 'flows/warehouse-lifecycle', + 'flows/loqe', + 'flows/warehouse', + 'flows/role', + 'perms/access-control', + 'storage/cors', +]; +const orderOf = (file) => { + const i = SPEC_ORDER.findIndex((s) => file.includes(s)); + return i === -1 ? SPEC_ORDER.length : i; +}; + +const groups = []; +for (const s of SCAN) { + const files = walk(s.root, s.match); + if (!files.length) continue; + const items = files.flatMap((f) => + parseFile(f).map((c) => ({ ...c, file: path.relative(s.root, f) })), + ); + // e2e: order by the journey sequence (then by line); component: keep file/line. + items.sort((a, b) => + s.layer === 'e2e' + ? orderOf(a.file) - orderOf(b.file) || a.file.localeCompare(b.file) || a.line - b.line + : a.file.localeCompare(b.file) || a.line - b.line, + ); + if (items.length) groups.push({ ...s, items }); +} + +// --- render markdown --- +const out = []; +out.push('# Test Catalog', ''); +out.push('_Static inventory of test cases for planning (generated by `catalog.mjs`, no run)._', ''); + +let total = 0, + active = 0, + pending = 0; + +for (const g of groups) { + out.push(`## ${g.label} — ${g.layer}`, ''); + if (g.layer === 'e2e') { + out.push('| Spec | Case | ' + MODES.join(' | ') + ' | Status |'); + out.push('|---|---|' + MODES.map(() => '---').join('|') + '|---|'); + for (const c of g.items) { + total++; + c.status === 'active' ? active++ : pending++; + const cells = MODES.map((m) => (c.tags.includes('@' + m) ? '✓' : '')); + out.push(`| ${c.file} | ${c.title} | ${cells.join(' | ')} | ${c.status === 'active' ? '' : '🟡 ' + c.status} |`); + } + } else { + out.push('| File | Case | Status |'); + out.push('|---|---|---|'); + for (const c of g.items) { + total++; + c.status === 'active' ? active++ : pending++; + out.push(`| ${c.file} | ${c.title} | ${c.status === 'active' ? '' : '🟡 ' + c.status} |`); + } + } + out.push(''); +} + +if (!groups.length) out.push('_No test files found in the scanned roots._', ''); + +out.push('---', `**${total} cases** — ${active} active, ${pending} pending (fixme/skip). Modes: ${MODES.join(', ')}.`); +const md = out.join('\n'); +console.log(md); +fs.writeFileSync(path.join(dir, 'TEST-CATALOG.md'), md + '\n'); + +// --- also emit a standalone visual HTML overview (a stable "overview portal") --- +const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); +const tick = (on) => (on ? '' : '·'); +const sections = groups + .map((g) => { + const head = + g.layer === 'e2e' + ? `SpecCase${MODES.map((m) => `${m}`).join('')}Status` + : `FileCaseStatus`; + const rows = g.items + .map((c) => { + const status = c.status === 'active' ? '' : `${c.status}`; + if (g.layer === 'e2e') { + const cells = MODES.map((m) => `${tick(c.tags.includes('@' + m))}`).join(''); + return `${esc(c.file)}${esc(c.title)}${cells}${status}`; + } + return `${esc(c.file)}${esc(c.title)}${status}`; + }) + .join(''); + return `

${esc(g.label)} ${g.layer}

${head}${rows}
`; + }) + .join(''); +const html = `Test Catalog + +

Test Catalog

+

What test cases exist (planning view, generated — no run). Results live in the Playwright report.

+${sections || '

No test files found.

'} +

${total} cases — ${active} active, ${pending} pending. A ✓ means that case runs in that auth mode; an empty cell is an uncovered scenario.

`; +fs.writeFileSync(path.join(dir, 'TEST-CATALOG.html'), html); diff --git a/cedar/policies.cedar b/cedar/policies.cedar new file mode 100644 index 0000000..4ef54c1 --- /dev/null +++ b/cedar/policies.cedar @@ -0,0 +1,9 @@ +// Base Cedar policy for the console-e2e `cedar` mode. SELF-CONTAINED. +// peter is the instance admin; anna starts with NO grant (denied). Managed by the +// cedar access-control test via _utils/cedar.ts. + +permit ( + principal == Lakekeeper::User::"oidc~cfb55bf6-fcbb-4a1e-bfec-30c6649b52f8", + action, + resource +); diff --git a/dashboard.mjs b/dashboard.mjs new file mode 100644 index 0000000..3d84a37 --- /dev/null +++ b/dashboard.mjs @@ -0,0 +1,241 @@ +#!/usr/bin/env node +/** + * Matrix dashboard — ONE page showing every test × every app×mode with the + * LATEST pass/fail, accumulated across runs (it does NOT shrink on a partial run). + * + * Reads results/-.json (Playwright JSON reporter output; one file per + * combo, overwritten only when that combo runs) and renders DASHBOARD.html. + * Each column header shows when that combo last ran, so stale cells are visible. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const resultsDir = path.join(dir, 'results'); +const files = fs.existsSync(resultsDir) + ? fs.readdirSync(resultsDir).filter((f) => f.endsWith('.json') && f !== 'current.json' && !f.startsWith('unit')) + : []; // current.json (live marker) + unit*.json (component unit tests) are not combos + +// Component unit tests (Vitest), written by run.mjs runUnitTests() → results/unit.json. +let unit = []; +try { + unit = JSON.parse(fs.readFileSync(path.join(resultsDir, 'unit.json'), 'utf8')); +} catch { + /* no unit results yet */ +} + +const combos = {}; // combo -> { startTime, tests: Map(key->status) } +const allTests = new Map(); // key -> { file, title } + +function statusOf(spec) { + const t = spec.tests?.[0]; + const s = t?.status; // expected | unexpected | flaky | skipped + if (s === 'expected') return 'passed'; + if (s === 'unexpected') return 'failed'; + if (s === 'flaky') return 'flaky'; + if (s === 'skipped') return 'skipped'; + return spec.ok ? 'passed' : 'failed'; +} + +function walk(suite, combo, fileHint) { + const file = suite.file || fileHint; + for (const spec of suite.specs || []) { + const f = spec.file || file; + const key = `${f} › ${spec.title}`; + allTests.set(key, { file: f, title: spec.title }); + combos[combo].tests.set(key, statusOf(spec)); + } + for (const s of suite.suites || []) walk(s, combo, file); +} + +for (const f of files) { + const combo = f.replace(/\.json$/, ''); + let data; + try { + data = JSON.parse(fs.readFileSync(path.join(resultsDir, f), 'utf8')); + } catch { + continue; + } + combos[combo] = { startTime: data.stats?.startTime || null, tests: new Map() }; + for (const s of data.suites || []) walk(s, combo, null); +} + +const comboNames = Object.keys(combos).sort(); +// Preserve definition order (Playwright reports specs in file order), NOT alpha. +const testKeys = [...allTests.keys()]; + +// Logical journey order for the spec files (a user's real flow), not alphabetical. +const SPEC_ORDER = [ + 'bootstrap/bootstrap', + 'auth/login', + 'auth/noauth-access', + 'auth/logout', + 'smoke/route-smoke', + 'flows/warehouse-lifecycle', + 'flows/loqe', + 'flows/warehouse', + 'flows/role', + 'perms/access-control', + 'storage/cors', +]; +const orderOf = (file) => { + const i = SPEC_ORDER.findIndex((s) => file.includes(s)); + return i === -1 ? SPEC_ORDER.length : i; +}; + +// Human label per mode — makes explicit that authZ has TWO backends (OpenFGA, +// Cedar); both are "authZ on", just different authorizers. +const MODE_LABEL = { + noauth: 'authN ✗ · authZ ✗', + authn: 'authN ✓ · authZ ✗', + authz: 'authN ✓ · authZ OpenFGA', + cedar: 'authN ✓ · authZ Cedar', + // Cross-browser @smoke columns (combo = -authn-). + firefox: 'Firefox · @smoke', + webkit: 'Safari/WebKit · @smoke', +}; +// Combo key is - or --; the trailing segment is the +// label key in both cases (browser for cross-browser columns, else the mode). +const modeOf = (combo) => combo.split('-').pop(); + +const ICON = { + passed: '', + failed: '', + flaky: '⚠️', + skipped: '', +}; +const cell = (st) => (st ? ICON[st] || st : '·'); +const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); + +// group rows by file +const byFile = {}; +for (const k of testKeys) { + const { file, title } = allTests.get(k); + (byFile[file] ||= []).push({ key: k, title }); +} + +const fmt = (iso) => { + if (!iso) return '—'; + const d = new Date(iso); + return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +}; + +// per-combo tallies +const tally = (combo) => { + let p = 0, f = 0, k = 0, s = 0; + for (const st of combos[combo].tests.values()) { + if (st === 'passed') p++; + else if (st === 'failed') f++; + else if (st === 'flaky') k++; + else if (st === 'skipped') s++; + } + return { p, f, k, s }; +}; + +const header = + `Test` + + comboNames + .map((c) => { + const t = tally(c); + const cls = t.f ? 'colfail' : 'colok'; + // Link the column to that combo's NATIVE Playwright report (drill into + // traces/video/screenshots). Served from the same dir as this dashboard. + const link = `reports/${c}/index.html`; + const label = MODE_LABEL[modeOf(c)] || ''; + return `${esc(c)} ↗
${esc(label)}
${t.p}✅ ${t.f}❌${t.k ? ` ${t.k}⚠️` : ''}${t.s ? ` ${t.s}➖` : ''} · ${fmt(combos[c].startTime)}
`; + }) + .join('') + + ``; + +const rows = Object.keys(byFile) + .sort((a, b) => orderOf(a) - orderOf(b) || a.localeCompare(b)) + .map((file) => { + const fileRow = `${esc(file)}`; + const testRows = byFile[file] + .map((t) => { + const cells = comboNames.map((c) => `${cell(combos[c].tests.get(t.key))}`).join(''); + return `${esc(t.title)}${cells}`; + }) + .join(''); + return fileRow + testRows; + }) + .join(''); + +const totalP = comboNames.reduce((a, c) => a + tally(c).p, 0); +const totalF = comboNames.reduce((a, c) => a + tally(c).f, 0); + +// Run-in-progress state (run.mjs sets these while a run is active). When running, +// refresh faster and show a clear "in progress" banner so the matrix isn't +// mistaken for the final result. +const running = !!process.env.RUN_IN_PROGRESS; +const runCurrent = process.env.RUN_CURRENT || ''; +const refreshSecs = running ? 8 : 60; +const runBanner = running + ? `` + : ''; +// Client-side poll of results/current.json (written by reporters/current.mjs) so the +// banner shows the live test name BETWEEN the per-combo HTML rebuilds. +const livePoll = running + ? `` + : ''; + +const html = `Test Matrix Dashboard + + +

Test Matrix Dashboard

+

Every test × every app·mode. Latest result per combo (accumulates across runs — partial runs only update their own columns). Auto-refreshes every 30s. ✅ pass · ❌ fail · ⚠️ flaky · ➖ skipped/n-a · · not run in that mode.
+Click a combo column ↗ to open its native Playwright report (traces/video). Full merged report: playwright-report ↗.

+${runBanner} +${ + unit.length + ? `
Component unit tests (Vitest) — run once per matrix, browser-independent:${unit + .map((u) => + u.skipped + ? ` ➖ ${esc(u.repo)} (${esc(u.skipped)})` + : ` ${u.failed ? '❌' : '✅'} ${esc(u.repo)} ${u.passed}/${u.total}`, + ) + .join(' · ')}
` + : '' +} +${comboNames.length ? `
${header}${rows}
` : '

No results yet — run just test-matrix.

'} +${livePoll}`; + +fs.writeFileSync(path.join(dir, 'DASHBOARD.html'), html); +console.log(`Matrix dashboard → console-e2e/DASHBOARD.html (${comboNames.length} combos, ${testKeys.length} tests, ${totalF} failing)`); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c6464df --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,107 @@ +# Backend stack for the E2E matrix engine. +# Orchestrated by run.mjs, which sets TEST_MODE (selects modes/.env) and +# LK_IMAGE (OSS vs Plus). Ports match the apps' existing .env so no app rewiring +# is needed: lakekeeper :8181, keycloak :30080, openfga :35081, postgres :5432. +# +# Host networking note: lakekeeper reaches keycloak via host.docker.internal:30080 +# (the *published* port) so the token issuer matches what the browser sees +# (http://localhost:30080). OPENID_ADDITIONAL_ISSUERS in the mode files accepts the +# localhost form too. + +x-lk-common: &lk-common + image: ${LK_IMAGE:-quay.io/lakekeeper/catalog:latest-main} + env_file: + - ./modes/${TEST_MODE:-authn}.env + environment: + # Injected from .env.secret by run.mjs (only the cedar mode needs it). + LAKEKEEPER__LICENSE__KEY: ${LAKEKEEPER__LICENSE__KEY:-} + volumes: + # Cedar policy files. Harmless to mount in other modes. + - ${CEDAR_POLICY_DIR}:/policies:ro + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + postgres: + condition: service_healthy + +services: + postgres: + image: postgres:16.4 + command: ["-c", "max_connections=10000"] + environment: + POSTGRES_PASSWORD: postgres + # No host port published — only reached by lakekeeper over the compose network. + # Avoids clashing with a dev postgres on :5432. + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 2s + timeout: 3s + retries: 30 + + openfga: + image: openfga/openfga:v1.8 + command: run + # No host ports — lakekeeper reaches it at openfga:8081 over the compose network. + + # S3-compatible object storage for warehouse tests (SeaweedFS). Reached by + # lakekeeper at http://seaweedfs:8333 over the compose network; browser/LoQE + # use localhost:8333. Credentials come from seaweedfs/s3.json. + seaweedfs: + image: chrislusf/seaweedfs:latest + command: server -s3 -s3.config=/etc/seaweedfs/s3.json -dir=/data -ip.bind=0.0.0.0 + volumes: + - ./seaweedfs/s3.json:/etc/seaweedfs/s3.json:ro + ports: + - "8333:8333" # S3 API (browser/LoQE) + # No healthcheck — the seaweedfs image has no wget/curl. bucket-init retries + # the S3 endpoint itself until it's ready, which is the real readiness gate. + + # One-shot: create the warehouse test bucket via the S3 API (mc = the S3 CLI; + # works against any S3-compatible store, here SeaweedFS). + bucket-init: + image: amazon/aws-cli:latest + depends_on: + - seaweedfs + environment: + AWS_ACCESS_KEY_ID: lakekeeper + AWS_SECRET_ACCESS_KEY: lakekeeper-secret + AWS_DEFAULT_REGION: us-east-1 + entrypoint: ["/bin/sh", "-c"] + command: + - > + for i in $$(seq 1 30); do + aws --endpoint-url http://seaweedfs:8333 s3 mb s3://lakekeeper-test && break; + echo 'waiting for seaweedfs s3...'; sleep 2; + done; + aws --endpoint-url http://seaweedfs:8333 s3api put-bucket-cors --bucket lakekeeper-test + --cors-configuration '{"CORSRules":[{"AllowedOrigins":["*"],"AllowedMethods":["GET","PUT","POST","DELETE","HEAD"],"AllowedHeaders":["*"],"ExposeHeaders":["ETag"]}]}' || true + restart: "no" + + keycloak: + image: quay.io/keycloak/keycloak:26.0.7 + command: + - start-dev + - --import-realm + - --health-enabled=true + - --features=token-exchange + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + volumes: + - ${KEYCLOAK_REALM_FILE}:/opt/keycloak/data/import/realm.json:ro + ports: + - "30080:8080" + + # One-shot DB/authz migration. Run via `docker compose run --rm migrate`. + migrate: + <<: *lk-common + command: migrate + restart: "no" + + lakekeeper: + <<: *lk-common + command: serve + ports: + - "8181:8181" + # Readiness is polled over HTTP by run.mjs (http://localhost:8181/health), + # which avoids depending on the in-container binary path. diff --git a/justfile b/justfile new file mode 100644 index 0000000..a707f11 --- /dev/null +++ b/justfile @@ -0,0 +1,95 @@ +# console-e2e test framework. Run from the console-e2e/ directory. +# All recipes are namespaced under `test-`. List them with `just --list`. + +# Install deps + browser +test-setup: + npm install + npx playwright install chromium + +# Full matrix: both apps × all modes +test-matrix: + node run.mjs + +# E2E CODE COVERAGE (chromium combos only — V8/CDP is chromium-only). Builds +# console-components WITH sourcemaps so coverage maps to real source, clears the +# apps' vite caches so the fresh dist is used, runs the chromium matrix collecting +# V8 coverage, then MERGES every combo into one report at coverage/index.html. +test-coverage: + cd ../console-components && npx vite build --sourcemap >/dev/null + rm -rf ../console/node_modules/.vite ../console-plus/node_modules/.vite coverage + E2E_COVERAGE=1 NO_CROSS_BROWSER=1 node run.mjs + npx monocart-coverage-reports --input coverage/.raw --output coverage --reports v8,console-summary + @echo "→ open console-e2e/coverage/index.html" + +# Component-repo UNIT tests (Vitest) — fast, browser-independent (the Tier-0 guard, +# e.g. the authToken/Bearer-undefined fix). Runs automatically as part of the matrix +# too; this is for running them standalone. +test-unit: + cd ../console-components && npm test + @cd ../console-plus-components 2>/dev/null && [ -f vitest.config.ts ] && npm test || echo "➖ console-plus-components: no unit tests yet" + +# One combo, e.g. `just test-one console authn` +test-one app mode: + node run.mjs --app {{app}} --mode {{mode}} + +# One app, all modes, e.g. `just test-app console-plus` +test-app app: + node run.mjs --app {{app}} + +# One mode, both apps, e.g. `just test-mode authz` +test-mode mode: + node run.mjs --mode {{mode}} + +# CENTRAL TEST CATALOG — what test cases exist (planning view, no run). +# Writes TEST-CATALOG.md + TEST-CATALOG.html (open the .html for the visual matrix). +test-catalog: + node catalog.mjs >/dev/null && echo "→ open console-e2e/TEST-CATALOG.html (visual) or TEST-CATALOG.md" + +# THE matrix dashboard — every test × every app·mode, latest pass/fail, +# accumulates across runs (partial runs only update their columns). Serves it. +test-dashboard: + node dashboard.mjs && python3 -m http.server 9325 --bind 127.0.0.1 --directory . >/dev/null 2>&1 & sleep 1 && echo "→ http://127.0.0.1:9325/DASHBOARD.html" + +# List archived runs (each kept until YOU delete it). +test-history: + @ls -1t history 2>/dev/null || echo "no runs yet — run `just test-matrix`" + +# Open a specific archived run, e.g. `just test-report-at 2026-06-28T10-30-00__full` +test-report-at run: + npx playwright show-report history/{{run}} + +# Keep only the N most recent run archives in history/ (delete older). +# e.g. `just test-history-keep 2` (default keeps 2) +test-history-keep n="2": + @ls -1dt history/*/ 2>/dev/null | tail -n +$(( {{n}} + 1 )) | while read d; do rm -rf "$d"; done; echo "kept newest {{n}} run archive(s); $(ls -1d history/*/ 2>/dev/null | wc -l | tr -d ' ') remain" + +# Delete archived runs older than N days. +test-history-prune days="14": + find history -maxdepth 1 -mindepth 1 -type d -mtime +{{days}} -exec rm -rf {} + 2>/dev/null; echo done + +# Bring the backend stack UP for a mode and LEAVE it running — required before +# `test-ui` if you want to actually RUN tests (not just browse the tree). +# e.g. `just test-up authn` then `just test-ui`. Tear down with `just test-down`. +test-up mode="authn" app="console": + node run.mjs --up --app {{app}} --mode {{mode}} + +# Playwright UI portal — browse/run ALL e2e tests in a tree. To RUN tests, first +# `just test-up ` (same mode), else the app has no Lakekeeper → failures. +test-ui app="console" mode="authn": + E2E_ALL=1 APP={{app}} TEST_MODE={{mode}} npx playwright test --ui + +# Vitest UI portal for the component tests (run from the component repo). +test-ui-components: + cd ../console-components && npx vitest --ui + +# Open the CENTRAL merged report across all combos (RESULTS, after a run). +test-report: + npx playwright show-report playwright-report + +# Open one combo's report, e.g. `just test-report-combo console authn` +test-report-combo app mode: + npx playwright show-report reports/{{app}}-{{mode}} + +# Tear everything down +test-down: + docker-compose -f docker-compose.yml down -v --remove-orphans diff --git a/modes/authn.env b/modes/authn.env new file mode 100644 index 0000000..530992f --- /dev/null +++ b/modes/authn.env @@ -0,0 +1,30 @@ +# Mode: authn — OIDC authentication, NO authz backend (everyone authenticated is allowed). + +# --- Backend (Lakekeeper) --- +LAKEKEEPER__PG_ENCRYPTION_KEY=abc +LAKEKEEPER__PG_DATABASE_URL_READ=postgresql://postgres:postgres@postgres:5432/postgres +LAKEKEEPER__PG_DATABASE_URL_WRITE=postgresql://postgres:postgres@postgres:5432/postgres +LAKEKEEPER__BASE_URI=http://localhost:8181 +LAKEKEEPER__LISTEN_PORT=8181 +LAKEKEEPER__ALLOW_ORIGIN=* +LAKEKEEPER__OPENID_PROVIDER_URI=http://host.docker.internal:30080/realms/iceberg +LAKEKEEPER__OPENID_ADDITIONAL_ISSUERS=http://localhost:30080/realms/iceberg +LAKEKEEPER__OPENID_AUDIENCE=lakekeeper +LAKEKEEPER__UI__OPENID_CLIENT_ID=lakekeeper +LAKEKEEPER__UI__OPENID_PROVIDER_URI=http://localhost:30080/realms/iceberg + +# --- Frontend (Vite) --- +VITE_ENABLE_AUTHENTICATION=true +VITE_ENABLE_PERMISSIONS=false +VITE_APP_ICEBERG_CATALOG_URL=http://localhost:8181 +VITE_BASE_URL_PREFIX= +VITE_IDP_AUTHORITY=http://localhost:30080/realms/iceberg/ +VITE_IDP_CLIENT_ID=lakekeeper +VITE_IDP_REDIRECT_PATH=/callback +VITE_IDP_SCOPE=openid profile email lakekeeper +VITE_IDP_RESOURCE= +VITE_IDP_POST_LOGOUT_REDIRECT_PATH=/logout +VITE_IDP_TOKEN_TYPE=access_token + +# Suppress the OSS user-survey popup during tests +VITE_ENABLE_USER_SURVEYS=false diff --git a/modes/authz.env b/modes/authz.env new file mode 100644 index 0000000..0a1da03 --- /dev/null +++ b/modes/authz.env @@ -0,0 +1,34 @@ +# Mode: authz — OIDC authentication + OpenFGA authorization (permissions enforced). + +# --- Backend (Lakekeeper) --- +LAKEKEEPER__AUTHZ_BACKEND=openfga +LAKEKEEPER__OPENFGA__ENDPOINT=http://openfga:8081 +LAKEKEEPER__PG_ENCRYPTION_KEY=abc +LAKEKEEPER__PG_DATABASE_URL_READ=postgresql://postgres:postgres@postgres:5432/postgres +LAKEKEEPER__PG_DATABASE_URL_WRITE=postgresql://postgres:postgres@postgres:5432/postgres +LAKEKEEPER__BASE_URI=http://localhost:8181 +LAKEKEEPER__LISTEN_PORT=8181 +LAKEKEEPER__ALLOW_ORIGIN=* +LAKEKEEPER__OPENID_PROVIDER_URI=http://host.docker.internal:30080/realms/iceberg +LAKEKEEPER__OPENID_ADDITIONAL_ISSUERS=http://localhost:30080/realms/iceberg +LAKEKEEPER__OPENID_AUDIENCE=lakekeeper +LAKEKEEPER__UI__OPENID_CLIENT_ID=lakekeeper +LAKEKEEPER__UI__OPENID_PROVIDER_URI=http://localhost:30080/realms/iceberg +# peter — first/instance admin so bootstrap can complete deterministically. +LAKEKEEPER__INSTANCE_ADMINS=["oidc~cfb55bf6-fcbb-4a1e-bfec-30c6649b52f8"] + +# --- Frontend (Vite) --- +VITE_ENABLE_AUTHENTICATION=true +VITE_ENABLE_PERMISSIONS=true +VITE_APP_ICEBERG_CATALOG_URL=http://localhost:8181 +VITE_BASE_URL_PREFIX= +VITE_IDP_AUTHORITY=http://localhost:30080/realms/iceberg/ +VITE_IDP_CLIENT_ID=lakekeeper +VITE_IDP_REDIRECT_PATH=/callback +VITE_IDP_SCOPE=openid profile email lakekeeper +VITE_IDP_RESOURCE= +VITE_IDP_POST_LOGOUT_REDIRECT_PATH=/logout +VITE_IDP_TOKEN_TYPE=access_token + +# Suppress the OSS user-survey popup during tests +VITE_ENABLE_USER_SURVEYS=false diff --git a/modes/cedar.env b/modes/cedar.env new file mode 100644 index 0000000..9de8979 --- /dev/null +++ b/modes/cedar.env @@ -0,0 +1,39 @@ +# Mode: cedar — OIDC + Cedar authorization (premium / console-plus only). +# Requires the Plus image (LK_IMAGE_PLUS) and a license key in .env.secret. +# run.mjs mounts CEDAR_POLICY_DIR at /policies in the lakekeeper container. + +# --- Backend (Lakekeeper Plus) --- +LAKEKEEPER__AUTHZ_BACKEND=cedar +LAKEKEEPER__CEDAR__POLICY_SOURCES__LOCAL_FILES=[/policies/policies.cedar] +LAKEKEEPER__CEDAR__USER_DERIVATIONS__SOURCE_ID_PARTS__SOURCE=source_id +LAKEKEEPER__CEDAR__USER_DERIVATIONS__SOURCE_ID_PARTS__PATTERN=^(?.+)$ +LAKEKEEPER__CEDAR__USER_DERIVATIONS__SOURCE_ID_PARTS__TRANSFORM=uppercase +LAKEKEEPER__PG_ENCRYPTION_KEY=abc +LAKEKEEPER__PG_DATABASE_URL_READ=postgresql://postgres:postgres@postgres:5432/postgres +LAKEKEEPER__PG_DATABASE_URL_WRITE=postgresql://postgres:postgres@postgres:5432/postgres +LAKEKEEPER__BASE_URI=http://localhost:8181 +LAKEKEEPER__LISTEN_PORT=8181 +LAKEKEEPER__ALLOW_ORIGIN=* +LAKEKEEPER__OPENID_PROVIDER_URI=http://host.docker.internal:30080/realms/iceberg +LAKEKEEPER__OPENID_ADDITIONAL_ISSUERS=http://localhost:30080/realms/iceberg +LAKEKEEPER__OPENID_AUDIENCE=lakekeeper +LAKEKEEPER__UI__OPENID_CLIENT_ID=lakekeeper +LAKEKEEPER__UI__OPENID_PROVIDER_URI=http://localhost:30080/realms/iceberg +LAKEKEEPER__INSTANCE_ADMINS=["oidc~cfb55bf6-fcbb-4a1e-bfec-30c6649b52f8"] +# LAKEKEEPER__LICENSE__KEY is injected from .env.secret by run.mjs. + +# --- Frontend (Vite) --- +VITE_ENABLE_AUTHENTICATION=true +VITE_ENABLE_PERMISSIONS=true +VITE_APP_ICEBERG_CATALOG_URL=http://localhost:8181 +VITE_BASE_URL_PREFIX= +VITE_IDP_AUTHORITY=http://localhost:30080/realms/iceberg/ +VITE_IDP_CLIENT_ID=lakekeeper +VITE_IDP_REDIRECT_PATH=/callback +VITE_IDP_SCOPE=openid profile email lakekeeper +VITE_IDP_RESOURCE= +VITE_IDP_POST_LOGOUT_REDIRECT_PATH=/logout +VITE_IDP_TOKEN_TYPE=access_token + +# Suppress the OSS user-survey popup during tests +VITE_ENABLE_USER_SURVEYS=false diff --git a/modes/noauth.env b/modes/noauth.env new file mode 100644 index 0000000..58212d7 --- /dev/null +++ b/modes/noauth.env @@ -0,0 +1,27 @@ +# Mode: noauth — no OIDC, no authz backend. Anonymous access. +# Consumed by the lakekeeper container (LAKEKEEPER__*, ignores VITE_*) AND by +# run.mjs which forwards the VITE_* vars to the app's dev server. + +# --- Backend (Lakekeeper) --- +LAKEKEEPER__PG_ENCRYPTION_KEY=abc +LAKEKEEPER__PG_DATABASE_URL_READ=postgresql://postgres:postgres@postgres:5432/postgres +LAKEKEEPER__PG_DATABASE_URL_WRITE=postgresql://postgres:postgres@postgres:5432/postgres +LAKEKEEPER__BASE_URI=http://localhost:8181 +LAKEKEEPER__LISTEN_PORT=8181 +LAKEKEEPER__ALLOW_ORIGIN=* + +# --- Frontend (Vite) — overrides app .env via process.env precedence --- +VITE_ENABLE_AUTHENTICATION=false +VITE_ENABLE_PERMISSIONS=false +VITE_APP_ICEBERG_CATALOG_URL=http://localhost:8181 +VITE_BASE_URL_PREFIX= +VITE_IDP_AUTHORITY= +VITE_IDP_CLIENT_ID= +VITE_IDP_REDIRECT_PATH=/callback +VITE_IDP_SCOPE=openid profile email +VITE_IDP_RESOURCE= +VITE_IDP_POST_LOGOUT_REDIRECT_PATH=/logout +VITE_IDP_TOKEN_TYPE=access_token + +# Suppress the OSS user-survey popup during tests +VITE_ENABLE_USER_SURVEYS=false diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..63e1f7c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,774 @@ +{ + "name": "@lakekeeper/console-e2e", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@lakekeeper/console-e2e", + "version": "0.1.0", + "license": "Apache-2.0", + "bin": { + "lakekeeper-e2e": "run.mjs" + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "dotenv": "^16.4.5", + "monocart-reporter": "^2.12.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-loose": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz", + "integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/console-grid": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/console-grid/-/console-grid-2.2.4.tgz", + "integrity": "sha512-OLjCRTiHhOpTRo9lQp/2FgJDyq5uQHwkEmVJulEnQ6JVf27oKKzXHZnNOv/e72V4++UdMZCrDWtvXW5sx4lyQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/eight-colors": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/eight-colors/-/eight-colors-1.3.3.tgz", + "integrity": "sha512-4B54S2Qi4pJjeHmCbDIsveQZWQ/TSSQng4ixYJ9/SYHHpeS5nYK0pzcHvWzWUfRsvJQjwoIENhAwqg59thQceg==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/foreground-child": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-4.0.3.tgz", + "integrity": "sha512-yeXZaNbCBGaT9giTpLPBdtedzjwhlJBUoL/R4BVQU5mn0TQXOHwVIl1Q2DMuBIdNno4ktA1abZ7dQFVxD6uHxw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-assert/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koa/-/koa-3.2.1.tgz", + "integrity": "sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^1.3.8", + "content-disposition": "~1.0.1", + "content-type": "^1.0.5", + "cookies": "~0.9.1", + "delegates": "^1.0.0", + "destroy": "^1.2.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.5.0", + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "mime-types": "^3.0.1", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/koa-static-resolver": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/koa-static-resolver/-/koa-static-resolver-1.0.6.tgz", + "integrity": "sha512-ZX5RshSzH8nFn05/vUNQzqw32nEigsPa67AVUr6ZuQxuGdnCcTLcdgr4C81+YbJjpgqKHfacMBd7NmJIbj7fXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lz-utils": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/lz-utils/-/lz-utils-2.1.1.tgz", + "integrity": "sha512-d3Thjos0PSJQAoyMj6vipSSrtrRHS7DImqUNR8x9NW3+zQIftPIbMJAWhi5nPdg5Q9zHz6lxtN8kp/VdMlhi/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/monocart-coverage-reports": { + "version": "2.12.12", + "resolved": "https://registry.npmjs.org/monocart-coverage-reports/-/monocart-coverage-reports-2.12.12.tgz", + "integrity": "sha512-d9FdUr2dn58Crweon0IE0zVi8r/i4vLvfvb2G7eL5vL5LfrxCB2X6F6qzuiwV6RioA4zbAI//7CYi6LjCvN3zA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "acorn-loose": "^8.5.2", + "acorn-walk": "^8.3.5", + "commander": "^14.0.3", + "console-grid": "^2.2.4", + "eight-colors": "^1.3.3", + "foreground-child": "^4.0.3", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "lz-utils": "^2.1.1", + "monocart-locator": "^1.0.3" + }, + "bin": { + "mcr": "lib/cli.js" + } + }, + "node_modules/monocart-locator": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/monocart-locator/-/monocart-locator-1.0.3.tgz", + "integrity": "sha512-pe29W2XAoA1WQmZZqxXoP7s06ZEXUhcb81086v68cqjk1HnVL7Q/iU/WJnnetxjPcLqwb4qG8vaSGUOMQU602g==", + "dev": true, + "license": "MIT" + }, + "node_modules/monocart-reporter": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/monocart-reporter/-/monocart-reporter-2.12.1.tgz", + "integrity": "sha512-Qw7sYv7IMsW1U/zcs03QEre5WlMiHZTSQV2316toPPk4QKBBplLwBIn6KIK4KRPFrOZEDNE0t5t2nZ06iZ5Uvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "console-grid": "^2.2.4", + "eight-colors": "^1.3.3", + "koa": "^3.2.1", + "koa-static-resolver": "^1.0.6", + "lz-utils": "^2.1.1", + "monocart-coverage-reports": "^2.12.12", + "monocart-locator": "^1.0.3" + }, + "bin": { + "monocart": "lib/cli.js" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..40053e8 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "@lakekeeper/console-e2e", + "version": "0.1.0", + "type": "module", + "description": "Shared E2E test framework for Lakekeeper console & console-plus — both apps × auth modes (noauth/authn/authz/cedar) against a real Lakekeeper stack.", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/lakekeeper/console-e2e.git" + }, + "bin": { + "lakekeeper-e2e": "./run.mjs" + }, + "files": [ + "run.mjs", + "playwright.config.ts", + "docker-compose.yml", + "modes", + "seaweedfs", + "specs", + "justfile", + ".env.example", + ".env.secret.example", + "README.md" + ], + "scripts": { + "matrix": "node run.mjs", + "test": "playwright test", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "dotenv": "^16.4.5", + "monocart-reporter": "^2.12.1" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..179dbd6 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,158 @@ +import { defineConfig, devices } from '@playwright/test'; +import * as dotenv from 'dotenv'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); + +// Shared, non-secret config (images, paths, ports, test users). +dotenv.config({ path: path.resolve(dir, '.env') }); +// Optional secrets (cedar license etc.). +dotenv.config({ path: path.resolve(dir, '.env.secret') }); + +const mode = process.env.TEST_MODE || 'authn'; +const app = process.env.APP || 'console'; +const port = process.env.APP_PORT || '5001'; +const baseURL = `http://localhost:${port}`; +// Second app origin for the storage CORS test (a real LoQE SELECT * from a +// non-allowed origin). The AWS bucket CORS allows :3001 but not :3002. +const port2 = '3002'; + +// Browser dimension (3D matrix). chromium runs EVERYTHING for the mode; firefox / +// webkit run only the @smoke subset (cross-browser sanity) — the deep flows (LoQE +// DuckDB-WASM) are chromium-only. Default chromium. +const browser = process.env.BROWSER || 'chromium'; +const browserDevice: Record = { + chromium: 'Desktop Chrome', + firefox: 'Desktop Firefox', + webkit: 'Desktop Safari', +}; +const isCross = browser !== 'chromium'; +// Combo key: chromium uses app-mode; firefox/webkit append the browser so their +// results land in their own columns/report. +const combo = isCross ? `${app}-${mode}-${browser}` : `${app}-${mode}`; + +// Resolve which app to serve. +const appDir = + app === 'console-plus' + ? process.env.CONSOLE_PLUS_DIR || path.resolve(dir, '../console-plus') + : process.env.CONSOLE_DIR || path.resolve(dir, '../console'); + +// Pull the VITE_* vars for this mode and forward them to the dev server, where +// they override the app's own .env via Vite's process.env precedence. +const modeEnv = dotenv.parse(fs.readFileSync(path.resolve(dir, `modes/${mode}.env`))); +const viteEnv: Record = {}; +for (const [k, v] of Object.entries(modeEnv)) { + if (k.startsWith('VITE_')) viteEnv[k] = v; +} + +export default defineConfig({ + testDir: './specs', + fullyParallel: false, // one backend on a fixed port → keep app state deterministic + forbidOnly: !!process.env.CI, + retries: process.env.E2E_RETRIES ? Number(process.env.E2E_RETRIES) : 2, // absorb OIDC/token races + workers: 1, + expect: { timeout: 10_000 }, + reporter: [ + ['list'], + // Per-combo HTML (kept for drill-down). + ['html', { outputFolder: `reports/${combo}`, open: 'never' }], + // Blob → merged into ONE central report across all combos by run.mjs. + ['blob', { outputDir: path.resolve(dir, 'blob-report'), fileName: `${combo}.zip` }], + // JSON → consumed by dashboard.mjs to build the matrix dashboard. + ['json', { outputFile: path.resolve(dir, 'results', `${combo}.json`) }], + // Writes the currently-running test to results/current.json so the dashboard + // banner can show the live test name (polled client-side). Bulletproof. + ['./reporters/current.mjs', { combo }], + // V8 code coverage (E2E_COVERAGE=1, chromium only — see the _coverage fixture). + // Maps browser coverage back to console-components/console source via sourcemaps + // (console-components must be built with --sourcemap; see `just test-coverage`). + ...(process.env.E2E_COVERAGE === '1' && browser === 'chromium' + ? [ + [ + 'monocart-reporter', + { + name: `LoQE E2E coverage — ${combo}`, + outputFile: path.resolve(dir, 'coverage', combo, 'index.html'), + coverage: { + // raw V8 dumps too, so run.mjs can MERGE all chromium combos into + // one matrix-wide coverage report. + reports: [['v8'], ['raw', { outputDir: path.resolve(dir, 'coverage', '.raw', combo) }]], + entryFilter: (entry: { url: string }) => + !entry.url.includes('duckdb') && + (entry.url.includes('console-components') || entry.url.includes('localhost:3001')), + sourceFilter: (sourcePath: string) => + /(console-components|console-plus|console)\/src\//.test(sourcePath), + }, + }, + ], + ] + : []), + ], + // chromium: run all specs tagged for the active mode (e.g. @authz). firefox/webkit: + // run only the cross-browser @smoke subset. E2E_ALL=1 (just ui) browses everything. + // chromium + firefox run the FULL mode suite (firefox is tested like chrome). + // webkit runs only the @smoke subset (Safari/WebKit DuckDB-WASM support is limited). + grep: process.env.E2E_ALL + ? undefined + : browser === 'webkit' + ? new RegExp(`@smoke\\b`) + : new RegExp(`@${mode}\\b`), + + use: { + baseURL, + // Cap every action (click/fill/check). Without this a stuck click waits out the + // whole test timeout (we saw a firefox grant-UI click hang the full 5 min before + // retrying). 20s is generous for a real click but fails a hung one fast so the + // retry kicks in within seconds, not minutes. + actionTimeout: 20_000, + // Trace = full step-by-step DOM/console/network timeline (the richest debug + // view); kept to retries to stay light. Flip to 'on' for a full demo capture. + trace: 'on-first-retry', + // Screenshot + video for EVERY test (pass or fail), so the central report is + // a visual record of each flow. For CI you can switch these back to + // 'only-on-failure' / 'retain-on-failure' to save disk + time. + screenshot: 'on', + video: process.env.E2E_LIGHT ? 'retain-on-failure' : 'on', + }, + + projects: [ + { + name: combo, + use: { ...devices[browserDevice[browser]] }, + }, + ], + + webServer: [ + { + // dev:test hardcodes :5001 — pass the port explicitly so APP_PORT wins + // (e.g. :3001, which the AWS bucket's CORS allows for browser→S3 in LoQE). + // --strictPort: FAIL if the port is taken instead of silently bumping to + // 3002+ (which would change the browser origin and break the bucket CORS). + command: `npm run dev -- --port ${port} --strictPort`, + cwd: appDir, + url: baseURL, + // Never reuse: each (app × mode) combo needs its own server with mode-specific + // VITE_* env. Reusing would bleed the previous combo's app/config across runs. + reuseExistingServer: false, + timeout: 120_000, + env: viteEnv, + }, + // A SECOND app instance on :3002, only in authn mode — used by the storage CORS + // test to prove a real LoQE `SELECT *` works from :3001 (bucket CORS allows it) + // but is BLOCKED from :3002 (different origin → the in-app Query Error / CORS box). + ...(mode === 'authn' && browser !== 'webkit' + ? [ + { + command: `npm run dev -- --port ${port2} --strictPort`, + cwd: appDir, + url: `http://localhost:${port2}`, + reuseExistingServer: false, + timeout: 120_000, + env: viteEnv, + }, + ] + : []), + ], +}); diff --git a/reporters/current.mjs b/reporters/current.mjs new file mode 100644 index 0000000..1e9fd15 --- /dev/null +++ b/reporters/current.mjs @@ -0,0 +1,36 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Tiny Playwright reporter: records the currently-running test to +// results/current.json so the dashboard's "run in progress" banner can show the +// live test name (the dashboard polls this file client-side). Everything is wrapped +// in try/catch so it can never break a test run. +const dir = path.dirname(fileURLToPath(import.meta.url)); +const file = path.resolve(dir, '../results/current.json'); + +export default class CurrentTestReporter { + constructor(opts = {}) { + this.combo = opts.combo || `${process.env.APP || 'console'}-${process.env.TEST_MODE || 'authn'}`; + } + + onTestBegin(test) { + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const rel = test.location?.file ? path.relative(path.resolve(dir, '..'), test.location.file) : ''; + // titlePath() = [project, file, ...describes, test]; keep describes + test. + const title = test.titlePath().filter(Boolean).slice(2).join(' › ') || test.title; + fs.writeFileSync(file, JSON.stringify({ combo: this.combo, file: rel, test: title, ts: Date.now() })); + } catch { + /* never break the run */ + } + } + + onEnd() { + try { + fs.rmSync(file, { force: true }); + } catch { + /* ignore */ + } + } +} diff --git a/run.mjs b/run.mjs new file mode 100644 index 0000000..67eec50 --- /dev/null +++ b/run.mjs @@ -0,0 +1,357 @@ +#!/usr/bin/env node +/** + * E2E matrix orchestrator. + * + * For each (app × mode) combo, serially: + * 1. tear down + bring up the backend stack for that mode + * 2. run lakekeeper migrate, then serve; wait for readiness + * 3. run Playwright (which launches the app's dev server with mode env) + * 4. tear down + * + * Usage: + * node run.mjs # all apps × all modes + * node run.mjs --app console --mode authn + * node run.mjs --mode noauth,authn # subset of modes, both apps + * node run.mjs --app console-plus --mode cedar + * node run.mjs --keep # leave the last stack running + * node run.mjs --grep @smoke # extra Playwright grep filter + */ +import { spawn, spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as dotenv from 'dotenv'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const env = { + ...dotenv.parse(fs.existsSync(path.join(dir, '.env')) ? fs.readFileSync(path.join(dir, '.env')) : ''), + ...dotenv.parse(fs.existsSync(path.join(dir, '.env.secret')) ? fs.readFileSync(path.join(dir, '.env.secret')) : ''), + ...process.env, +}; + +// Local SeaweedFS S3 must be reachable from BOTH the browser (host) and the +// lakekeeper container — the host LAN IP satisfies both (localhost fails in the +// container; the compose hostname fails in the browser). Auto-detect it. +function hostLanIp() { + for (const ifaces of Object.values(os.networkInterfaces())) { + for (const i of ifaces || []) if (i.family === 'IPv4' && !i.internal) return i.address; + } + return null; +} +if (!env.S3_LOCAL_ENDPOINT) { + const ip = hostLanIp(); + if (ip) env.S3_LOCAL_ENDPOINT = `http://${ip}:8333`; +} + +const ALL_APPS = ['console', 'console-plus']; +const ALL_MODES = ['noauth', 'authn', 'authz', 'cedar']; +// Per-app supported modes. Cedar is a PREMIUM authorizer → console-plus only; +// the OSS `console` never runs cedar. +const APP_MODES = { + console: ['noauth', 'authn', 'authz'], + 'console-plus': ['noauth', 'authn', 'authz', 'cedar'], +}; +// Which backend services each mode needs (migrate + lakekeeper always added). +const SERVICES = { + noauth: ['postgres', 'seaweedfs', 'bucket-init'], + authn: ['postgres', 'keycloak', 'seaweedfs', 'bucket-init'], + authz: ['postgres', 'openfga', 'keycloak', 'seaweedfs', 'bucket-init'], + cedar: ['postgres', 'keycloak', 'seaweedfs', 'bucket-init'], +}; + +// --- arg parsing --- +const args = process.argv.slice(2); +const opt = (name) => { + const i = args.indexOf(name); + return i >= 0 ? args[i + 1] : undefined; +}; +const has = (name) => args.includes(name); +const apps = (opt('--app') || ALL_APPS.join(',')).split(',').filter(Boolean); +const modes = (opt('--mode') || ALL_MODES.join(',')).split(',').filter(Boolean); +const extraGrep = opt('--grep'); +const keep = has('--keep'); +const upOnly = has('--up'); // bring the stack up and leave it running (for test-ui) + +const HEALTH = 'http://localhost:8181/health'; +const KC_DISCOVERY = 'http://localhost:30080/realms/iceberg/.well-known/openid-configuration'; + +// Rebuild DASHBOARD.html. Pass {RUN_IN_PROGRESS:'1', RUN_CURRENT} while a run is +// active so the page shows an "in progress" banner and refreshes fast; omit at +// the end to clear it. Columns fill live as each combo finishes. +const buildDashboard = (extra = {}) => + spawnSync('node', ['dashboard.mjs'], { cwd: dir, stdio: 'ignore', env: { ...env, ...extra } }); + +// Container engine. `docker` here is a shell alias to podman (invisible to +// spawn), and the standalone docker-compose binary targets the wrong socket, so +// we drive compose via `podman compose` which wires up podman's own socket. +// Override with COMPOSE_BIN (+ COMPOSE_SUBCMD) for a real docker install. +const COMPOSE_BIN = process.env.COMPOSE_BIN || 'podman'; +const COMPOSE_PREFIX = process.env.COMPOSE_SUBCMD === '' ? [] : [process.env.COMPOSE_SUBCMD || 'compose']; +// `--env-file /dev/null` stops compose from auto-reading the project .env for +// ${VAR} interpolation — all the vars compose needs (LK_IMAGE, TEST_MODE, paths, +// license) are already in the spawn env below. This makes compose immune to typos +// or quoting issues in the user's .env / secret entries. +const compose = (composeArgs, extraEnv = {}) => + spawnSync( + COMPOSE_BIN, + [...COMPOSE_PREFIX, '--env-file', '/dev/null', '-f', path.join(dir, 'docker-compose.yml'), ...composeArgs], + { + cwd: dir, + stdio: 'inherit', + env: { ...env, ...extraEnv }, + }, + ); + +async function waitFor(label, url, { timeoutMs = 120_000, expectOk = true } = {}) { + const start = Date.now(); + process.stdout.write(`⏳ waiting for ${label} `); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(url); + if (!expectOk || res.ok) { + console.log(`✓ (${Math.round((Date.now() - start) / 1000)}s)`); + return true; + } + } catch { + /* not up yet */ + } + process.stdout.write('.'); + await new Promise((r) => setTimeout(r, 2000)); + } + console.log(' ✗ timeout'); + throw new Error(`${label} not ready after ${timeoutMs}ms`); +} + +function freeAppPort() { + const port = env.APP_PORT || '5001'; + // Kill any lingering vite from a previous combo AND WAIT until the ports are + // actually released. With reuseExistingServer:false + --strictPort, a port that + // is still in TIME_WAIT / held by a slow-dying child makes the next combo's + // webServer fail hard ("http://localhost:3001 is already used") and the whole + // combo errors with CONNECTION_REFUSED. Poll up to ~8s for a clean release. + // :3002 is the CORS test's second app instance (authn mode). + for (let i = 0; i < 25; i++) { + const inUse = spawnSync('bash', ['-c', `lsof -ti tcp:${port} tcp:3002 2>/dev/null`], { + encoding: 'utf8', + }).stdout.trim(); + if (!inUse) return; + spawnSync('bash', ['-c', `echo "${inUse}" | xargs kill -9 2>/dev/null; sleep 0.3`], { + stdio: 'ignore', + }); + } +} + +function runPlaywright(app, mode, browser = 'chromium') { + freeAppPort(); + return new Promise((resolve) => { + const pwArgs = ['playwright', 'test']; + if (extraGrep) pwArgs.push('--grep', extraGrep); + const child = spawn('npx', pwArgs, { + cwd: dir, + stdio: 'inherit', + env: { ...env, APP: app, TEST_MODE: mode, BROWSER: browser }, + }); + child.on('exit', (code) => resolve(code ?? 1)); + }); +} + +// Component-repo UNIT tests (Vitest). Browser/mode-independent, so run ONCE per +// matrix (not per combo). Writes results/unit.json for the dashboard. Skips a repo +// that has no `test` script / no test files. This is the Tier-0 guard (e.g. the +// authToken/currentAccessToken fix for the Bearer-undefined bug). +function runUnitTests() { + const repos = ['console-components', 'console-plus-components']; + const out = []; + for (const name of repos) { + const repoDir = path.resolve(dir, '..', name); + const pkgPath = path.join(repoDir, 'package.json'); + if (!fs.existsSync(pkgPath)) continue; + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (!pkg.scripts?.test) { + out.push({ repo: name, skipped: 'no test script' }); + continue; + } + const jsonFile = path.join(dir, 'results', `unit-${name}.json`); + console.log(`\n▶ unit tests · ${name}`); + const res = spawnSync('npm', ['test', '--', '--reporter=json', `--outputFile=${jsonFile}`], { + cwd: repoDir, + stdio: 'inherit', + env, + }); + let passed = 0, failed = 0, total = 0; + try { + const j = JSON.parse(fs.readFileSync(jsonFile, 'utf8')); + passed = j.numPassedTests ?? 0; + failed = j.numFailedTests ?? 0; + total = j.numTotalTests ?? passed + failed; + } catch { /* no parsable output (e.g. no test files) */ } + out.push({ repo: name, passed, failed, total, code: res.status ?? 1 }); + } + fs.writeFileSync(path.join(dir, 'results', 'unit.json'), JSON.stringify(out, null, 2)); + return out; +} + +const results = []; + +// Fresh blob dir — each combo drops a blob here; merged into one report at the end. +fs.rmSync(path.join(dir, 'blob-report'), { recursive: true, force: true }); + +// Show "run in progress" on the dashboard immediately (over last run's data). +if (!upOnly) buildDashboard({ RUN_IN_PROGRESS: '1' }); + +// Component unit tests once up front (fast, browser-independent). Skipped for --up +// and when filtering to a grep (those are targeted e2e iterations). +let unitResults = []; +if (!upOnly && !extraGrep && !env.NO_UNIT) { + unitResults = runUnitTests(); + buildDashboard({ RUN_IN_PROGRESS: '1' }); +} + +for (const app of apps) { + for (const mode of modes) { + if (!ALL_MODES.includes(mode)) { + console.error(`⚠️ unknown mode "${mode}" — skipping`); + continue; + } + if (!APP_MODES[app]?.includes(mode)) { + console.log(`↳ ${app} does not support ${mode} (cedar is console-plus only) — skipping`); + continue; + } + const lkImage = + mode === 'cedar' ? env.LK_IMAGE_PLUS : env.LK_IMAGE_OSS; + const stackEnv = { TEST_MODE: mode, LK_IMAGE: lkImage }; + + console.log(`\n${'='.repeat(70)}\n▶ ${app} · ${mode} (image: ${lkImage})\n${'='.repeat(70)}`); + + if (mode === 'cedar' && !env.LAKEKEEPER__LICENSE__KEY) { + console.error('✗ cedar mode requires LAKEKEEPER__LICENSE__KEY in e2e/.env.secret — skipping'); + results.push({ app, mode, code: -1, note: 'no license' }); + continue; + } + + try { + // clean slate + compose(['down', '-v', '--remove-orphans'], stackEnv); + + // infra (postgres readiness is gated by the compose healthcheck + + // migrate's depends_on; keycloak we poll explicitly below) + compose(['up', '-d', ...SERVICES[mode]], stackEnv); + if (SERVICES[mode].includes('keycloak')) { + await waitFor('keycloak realm', KC_DISCOVERY, { timeoutMs: 240_000 }); + } + + // migrate (one-shot) then serve + const mig = compose(['run', '--rm', 'migrate'], stackEnv); + if (mig.status !== 0) throw new Error('migrate failed'); + compose(['up', '-d', 'lakekeeper'], stackEnv); + await waitFor('lakekeeper', HEALTH, { timeoutMs: 120_000 }); + + // --up: bring the stack up and LEAVE it running (for `just test-ui` / + // manual clicking). No tests, no teardown. + if (upOnly) { + console.log(`\n✅ Stack up for ${mode} (lakekeeper :8181, keycloak :30080).`); + console.log(` Browse/run tests: just test-ui ${app}`); + console.log(` Tear down when done: just test-down`); + process.exit(0); // exit before the finally teardown so the stack persists + } + + // tests (Playwright boots the app dev server) + buildDashboard({ RUN_IN_PROGRESS: '1', RUN_CURRENT: `${app}·${mode}` }); // "now running" + const code = await runPlaywright(app, mode); + results.push({ app, mode, code }); + buildDashboard({ RUN_IN_PROGRESS: '1' }); // column now filled, run still going + + // 3D matrix (cross-browser). The stack is up, so run extra browsers now. + // Skip when filtering (--grep) or via NO_CROSS_BROWSER=1. + if (!extraGrep && !env.NO_CROSS_BROWSER) { + // firefox: FULL parity with chromium — runs the whole mode suite every + // combo (LoQE/DuckDB-WASM works headless in firefox). + buildDashboard({ RUN_IN_PROGRESS: '1', RUN_CURRENT: `${app}·${mode}·firefox` }); + const ff = await runPlaywright(app, mode, 'firefox'); + results.push({ app, mode: `${mode}-firefox`, code: ff }); + buildDashboard({ RUN_IN_PROGRESS: '1' }); + + // webkit: @smoke only, once per app (authn) — Safari/WebKit DuckDB-WASM + // support is limited, so deep flows stay off it. + if (mode === 'authn') { + buildDashboard({ RUN_IN_PROGRESS: '1', RUN_CURRENT: `${app}·${mode}·webkit` }); + const wk = await runPlaywright(app, mode, 'webkit'); + results.push({ app, mode: `${mode}-webkit`, code: wk }); + buildDashboard({ RUN_IN_PROGRESS: '1' }); + } + } + } catch (e) { + console.error(`✗ ${app}·${mode}: ${e.message}`); + results.push({ app, mode, code: 1, note: e.message }); + } finally { + const isLast = app === apps.at(-1) && mode === modes.at(-1); + if (!(keep && isLast)) compose(['down', '-v', '--remove-orphans'], stackEnv); + } + } +} + +// --- summary --- +console.log(`\n${'='.repeat(70)}\nMATRIX SUMMARY\n${'='.repeat(70)}`); +let failed = 0; +for (const r of results) { + const ok = r.code === 0; + if (!ok) failed++; + console.log(`${ok ? '✅' : '❌'} ${r.app.padEnd(13)} ${r.mode.padEnd(7)} ${r.note || (ok ? '' : `exit ${r.code}`)}`); +} +console.log(`\n${results.length - failed}/${results.length} combos passed.`); + +// Component unit tests (Vitest) summary. +if (unitResults.length) { + console.log(`\n${'-'.repeat(70)}\nCOMPONENT UNIT TESTS (Vitest)\n${'-'.repeat(70)}`); + for (const u of unitResults) { + if (u.skipped) console.log(`➖ ${u.repo.padEnd(24)} ${u.skipped}`); + else console.log(`${u.code === 0 ? '✅' : '❌'} ${u.repo.padEnd(24)} ${u.passed}/${u.total} passed${u.failed ? ` (${u.failed} failed)` : ''}`); + } +} + +// Merge every combo's blob into ONE central HTML report. +if (fs.existsSync(path.join(dir, 'blob-report'))) { + console.log('\nBuilding central report…'); + spawnSync('npx', ['playwright', 'merge-reports', '--reporter', 'html', './blob-report'], { + cwd: dir, + stdio: 'inherit', + env: { ...env, PLAYWRIGHT_HTML_OPEN: 'never' }, + }); + + // Archive this run under a timestamp so it's never lost when you run again. + // `playwright-report/` is always the LATEST; `history//` are kept until + // YOU delete them (nothing here auto-prunes). Stamp + scope in the folder name. + const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const scope = + extraGrep || apps.length < ALL_APPS.length || modes.length < ALL_MODES.length + ? `partial-${apps.join('+')}-${modes.join('+')}` + : 'full'; + const archive = path.join(dir, 'history', `${stamp}__${scope}`); + fs.mkdirSync(path.join(dir, 'history'), { recursive: true }); + fs.cpSync(path.join(dir, 'playwright-report'), archive, { recursive: true }); + + console.log(`📊 Latest report: e2e/playwright-report/ → just test-report`); + console.log(`🗄 Archived run: e2e/history/${stamp}__${scope}/ (kept until you delete it)`); + + // Rebuild the accumulating matrix dashboard from results/ (one JSON per combo; + // partial runs only update their own columns — the matrix never shrinks). + spawnSync('node', ['dashboard.mjs'], { cwd: dir, stdio: 'inherit', env }); + console.log(`🧮 Matrix dashboard: e2e/DASHBOARD.html → just test-dashboard`); + // Keep the catalog (what tests EXIST) in sync with the specs on every run. + spawnSync('node', ['catalog.mjs'], { cwd: dir, stdio: 'ignore', env }); + console.log(`📚 Test catalog: e2e/TEST-CATALOG.html → just test-catalog`); + // Warn when this report is only a SUBSET (filtered/partial run) — the report + // always reflects the LAST run, so a --grep or partial app/mode shrinks it. + const partial = extraGrep || apps.length < ALL_APPS.length || modes.length < ALL_MODES.length; + if (partial) { + console.log( + `⚠️ PARTIAL report — this run was filtered (` + + `${apps.join(',')} × ${modes.join(',')}${extraGrep ? ` grep:"${extraGrep}"` : ''}). ` + + `Run \`just test-matrix\` (no filter) for the full report. ` + + `For the catalog of ALL cases: \`just test-catalog\`.`, + ); + } +} +console.log(' Per-combo reports: e2e/reports/-/'); +process.exit(failed > 0 ? 1 : 0); diff --git a/seaweedfs/s3.json b/seaweedfs/s3.json new file mode 100644 index 0000000..254ae15 --- /dev/null +++ b/seaweedfs/s3.json @@ -0,0 +1,11 @@ +{ + "identities": [ + { + "name": "lakekeeper", + "credentials": [ + { "accessKey": "lakekeeper", "secretKey": "lakekeeper-secret" } + ], + "actions": ["Admin", "Read", "Write", "List", "Tagging"] + } + ] +} diff --git a/specs/_data/storage-backends.ts b/specs/_data/storage-backends.ts new file mode 100644 index 0000000..4a242ac --- /dev/null +++ b/specs/_data/storage-backends.ts @@ -0,0 +1,160 @@ +import { Locator } from '@playwright/test'; + +// v-tabs keeps every storage subform mounted, so field lookups must be scoped to +// the active panel to avoid matching hidden tabs. +async function fillIfPresent(scope: Locator, label: RegExp, value: string) { + const field = scope.getByLabel(label).first(); + if (await field.isVisible().catch(() => false)) await field.fill(value); +} + +/** + * Storage backends for warehouse tests. Each backend fills its tab's subform in + * the "Add Warehouse" dialog. S3-compatible (SeaweedFS) is always available locally; + * the cloud backends activate only when their credentials are present in the + * environment (export locally now; GitHub secrets later) — otherwise skipped, + * the same skip-if-absent pattern as the cedar mode. + * + * The storage-type tabs in WarehouseAddDialog have values: + * S3 · GCS · AZURE · ONELAKE · R2 · S3_COMPAT + */ +export interface StorageBackend { + key: string; + /** v-tab to activate (accessible name / visible text). */ + tab: RegExp; + /** True when this backend's credentials are configured. */ + enabled: boolean; + /** Fill the subform fields for this backend (scoped to the active tab panel). */ + fill: (scope: Locator) => Promise; + /** + * Whether deep flows (open detail → namespace → table) work. They need the + * storage endpoint reachable FROM THE BROWSER (the detail page's storage + * explorer fetches it). Cloud (AWS/R2/…) is browser-reachable; local SeaweedFS + * sends no CORS headers, so it's create+verify only. Default true. + */ + deepFlows?: boolean; +} + +const env = process.env; +const has = (...keys: string[]) => keys.every((k) => !!env[k]); + +async function fillS3Compat(scope: Locator) { + // Local SeaweedFS (S3-compatible). The endpoint must be reachable from BOTH the + // browser and the lakekeeper container, so default to the host LAN IP (run.mjs + // injects S3_LOCAL_ENDPOINT); seaweedfs:8333 only works server-side. + await fillIfPresent(scope, /Access Key ID/i, env.S3_LOCAL_ACCESS_KEY || 'lakekeeper'); + await fillIfPresent(scope, /Secret Access Key/i, env.S3_LOCAL_SECRET_KEY || 'lakekeeper-secret'); + await fillIfPresent(scope, /Bucket Name/i, env.S3_LOCAL_BUCKET || 'lakekeeper-test'); + await fillIfPresent(scope, /Bucket Region/i, env.S3_LOCAL_REGION || 'us-east-1'); + // Endpoint + path-style live under the collapsed "Advanced Storage Options" + // panel — expand it or they silently stay empty (→ no URL → "Failed to fetch"). + const adv = scope.getByRole('button', { name: /Advanced Storage Options/i }); + if (await adv.isVisible().catch(() => false)) await adv.click(); + await fillIfPresent(scope, /Endpoint/i, env.S3_LOCAL_ENDPOINT || 'http://seaweedfs:8333'); + const pathStyle = scope.getByLabel(/path[- ]style/i); + if (await pathStyle.isVisible().catch(() => false)) { + await pathStyle.check().catch(() => pathStyle.click()); + } +} + +export const STORAGE_BACKENDS: StorageBackend[] = [ + { + key: 's3 (seaweedfs)', + tab: /S3 Compatible|S3.?Compat/i, + // On by default (the always-available local backend); run.mjs injects a host + // LAN-IP endpoint so it's reachable from both browser and container. Opt out + // with S3_LOCAL_ENABLE=0. + enabled: process.env.S3_LOCAL_ENABLE !== '0', + // create+verify only — the detail page's browser-side storage explorer can't + // reach local SeaweedFS (no CORS), so skip open/namespace/table for it. + deepFlows: false, + fill: fillS3Compat, + }, + { + key: 's3 (aws)', + tab: /AWS S3|Amazon S3/i, + enabled: has('AWS_S3_BUCKET', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'), + fill: async (scope) => { + // credential type = access-key (radio), if the AWS variant shows the toggle + const accessKeyRadio = scope.getByRole('radio', { name: /access key/i }).first(); + if (await accessKeyRadio.isVisible().catch(() => false)) { + await accessKeyRadio.check().catch(() => {}); + } + await fillIfPresent(scope, /Access Key ID/i, env.AWS_ACCESS_KEY_ID!); + await fillIfPresent(scope, /Secret Access Key/i, env.AWS_SECRET_ACCESS_KEY!); + await fillIfPresent(scope, /Bucket Name/i, env.AWS_S3_BUCKET!); + await fillIfPresent(scope, /Bucket Region/i, env.AWS_REGION || 'us-east-1'); + + // Match the working manual profile: { remote-signing-enabled (default on), + // sts-enabled, sts-role-arn, key-prefix }. Without STS the warehouse uses + // plain vended access-key creds writing to the bucket root, and the browser + // write 404s. The two fields live in different places: + + // 1) Key Prefix — inside the collapsed "Advanced Storage Options" panel. + if (env.AWS_KEY_PREFIX) { + const adv = scope.getByRole('button', { name: /Advanced Storage Options/i }); + if (await adv.isVisible().catch(() => false)) { + await adv.click(); + await scope.getByLabel(/Key Prefix/i).first().waitFor({ timeout: 5000 }).catch(() => {}); + } + await fillIfPresent(scope, /Key Prefix/i, env.AWS_KEY_PREFIX); + } + + // 2) STS — an always-visible switch (after the panel). Toggle it on by + // clicking its label, which reveals the "STS Role ARN" field. + if (env.AWS_STS_ROLE_ARN) { + const stsToggle = scope.getByText(/Enable STS/i).first(); + if (await stsToggle.isVisible().catch(() => false)) await stsToggle.click(); + const roleArn = scope.getByLabel(/STS Role ARN/i).first(); + await roleArn.waitFor({ timeout: 5000 }).catch(() => {}); + await fillIfPresent(scope, /STS Role ARN/i, env.AWS_STS_ROLE_ARN); + } + }, + }, + { + key: 'r2 (cloudflare)', + tab: /Cloudflare R2|^R2$/i, + enabled: has('R2_BUCKET', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY', 'R2_ACCOUNT_ID'), + fill: async (scope) => { + await fillIfPresent(scope, /Access Key ID/i, env.R2_ACCESS_KEY_ID!); + await fillIfPresent(scope, /Secret Access Key/i, env.R2_SECRET_ACCESS_KEY!); + await fillIfPresent(scope, /Account ID/i, env.R2_ACCOUNT_ID!); + await fillIfPresent(scope, /Bucket Name/i, env.R2_BUCKET!); + }, + }, + { + key: 'adls (azure)', + tab: /Azure|ADLS/i, + enabled: has('ADLS_ACCOUNT_NAME', 'ADLS_FILESYSTEM', 'ADLS_CLIENT_ID', 'ADLS_CLIENT_SECRET', 'ADLS_TENANT_ID'), + fill: async (scope) => { + await fillIfPresent(scope, /Account Name/i, env.ADLS_ACCOUNT_NAME!); + await fillIfPresent(scope, /Filesystem/i, env.ADLS_FILESYSTEM!); + await fillIfPresent(scope, /Client ID/i, env.ADLS_CLIENT_ID!); + await fillIfPresent(scope, /Client Secret/i, env.ADLS_CLIENT_SECRET!); + await fillIfPresent(scope, /Tenant ID/i, env.ADLS_TENANT_ID!); + }, + }, + { + key: 'onelake (fabric)', + tab: /OneLake/i, + enabled: has('ONELAKE_ACCOUNT_NAME', 'ONELAKE_FILESYSTEM', 'ONELAKE_CLIENT_ID', 'ONELAKE_CLIENT_SECRET', 'ONELAKE_TENANT_ID'), + fill: async (scope) => { + await fillIfPresent(scope, /Account Name/i, env.ONELAKE_ACCOUNT_NAME!); + await fillIfPresent(scope, /Filesystem|Workspace/i, env.ONELAKE_FILESYSTEM!); + await fillIfPresent(scope, /Client ID/i, env.ONELAKE_CLIENT_ID!); + await fillIfPresent(scope, /Client Secret/i, env.ONELAKE_CLIENT_SECRET!); + await fillIfPresent(scope, /Tenant ID/i, env.ONELAKE_TENANT_ID!); + }, + }, + { + key: 'gcs (google)', + tab: /GCS|Google/i, + enabled: has('GCS_BUCKET', 'GCS_SERVICE_ACCOUNT_KEY'), + fill: async (scope) => { + await fillIfPresent(scope, /Bucket Name/i, env.GCS_BUCKET!); + // GCS uses a service-account JSON key — pasted into the key field. + await fillIfPresent(scope, /Service Account|Key|Credential/i, env.GCS_SERVICE_ACCOUNT_KEY!); + }, + }, +]; + +export const ENABLED_BACKENDS = STORAGE_BACKENDS.filter((b) => b.enabled); diff --git a/specs/_fixtures/auth.fixture.ts b/specs/_fixtures/auth.fixture.ts new file mode 100644 index 0000000..e99195e --- /dev/null +++ b/specs/_fixtures/auth.fixture.ts @@ -0,0 +1,85 @@ +import { test as base, expect, Page } from '@playwright/test'; +import { addCoverageReport } from 'monocart-reporter'; +import { login, isAuthMode, TEST_USER } from '../_utils/auth'; + +type AuthFixtures = { + authenticatedPage: Page; // logged in (or direct access in noauth) + bootstrappedPage: Page; // logged in AND server bootstrapped + _coverage: void; // auto fixture: collect V8 coverage (chromium, E2E_COVERAGE=1) +}; + +/** + * Drives the bootstrap stepper if the server isn't bootstrapped yet. + * Stepper: 1) Global Admin → Next, 2) EULA (must scroll to bottom) → Next, + * 3) Submit → Accept. Works for both auth and noauth modes (fresh DB per run). + */ +async function ensureBootstrapped(page: Page) { + if (!page.url().includes('/bootstrap')) { + await page.goto('/ui/bootstrap').catch(() => {}); + await page.waitForLoadState('networkidle').catch(() => {}); + if (!page.url().includes('bootstrap')) return; // already bootstrapped + } + + const next = page.getByRole('button', { name: 'Next' }); + + // Step 1 → 2 + await next.waitFor({ state: 'visible', timeout: 10000 }); + await next.click(); + + // Step 2 (EULA): force the overflow container to the bottom and fire a scroll + // event so the component enables Next, then advance. + await page.waitForTimeout(500); + await expect(next).toBeDisabled({ timeout: 5000 }).catch(() => {}); + await page.evaluate(() => { + const containers = Array.from(document.querySelectorAll('div')).filter( + (d) => d.scrollHeight > d.clientHeight && getComputedStyle(d).overflowY === 'auto', + ); + for (const c of containers) { + c.scrollTop = c.scrollHeight; + c.dispatchEvent(new Event('scroll')); + } + }); + await expect(next).toBeEnabled({ timeout: 5000 }); + await next.click(); + + // Step 3 (Submit) + const accept = page.getByRole('button', { name: 'Accept' }); + await accept.waitFor({ state: 'visible', timeout: 5000 }); + await accept.click(); + + // Bootstrap done → app redirects off /bootstrap. + await page.waitForURL((url) => !url.pathname.includes('bootstrap'), { timeout: 25000 }); +} + +export const test = base.extend({ + // V8 code coverage, auto-applied to every test's main page. Chromium-only (the + // CDP coverage API), and only when E2E_COVERAGE=1 so normal runs pay no cost. + // Captures the peter/bootstrapped page (the bulk of the journeys); anna's + // separate browser contexts aren't covered. monocart maps it through sourcemaps. + _coverage: [ + async ({ page, browserName }, use, testInfo) => { + const on = browserName === 'chromium' && process.env.E2E_COVERAGE === '1'; + if (on) await page.coverage.startJSCoverage({ resetOnNavigation: false }); + await use(); + if (on) { + const entries = await page.coverage.stopJSCoverage(); + await addCoverageReport(entries, testInfo); + } + }, + { auto: true }, + ], + + authenticatedPage: async ({ page }, use) => { + await login(page, TEST_USER); + await use(page); + }, + + bootstrappedPage: async ({ page }, use) => { + await login(page, TEST_USER); + await ensureBootstrapped(page); + await use(page); + }, +}); + +export { expect } from '@playwright/test'; +export { isAuthMode }; diff --git a/specs/_utils/app.ts b/specs/_utils/app.ts new file mode 100644 index 0000000..a57a2ca --- /dev/null +++ b/specs/_utils/app.ts @@ -0,0 +1,26 @@ +import { Page } from '@playwright/test'; + +// The router's beforeEach guard calls getServerInfo(); if the access token hasn't +// hydrated yet, that call goes out as "Bearer undefined" → 401 → empty serverInfo, +// and the guard bounces to /ui/server-offline ("Lakekeeper Unreachable / Check +// status"). Lakekeeper is NOT actually down — it's the token-hydration race. A +// reload (or the page's own "Check status" button) re-runs the guard once the +// token is in place. Recover from it before interacting with the real page. +export async function recoverFromOffline(page: Page, tries = 4) { + for (let i = 0; i < tries; i++) { + if (!/\/ui\/server-offline/.test(page.url())) return; + // The page's "Check status" button re-runs getServerInfo(); fall back to reload. + const check = page.getByRole('button', { name: /check status/i }); + if (await check.isVisible().catch(() => false)) await check.click().catch(() => {}); + else await page.reload().catch(() => {}); + await page.waitForLoadState('networkidle').catch(() => {}); + await page.waitForTimeout(1500); + } +} + +/** goto a path and shake off the transient server-offline page (auth race). */ +export async function gotoReady(page: Page, path: string) { + await page.goto(path); + await page.waitForLoadState('domcontentloaded'); + await recoverFromOffline(page); +} diff --git a/specs/_utils/auth.ts b/specs/_utils/auth.ts new file mode 100644 index 0000000..64fa8a6 --- /dev/null +++ b/specs/_utils/auth.ts @@ -0,0 +1,113 @@ +import { Page, expect } from '@playwright/test'; + +export interface AuthCredentials { + username: string; + password: string; +} + +export const TEST_MODE = process.env.TEST_MODE || 'authn'; +export const isAuthMode = TEST_MODE !== 'noauth'; + +// Instance admin (bootstraps the server, can do everything). +export const TEST_USER: AuthCredentials = { + username: process.env.TEST_USERNAME || 'peter', + password: process.env.TEST_PASSWORD || 'iceberg', +}; + +// Second, NON-admin user — used to prove permission enforcement in authz/cedar: +// they should be DENIED actions the admin can do. +export const TEST_USER_2: AuthCredentials = { + username: process.env.TEST_USERNAME_2 || 'anna', + password: process.env.TEST_PASSWORD_2 || 'iceberg', +}; + +/** + * OIDC login through Keycloak. No-op in noauth mode (no login screen). + */ +export async function login(page: Page, credentials: AuthCredentials = TEST_USER) { + await page.goto('/'); + + if (!isAuthMode) return; // noauth: app is reachable directly + + const { username, password } = credentials; + + const alreadyAuthenticated = await page + .locator('[data-testid="user-menu"], .v-app-bar') + .first() + .isVisible({ timeout: 2000 }) + .catch(() => false); + if (alreadyAuthenticated) return; + + const loginButton = page + .locator('button:has-text("Login"), button:has-text("Sign In"), [data-testid="login-button"]') + .first(); + + // Click "Sign In" and wait for the actual redirect to Keycloak. The app's OIDC + // client can init slightly after the button paints (more so in console-plus, + // which boots heavier), so retry the click until the redirect happens. + const onKeycloak = () => /\/realms\/iceberg\//.test(page.url()); + for (let attempt = 0; attempt < 3 && !onKeycloak(); attempt++) { + if (await loginButton.isVisible({ timeout: 8000 }).catch(() => false)) { + await loginButton.click().catch(() => {}); + } + await page.waitForURL(/\/realms\/iceberg\//, { timeout: 8000 }).catch(() => {}); + } + + const keycloakFormVisible = await page + .locator('#kc-form-login, input[name="username"], input[id="username"]') + .first() + .isVisible({ timeout: 10000 }) + .catch(() => false); + + if (keycloakFormVisible) { + const usernameField = page + .locator('input[name="username"]') + .or(page.locator('#username')) + .or(page.locator('input[id="username"]')) + .first(); + await usernameField.waitFor({ state: 'visible', timeout: 5000 }); + await usernameField.fill(username); + + const passwordField = page + .locator('input[name="password"]') + .or(page.locator('#password')) + .or(page.locator('input[id="password"]')) + .first(); + await passwordField.fill(password); + + const submit = page + .locator('input[type="submit"]') + .or(page.locator('button[type="submit"]')) + .or(page.locator('#kc-login')) + .first(); + await submit.click(); + + await page.waitForURL(/\/(ui\/)?callback/, { timeout: 15000 }); + await page.waitForURL(/\/$|\/ui\/$|\/ui\/bootstrap/, { timeout: 15000 }); + } +} + +export async function logout(page: Page) { + const userMenu = page.locator('[data-testid="user-menu"], .v-app-bar .v-avatar').first(); + if (await userMenu.isVisible({ timeout: 5000 }).catch(() => false)) { + await userMenu.click(); + const logoutButton = page + .locator('[data-testid="logout-button"], .v-list-item:has-text("Logout"), button:has-text("Logout")') + .first(); + await logoutButton.click(); + } else { + await page.goto('/ui/logout'); + } + await page.waitForURL(/\/ui\/login/, { timeout: 10000 }); +} + +export async function clearSession(page: Page) { + try { + await page.evaluate(() => { + sessionStorage.clear(); + localStorage.clear(); + }); + } catch { + /* page may not be loaded yet */ + } +} diff --git a/specs/_utils/cedar.ts b/specs/_utils/cedar.ts new file mode 100644 index 0000000..89f9413 --- /dev/null +++ b/specs/_utils/cedar.ts @@ -0,0 +1,59 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Cedar "grant" = editing the policy file Lakekeeper loads (/policies/policies.cedar, +// bind-mounted from console-e2e/cedar/). Lakekeeper hot-reloads it. Unlike FGA, a +// table grant is NOT enough — Cedar needs every level permitted explicitly +// (Project/Warehouse/Namespace Describe + TableSelect + IntrospectTableAuthorization). +// We rewrite the WHOLE file (base ± anna block) so state is deterministic. + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const POLICY_FILE = path.resolve(dir, '../../cedar/policies.cedar'); + +const BASE = `// Base Cedar policy for the console-e2e \`cedar\` mode. SELF-CONTAINED. +// peter is the instance admin; anna starts with NO grant (denied). Managed by the +// cedar access-control test via _utils/cedar.ts. + +permit ( + principal == Lakekeeper::User::"oidc~cfb55bf6-fcbb-4a1e-bfec-30c6649b52f8", + action, + resource +); +`; + +// anna's OIDC sub (from the iceberg realm). Cedar principals are lowercase oidc~. +const ANNA = 'oidc~d223d88c-85b6-4859-b5c5-27f3825e47f6'; + +function annaTableReadBlock(wh: string) { + return ` +// GRANT (added live by the access-control test): anna may read tables in ${wh}. +permit ( + principal == Lakekeeper::User::"${ANNA}", + action in [ + Lakekeeper::Action::"ProjectDescribeActions", + Lakekeeper::Action::"WarehouseDescribeActions", + Lakekeeper::Action::"NamespaceDescribeActions", + Lakekeeper::Action::"TableSelectActions", + Lakekeeper::Action::"IntrospectTableAuthorization" + ], + resource +) +when { + resource is Lakekeeper::Project + || (resource is Lakekeeper::Warehouse && resource.name == "${wh}") + || (resource is Lakekeeper::Namespace && resource.warehouse.name == "${wh}") + || (resource is Lakekeeper::Table && resource.warehouse.name == "${wh}") +}; +`; +} + +/** Reset the policy to base (anna denied). */ +export function resetCedarPolicy() { + fs.writeFileSync(POLICY_FILE, BASE); +} + +/** Append anna's table-read grant for the given warehouse (anna allowed). */ +export function grantAnnaTableReadCedar(wh: string) { + fs.writeFileSync(POLICY_FILE, BASE + annaTableReadBlock(wh)); +} diff --git a/specs/_utils/loqe.ts b/specs/_utils/loqe.ts new file mode 100644 index 0000000..2729cc1 --- /dev/null +++ b/specs/_utils/loqe.ts @@ -0,0 +1,102 @@ +import { Page, expect } from '@playwright/test'; +import { recoverFromOffline } from './app'; + +// Helpers for driving the in-browser DuckDB-WASM SQL engine (LoQE). Shared by the +// loqe create+query test and the access-control (table-read) test. + +/** Wait for the LoQE engine to report "Ready", then expand a warehouse in the tree + * so DuckDB ATTACHes its catalog. Asserts the namespace shows up (tree loaded). */ +export async function openLoqeAndAttach(page: Page, wh: string, ns: string) { + await page.goto('/ui/loqe'); + await page.waitForLoadState('domcontentloaded'); + await recoverFromOffline(page); + await expect(page.getByText('Ready', { exact: true }).first()).toBeVisible({ timeout: 90000 }); + await page.getByText(wh, { exact: true }).first().click(); + await expect(page.getByText(ns, { exact: true }).first()).toBeVisible({ timeout: 30000 }); + await page.waitForLoadState('networkidle').catch(() => {}); + await page.waitForTimeout(8000); // let the async ATTACH + credential vend settle +} + +export interface LoqeResult { + errored: boolean; + errorText: string; +} + +/** Run ONE SQL statement in the editor and wait for it to settle (the "Running + * query…" spinner disappears). Returns whether a Query Error alert is showing and + * its text. One statement at a time — multi-statement makes one result tab per + * statement and an error can hide under a non-active tab. */ +export async function loqeExec(page: Page, sql: string): Promise { + const editor = page.locator('.cm-content').first(); + const spinner = page.getByText('Running query…'); + const errorAlert = page.locator('.v-alert', { hasText: 'Query Error' }); + + await editor.click(); + await page.keyboard.press('ControlOrMeta+A'); + await page.keyboard.press('Delete'); + await editor.fill(sql); + await page.getByRole('button', { name: /^run( selection)?$/i }).first().click(); + await expect(spinner).toBeHidden({ timeout: 90000 }); + await page.waitForTimeout(500); + + const errored = await errorAlert.isVisible().catch(() => false); + const errorText = errored ? (await errorAlert.innerText().catch(() => '')).trim() : ''; + return { errored, errorText }; +} + +export interface LoqeReadResult { + warehouseVisible: boolean; + errored: boolean; + errorText: string; + value: string; +} + +/** Attempt to read a table via LoQE as the current user. Reloads up to `maxReloads` + * times until the warehouse appears in the tree — a restricted user's first calls + * 401 during token hydration, then retry, so the tree populates a beat late. If the + * warehouse never appears (no grant) the SELECT is still run and will error + * ("Catalog does not exist"). Returns what happened (no assertions) so callers can + * assert denied vs allowed. */ +export async function loqeReadTable( + page: Page, + wh: string, + ns: string, + tbl: string, + maxReloads = 4, +): Promise { + await page.goto('/ui/loqe'); + await page.waitForLoadState('domcontentloaded'); + await expect(page.getByText('Ready', { exact: true }).first()).toBeVisible({ timeout: 90000 }); + + let warehouseVisible = false; + for (let i = 0; i < maxReloads; i++) { + warehouseVisible = await page.getByText(wh, { exact: true }).first().isVisible({ timeout: 12000 }).catch(() => false); + if (warehouseVisible || i === maxReloads - 1) break; + await page.reload(); + await page.waitForLoadState('networkidle').catch(() => {}); + await expect(page.getByText('Ready', { exact: true }).first()).toBeVisible({ timeout: 90000 }); + } + + if (warehouseVisible) { + await page.getByText(wh, { exact: true }).first().click(); + await page.waitForLoadState('networkidle').catch(() => {}); + await page.waitForTimeout(8000); // attach + credential vend + } + + const r = await loqeExec(page, `select a from "${wh}"."${ns}"."${tbl}";`); + const value = r.errored + ? '' + : (await page.locator('.loqe-result-table').first().innerText().catch(() => '')).replace(/\s+/g, ' ').trim(); + return { warehouseVisible, errored: r.errored, errorText: r.errorText, value }; +} + +/** Create a table via LoQE and assert it round-trips (read-back returns 1). Throws + * with the DuckDB error text on failure. Assumes the warehouse is already attached. */ +export async function createTableViaLoqe(page: Page, wh: string, ns: string, tbl: string) { + await loqeExec(page, `drop table if exists "${wh}"."${ns}"."${tbl}";`); + const created = await loqeExec(page, `create table "${wh}"."${ns}"."${tbl}" as select 1 as a;`); + if (created.errored) throw new Error(`LoQE create failed:\n${created.errorText}`); + const read = await loqeExec(page, `select a from "${wh}"."${ns}"."${tbl}";`); + if (read.errored) throw new Error(`LoQE read-back failed:\n${read.errorText}`); + await expect(page.locator('.loqe-result-table').first()).toContainText('1', { timeout: 15000 }); +} diff --git a/specs/_utils/permissions.ts b/specs/_utils/permissions.ts new file mode 100644 index 0000000..646677f --- /dev/null +++ b/specs/_utils/permissions.ts @@ -0,0 +1,64 @@ +import { Page, expect } from '@playwright/test'; +import { openWarehouse } from './warehouse'; + +// Grant a user a relation on a table via the console UI (PermissionAssignDialog), +// the same path an admin uses: navigate to the table → Permissions tab → grant → +// pick the user → check the relation → save. Used by the access-control test (FGA). +export async function grantTableRelation( + page: Page, + wh: string, + ns: string, + tbl: string, + username: string, + relation: string, // e.g. 'select' +) { + await openWarehouse(page, wh); // /ui/warehouse/ + await page.getByText(ns, { exact: true }).first().click(); + await page.waitForURL(/\/namespace\//, { timeout: 10000 }).catch(() => {}); + await page.getByText(tbl, { exact: true }).first().click(); + await page.waitForURL(/\/table\//, { timeout: 10000 }).catch(() => {}); + + // The Permissions tab can reset to "details" while table data loads — click until + // it sticks. + const permTab = page.getByRole('tab', { name: /permissions/i }); + await page.waitForLoadState('networkidle').catch(() => {}); + for (let i = 0; i < 5; i++) { + await permTab.click().catch(() => {}); + await page.waitForTimeout(1000); + if ((await permTab.getAttribute('aria-selected')) === 'true') break; + } + await page.waitForLoadState('networkidle').catch(() => {}); + + // Grant dialog → user tab → search → pick → check relation → save. The Vuetify + // dialog + autocomplete is the firefox-flaky spot (the search result or the + // relation checkbox occasionally never settles). Retry the whole dialog a few + // times — close + reopen — so a stuck attempt recovers in seconds rather than + // hanging out the test timeout (clicks are also capped by config actionTimeout). + const userRow = page.getByRole('row', { name: new RegExp(username, 'i') }).first(); + for (let attempt = 0; attempt < 3; attempt++) { + try { + await page.getByRole('button', { name: /^grant$/i }).first().click({ timeout: 10000 }); + await page.getByRole('tab', { name: /user/i }).first().click({ timeout: 5000 }).catch(() => {}); + const combo = page.getByRole('combobox').last(); + await combo.fill(username, { timeout: 8000 }); + const option = page.getByRole('option', { name: new RegExp(username, 'i') }).first(); + await option.click({ timeout: 8000 }); // waits for the search result to appear + const cb = page.getByRole('checkbox', { name: relation }); + await cb.check({ timeout: 8000 }); + const save = page.getByRole('button', { name: /^save$/i }); + // save is disabled if the grant already exists (idempotent re-runs) — fine. + if (await save.isEnabled().catch(() => false)) await save.click({ timeout: 8000 }); + await page.keyboard.press('Escape').catch(() => {}); + // Confirm the user now appears in the assignment table. + await expect(userRow).toBeVisible({ timeout: 8000 }); + return; + } catch { + // Close any open dialog and retry the whole flow. + await page.keyboard.press('Escape').catch(() => {}); + await page.waitForTimeout(1000); + if (await userRow.isVisible({ timeout: 2000 }).catch(() => false)) return; // already granted + } + } + // Final assertion (surfaces a clear failure if all attempts fell through). + await expect(userRow).toBeVisible({ timeout: 8000 }); +} diff --git a/specs/_utils/warehouse.ts b/specs/_utils/warehouse.ts new file mode 100644 index 0000000..32019ff --- /dev/null +++ b/specs/_utils/warehouse.ts @@ -0,0 +1,95 @@ +import { Page, expect } from '@playwright/test'; +import { login } from './auth'; +import { recoverFromOffline } from './app'; +import type { StorageBackend } from '../_data/storage-backends'; + +/** A clean, stable warehouse name from a backend key: demo-aws, demo-seaweedfs. */ +export function warehouseName(backend: StorageBackend) { + const slug = (backend.key.match(/\(([^)]+)\)/)?.[1] ?? backend.key).replace(/[^a-z0-9]/gi, ''); + return `demo-${slug}`; +} + +async function gotoWarehouses(page: Page) { + await page.goto('/ui/warehouse'); + await page.waitForLoadState('domcontentloaded'); + if (/\/ui\/login/.test(page.url())) { + await login(page); + await page.goto('/ui/warehouse'); + } + // Shake off the transient "Lakekeeper Unreachable" page (auth-hydration race). + await recoverFromOffline(page); + // The warehouse nav tree does NOT auto-update after a create — refresh it so a + // just-created warehouse actually appears (esp. for the seaweedfs journey). + await refreshWarehouses(page); +} + +/** Click the nav tree's "Refresh warehouses" button (if present) and let it settle. */ +export async function refreshWarehouses(page: Page) { + const btn = page.getByRole('button', { name: /refresh warehouse/i }).first(); + if (await btn.isVisible({ timeout: 5000 }).catch(() => false)) { + await btn.click().catch(() => {}); + await page.waitForLoadState('networkidle').catch(() => {}); + await page.waitForTimeout(1000); + } +} + +/** Create a warehouse for the given storage backend (idempotent-ish: if it already + * exists the list still shows it, which is all callers assert). Returns its name. */ +export async function createWarehouse(page: Page, backend: StorageBackend) { + const wh = warehouseName(backend); + await gotoWarehouses(page); + // Idempotent: a prior spec in this combo may have already created it (combos + // share backend state, no per-test cleanup). Reuse it instead of colliding. + const already = + (await page.getByRole('treeitem', { name: new RegExp(wh) }).first().isVisible({ timeout: 3000 }).catch(() => false)) || + (await page.getByText(wh, { exact: true }).first().isVisible({ timeout: 1000 }).catch(() => false)); + if (already) return wh; + await page.getByRole('button', { name: /add warehouse/i }).first().click(); + await page.getByLabel(/Warehouse Name/i).first().fill(wh); + await page.getByRole('tab', { name: backend.tab }).click(); + const panel = page.locator('.v-window-item--active'); + await backend.fill(panel); + await panel.getByRole('button', { name: /^create$/i }).click(); + await expect(page.getByText(wh, { exact: false }).first()).toBeVisible({ timeout: 20000 }); + return wh; +} + +/** Open a warehouse's detail page by clicking its row name cell. Retries the click + * until the route changes (console-plus loads heavier and can miss the first click). */ +export async function openWarehouse(page: Page, wh: string) { + await gotoWarehouses(page); + const nameCell = page.getByRole('row', { name: new RegExp(wh) }).getByText(wh, { exact: true }); + for (let i = 0; i < 4 && !/\/ui\/warehouse\/[^/]+/.test(page.url()); i++) { + await nameCell.click().catch(() => {}); + await page.waitForURL(/\/ui\/warehouse\/[^/]+/, { timeout: 5000 }).catch(() => {}); + } + await expect(page).toHaveURL(/\/ui\/warehouse\/[^/]+/, { timeout: 5000 }); +} + +/** Add a namespace on the currently-open warehouse detail page (idempotent). */ +export async function addNamespace(page: Page, ns: string) { + // Reuse if a prior spec already created it in this combo. + if (await page.getByText(ns, { exact: true }).first().isVisible({ timeout: 3000 }).catch(() => false)) { + return; + } + const addNs = page.getByRole('button', { name: /add namespace/i }); + await expect(addNs.first()).toBeVisible({ timeout: 15000 }); + await addNs.first().click(); + await page.getByLabel(/Namespace Name/i).fill(ns); + const submit = page.getByRole('button', { name: /^add namespace$/i }).last(); + await expect(submit).toBeEnabled({ timeout: 5000 }); + await submit.click(); + await expect(page.getByText(ns, { exact: true }).first()).toBeVisible({ timeout: 15000 }); +} + +/** Full seed: create warehouse + open it + add a namespace. Returns { wh, ns }. */ +export async function seedWarehouseWithNamespace( + page: Page, + backend: StorageBackend, + ns = 'demo_ns', +) { + const wh = await createWarehouse(page, backend); + await openWarehouse(page, wh); + await addNamespace(page, ns); + return { wh, ns }; +} diff --git a/specs/auth/login.spec.ts b/specs/auth/login.spec.ts new file mode 100644 index 0000000..0f2baa7 --- /dev/null +++ b/specs/auth/login.spec.ts @@ -0,0 +1,12 @@ +import { test, expect } from '@playwright/test'; +import { login } from '../_utils/auth'; + +// Auth modes only — noauth has no login screen (see noauth-access.spec.ts). +test.describe('login @authn @authz @cedar', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('logs in and lands on home or bootstrap', async ({ page }) => { + await login(page); + await expect(page).toHaveURL(/\/$|\/ui\/$|\/ui\/bootstrap/); + }); +}); diff --git a/specs/auth/logout.spec.ts b/specs/auth/logout.spec.ts new file mode 100644 index 0000000..813607f --- /dev/null +++ b/specs/auth/logout.spec.ts @@ -0,0 +1,10 @@ +import { test, expect } from '../_fixtures/auth.fixture'; + +test.describe('logout @authn @authz @cedar', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('logs out back to the login screen', async ({ bootstrappedPage: page }) => { + await page.goto('/ui/logout'); + await expect(page).toHaveURL(/\/ui\/login/, { timeout: 10000 }); + }); +}); diff --git a/specs/auth/noauth-access.spec.ts b/specs/auth/noauth-access.spec.ts new file mode 100644 index 0000000..f5dcb62 --- /dev/null +++ b/specs/auth/noauth-access.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from '../_fixtures/auth.fixture'; + +// noauth mode: the app must be reachable with no login step at all. +test.describe('anonymous access @noauth', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('app is usable without logging in', async ({ bootstrappedPage: page }) => { + await page.goto('/'); + await expect(page.locator('.v-application').first()).toBeVisible({ timeout: 15000 }); + // No login button should be present when authentication is disabled. + const loginBtn = page.locator('[data-testid="login-button"], button:has-text("Sign In")'); + await expect(loginBtn).toHaveCount(0); + }); +}); diff --git a/specs/bootstrap/bootstrap.spec.ts b/specs/bootstrap/bootstrap.spec.ts new file mode 100644 index 0000000..20de41a --- /dev/null +++ b/specs/bootstrap/bootstrap.spec.ts @@ -0,0 +1,12 @@ +import { test, expect } from '../_fixtures/auth.fixture'; + +// Fresh DB per matrix run → the server always needs bootstrapping first. +test.describe('bootstrap @noauth @authn @authz @cedar', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('completes bootstrap and reaches home', async ({ bootstrappedPage: page }) => { + // bootstrappedPage fixture has already driven the stepper if needed. + await expect(page).toHaveURL(/\/$|\/ui\/$/); + await expect(page.locator('.v-application').first()).toBeVisible(); + }); +}); diff --git a/specs/flows/loqe.spec.ts b/specs/flows/loqe.spec.ts new file mode 100644 index 0000000..6d55c9a --- /dev/null +++ b/specs/flows/loqe.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '../_fixtures/auth.fixture'; +import { ENABLED_BACKENDS } from '../_data/storage-backends'; +import { seedWarehouseWithNamespace } from '../_utils/warehouse'; +import { openLoqeAndAttach, createTableViaLoqe } from '../_utils/loqe'; + +// LoQE = in-browser DuckDB-WASM SQL engine. Needs SharedArrayBuffer (cross-origin +// isolation). Probe: does the engine initialize headless? ("Ready" chip). +test.describe('loqe @authn @authz @cedar', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('DuckDB-WASM engine initializes', async ({ bootstrappedPage: page }) => { + await page.goto('/ui/loqe'); + await page.waitForLoadState('domcontentloaded'); + // Init can take a while (WASM download + worker). "Ready" chip = initialized. + await expect(page.getByText('Ready', { exact: true }).first()).toBeVisible({ timeout: 90000 }); + }); + + // The full Iceberg round-trip: attach a warehouse catalog, CREATE a table (writes + // parquet + commits metadata to S3 from the browser), then read it back. Needs a + // browser-reachable bucket whose CORS allows WRITES for the app origin, and (for + // AWS) an STS-enabled warehouse — see _data/storage-backends.ts. Skipped if no + // deep-flow backend is configured. + const deep = ENABLED_BACKENDS.find((b) => b.deepFlows !== false); + test('create + query an Iceberg table via LoQE', async ({ bootstrappedPage: page }) => { + test.skip(!deep, 'no browser-reachable storage backend with write CORS configured'); + test.setTimeout(180000); + + const { wh, ns } = await seedWarehouseWithNamespace(page, deep!); + + // PROVE the browser origin (the video has no address bar). The bucket CORS is + // pinned to http://localhost:3001 — if vite bumped the port, the write fails. + const origin = new URL(page.url()).origin; + console.log(`### LoQE page origin: ${origin}`); + expect(origin, 'app must run on the CORS-allowed origin').toBe('http://localhost:3001'); + + await openLoqeAndAttach(page, wh, ns); + await createTableViaLoqe(page, wh, ns, 'demo_tbl'); + }); +}); diff --git a/specs/flows/role.spec.ts b/specs/flows/role.spec.ts new file mode 100644 index 0000000..0ce553b --- /dev/null +++ b/specs/flows/role.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from '../_fixtures/auth.fixture'; +import { login } from '../_utils/auth'; + +// Roles exist only with the OpenFGA authorizer (@authz). Cedar is pure +// policy-based and has NO role concept, so this is intentionally NOT @cedar. +test.describe('role CRUD @authz', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('create, edit, delete a role', async ({ bootstrappedPage: page }) => { + await page.goto('/ui/roles'); + await page.waitForLoadState('domcontentloaded'); + if (/\/ui\/login/.test(page.url())) { + await login(page); + await page.goto('/ui/roles'); + } + await expect(page.locator('.v-application').first()).toBeVisible(); + + const roleName = 'e2e-test-role'; + + // Open the "New Role" dialog and create a role. + await page.getByRole('button', { name: /add role/i }).first().click(); + await page.getByRole('textbox', { name: 'Role Name' }).fill(roleName); + await page.getByRole('textbox', { name: 'Role description' }).fill('created by e2e'); + await page.getByRole('button', { name: /save role/i }).click(); + + // It should appear in the list. + await expect(page.getByText(roleName, { exact: true })).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/specs/flows/warehouse-lifecycle.spec.ts b/specs/flows/warehouse-lifecycle.spec.ts new file mode 100644 index 0000000..f1bb317 --- /dev/null +++ b/specs/flows/warehouse-lifecycle.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '../_fixtures/auth.fixture'; +import { ENABLED_BACKENDS } from '../_data/storage-backends'; +import { createWarehouse, openWarehouse, addNamespace, warehouseName, refreshWarehouses } from '../_utils/warehouse'; + +// End-to-end JOURNEY (ordered steps in ONE isolated test) — the realistic flow a +// user does: create warehouse → see it in the nav → open it → add a namespace. +// Each step shows as a numbered entry (with its own screenshot) in the HTML report. +// (Creating/querying a TABLE lives in flows/loqe.spec.ts, not here.) +// +// One journey PER enabled storage backend (AWS always; SeaweedFS when +// S3_LOCAL_ENABLE=1; R2/ADLS/OneLake/GCS when their creds are set). +test.describe('warehouse lifecycle @authn @authz @cedar', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + test.skip(!ENABLED_BACKENDS.length, 'no storage backend configured (set AWS_* or S3_LOCAL_ENABLE=1)'); + + for (const backend of ENABLED_BACKENDS) { + test(`warehouse → namespace journey (${backend.key})`, async ({ bootstrappedPage: page }) => { + const wh = warehouseName(backend); + const ns = 'demo_ns'; + + await test.step('1 · create warehouse', async () => { + await createWarehouse(page, backend); + }); + + await test.step('2 · warehouse appears in nav tree', async () => { + await page.goto('/ui/warehouse'); + await page.waitForLoadState('domcontentloaded'); + // The tree doesn't auto-refresh after a create, and the refresh button only + // appears once the tree has loaded — so click "Refresh warehouses" and check + // in a loop until the just-created warehouse shows up. + const treeitem = page.getByRole('treeitem', { name: new RegExp(wh) }); + for (let i = 0; i < 4; i++) { + if (await treeitem.first().isVisible({ timeout: 5000 }).catch(() => false)) break; + await refreshWarehouses(page); + } + await expect(treeitem.first()).toBeVisible({ timeout: 10000 }); + }); + + // Deep flows need the detail page's storage explorer to reach the endpoint + // from the browser — only for browser-reachable backends (not local SeaweedFS). + if (backend.deepFlows === false) return; + + await test.step('3 · open warehouse + create namespace', async () => { + await openWarehouse(page, wh); + await addNamespace(page, ns); + }); + + // TODO(step 4 · delete warehouse): actions menu → Delete → DeleteConfirmDialog. + }); + } +}); diff --git a/specs/perms/access-control.spec.ts b/specs/perms/access-control.spec.ts new file mode 100644 index 0000000..c9d541d --- /dev/null +++ b/specs/perms/access-control.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from '../_fixtures/auth.fixture'; +import { login, TEST_USER_2 } from '../_utils/auth'; +import { ENABLED_BACKENDS } from '../_data/storage-backends'; +import { seedWarehouseWithNamespace } from '../_utils/warehouse'; +import { openLoqeAndAttach, createTableViaLoqe, loqeReadTable } from '../_utils/loqe'; +import { grantTableRelation } from '../_utils/permissions'; +import { grantAnnaTableReadCedar, resetCedarPolicy } from '../_utils/cedar'; + +// Permission ENFORCEMENT via a real data read — the whole point of authz. A +// non-admin (anna) must be DENIED reading a table until the admin (peter) grants +// her access, then ALLOWED. The read is a LoQE (DuckDB-WASM) SELECT against the +// table peter creates. FGA only here — Cedar grants work differently (a policy +// edit), covered separately (@cedar). No authz ⇒ no gate, so not tagged @authn. +// +// FGA model (confirmed empirically): a TABLE-level `select` grant is enough — FGA +// cascades the ancestor describe/list so anna can see + attach the warehouse. +test.describe('access control @authz', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + const deep = ENABLED_BACKENDS.find((b) => b.deepFlows !== false); + + test('anna can read a table only after peter grants table-select', async ({ + bootstrappedPage: page, + browser, + }) => { + test.skip(!deep, 'no browser-reachable storage backend with write CORS configured'); + test.setTimeout(300000); + const tbl = 'demo_tbl'; + + // 1 · peter seeds a warehouse + namespace + table (LoQE create round-trip). + const { wh, ns } = await seedWarehouseWithNamespace(page, deep!); + await openLoqeAndAttach(page, wh, ns); + await createTableViaLoqe(page, wh, ns, tbl); + + // 2 · anna (no grants) is DENIED — she can't even see the warehouse to attach + // it, so the SELECT fails ("Catalog does not exist"). + const denyCtx = await browser.newContext({ baseURL: 'http://localhost:3001' }); + const annaDenied = await denyCtx.newPage(); + await login(annaDenied, TEST_USER_2); + const before = await loqeReadTable(annaDenied, wh, ns, tbl, 2); + expect(before.warehouseVisible, 'anna should NOT see the warehouse before any grant').toBeFalsy(); + expect(before.errored, 'anna SELECT should fail before any grant').toBeTruthy(); + await denyCtx.close(); + + // 3 · peter grants anna `select` on the TABLE via the Permissions-tab UI. + await grantTableRelation(page, wh, ns, tbl, 'anna', 'select'); + + // 4 · anna is now ALLOWED — she sees + attaches the warehouse and the SELECT + // returns the value 1. + const okCtx = await browser.newContext({ baseURL: 'http://localhost:3001' }); + const annaAllowed = await okCtx.newPage(); + await login(annaAllowed, TEST_USER_2); + const after = await loqeReadTable(annaAllowed, wh, ns, tbl, 4); + expect(after.warehouseVisible, 'anna should see the warehouse after the grant').toBeTruthy(); + expect(after.errored, `anna SELECT should succeed after the grant (got: ${after.errorText})`).toBeFalsy(); + expect(after.value).toContain('1'); + await okCtx.close(); + }); +}); + +// Cedar enforces the same outcome but the "grant" is a POLICY-FILE edit (Cedar has +// no per-user grant UI / roles). A table grant is NOT enough — the policy must +// permit every level explicitly. The test writes anna's permit block into the +// (self-contained) policy file, Cedar hot-reloads, anna can read; then restores it. +// Cedar is console-plus-only, so this is @cedar (runs only in the cedar combo). +test.describe('access control (cedar) @cedar', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + const deep = ENABLED_BACKENDS.find((b) => b.deepFlows !== false); + + test.beforeEach(() => resetCedarPolicy()); // start denied + test.afterEach(() => resetCedarPolicy()); // never leave anna granted + + test('anna can read a table only after the Cedar policy grants her', async ({ + bootstrappedPage: page, + browser, + }) => { + test.skip(!deep, 'no browser-reachable storage backend with write CORS configured'); + test.setTimeout(300000); + const tbl = 'demo_tbl'; + + // 1 · peter (admin via base policy) seeds warehouse + namespace + table. + const { wh, ns } = await seedWarehouseWithNamespace(page, deep!); + await openLoqeAndAttach(page, wh, ns); + await createTableViaLoqe(page, wh, ns, tbl); + + // 2 · anna is DENIED (base policy grants her nothing). + const denyCtx = await browser.newContext({ baseURL: 'http://localhost:3001' }); + const annaDenied = await denyCtx.newPage(); + await login(annaDenied, TEST_USER_2); + const before = await loqeReadTable(annaDenied, wh, ns, tbl, 2); + expect(before.warehouseVisible, 'anna should NOT see the warehouse before the policy grant').toBeFalsy(); + expect(before.errored, 'anna SELECT should fail before the policy grant').toBeTruthy(); + await denyCtx.close(); + + // 3 · GRANT via Cedar: write anna's permit block into the policy file; Lakekeeper + // hot-reloads it. Give the reload a moment to take effect. + grantAnnaTableReadCedar(wh); + await page.waitForTimeout(6000); + + // 4 · anna is now ALLOWED — SELECT returns 1. + const okCtx = await browser.newContext({ baseURL: 'http://localhost:3001' }); + const annaAllowed = await okCtx.newPage(); + await login(annaAllowed, TEST_USER_2); + const after = await loqeReadTable(annaAllowed, wh, ns, tbl, 5); + expect(after.warehouseVisible, 'anna should see the warehouse after the policy grant').toBeTruthy(); + expect(after.errored, `anna SELECT should succeed after the policy grant (got: ${after.errorText})`).toBeFalsy(); + expect(after.value).toContain('1'); + await okCtx.close(); + }); +}); diff --git a/specs/smoke/route-smoke.spec.ts b/specs/smoke/route-smoke.spec.ts new file mode 100644 index 0000000..c99ffe5 --- /dev/null +++ b/specs/smoke/route-smoke.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '../_fixtures/auth.fixture'; +import { login, isAuthMode } from '../_utils/auth'; + +// Breadth check: every major route renders its app shell without crashing. +// Runs in ALL modes — gating differences are asserted in perms specs, not here. +test.describe('route smoke @smoke @noauth @authn @authz @cedar', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + // Core routes present in both console and console-plus. The app is served + // under the /ui/ base path. + const routes = ['/ui/', '/ui/warehouse', '/ui/roles', '/ui/user-profile', '/ui/dependencies']; + + test('major routes render', async ({ bootstrappedPage: page }) => { + for (const route of routes) { + await page.goto(route); + await page.waitForLoadState('domcontentloaded'); + + // A transient token-refresh race can bounce an authenticated navigation to + // the login screen; this is a render check, not an auth-stress test, so + // re-authenticate once and retry the route. + if (isAuthMode && /\/ui\/login/.test(page.url())) { + await login(page); + await page.goto(route); + await page.waitForLoadState('domcontentloaded'); + } + + // App shell mounted = no white-screen crash. + await expect(page.locator('.v-application').first()).toBeVisible({ timeout: 15000 }); + // No unhandled router/runtime error surfaced as a fatal overlay. + const fatal = page.locator('text=/Internal Server Error|Cannot read properties of/i'); + await expect(fatal).toHaveCount(0); + console.log(`✓ ${route}`); + } + }); +}); diff --git a/specs/storage/cors.spec.ts b/specs/storage/cors.spec.ts new file mode 100644 index 0000000..5ddf051 --- /dev/null +++ b/specs/storage/cors.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '../_fixtures/auth.fixture'; +import { login } from '../_utils/auth'; +import { ENABLED_BACKENDS } from '../_data/storage-backends'; +import { seedWarehouseWithNamespace } from '../_utils/warehouse'; +import { openLoqeAndAttach, createTableViaLoqe, loqeExec } from '../_utils/loqe'; + +// Storage CORS gate, demonstrated through the REAL LoQE query path (not a synthetic +// fetch). The Lakekeeper catalog API is CORS-* so the object tree loads on ANY +// origin — but reading table DATA (parquet/avro) is a direct browser→S3 request +// that needs the bucket's CORS to allow the app's ORIGIN. The AWS bucket allows +// http://localhost:3001 only: +// • on :3001 → `SELECT *` succeeds (rows render) +// • on :3002 → `SELECT *` fails with the in-app "Query Error … CORS" box +// Both screenshots are attached to the report so the actual UI error is visible. +// authn-only: needs the second :3002 app server (started just for this mode) and +// login works on both ports (the lakekeeper OIDC client allows redirect '*'). +test.describe('storage CORS @authn', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + const deep = ENABLED_BACKENDS.find((b) => b.deepFlows !== false); + + test('LoQE SELECT * works on :3001 but is CORS-blocked on :3002', async ( + { bootstrappedPage: page, browser }, + testInfo, + ) => { + test.skip(!deep, 'no browser-reachable (origin-scoped CORS) storage backend configured'); + test.setTimeout(300000); + const tbl = 'demo_tbl'; + + // Seed (idempotent — reuses the table the loqe test made in this combo). + const { wh, ns } = await seedWarehouseWithNamespace(page, deep!); + await openLoqeAndAttach(page, wh, ns); + await createTableViaLoqe(page, wh, ns, tbl); + const sql = `select * from "${wh}"."${ns}"."${tbl}";`; + + // :3001 — the allowed origin. SELECT * resolves and renders rows. + const ok = await loqeExec(page, sql); + await testInfo.attach('select-star-on-3001-ALLOWED', { + body: await page.screenshot({ fullPage: false }), + contentType: 'image/png', + }); + expect(ok.errored, `SELECT * on :3001 should work (got: ${ok.errorText})`).toBeFalsy(); + await expect(page.locator('.loqe-result-table').first()).toBeVisible({ timeout: 15000 }); + + // :3002 — a different origin. The tree still loads (catalog API is CORS-*), but + // reading the table data from S3 is blocked → the in-app Query Error / CORS box. + const ctx = await browser.newContext({ + baseURL: `http://localhost:3002`, + recordVideo: { dir: testInfo.outputDir }, // capture the :3002 failure on video + }); + const p2 = await ctx.newPage(); + try { + await login(p2); // peter on :3002 (OIDC client redirect '*') + await openLoqeAndAttach(p2, wh, ns); // tree loads via catalog API + const blocked = await loqeExec(p2, sql); + await testInfo.attach('select-star-on-3002-CORS-ERROR', { + body: await p2.screenshot({ fullPage: false }), + contentType: 'image/png', + }); + // The data read must be BLOCKED on :3002, AND console-components must surface + // its FRIENDLY "Configure CORS" guidance — not a raw DuckDB error. chromium + // and firefox fail differently underneath (chromium: "HTTP Error … CORS"; + // firefox: "Cannot read N bytes from memory buffer"), but LoQEEngine's + // cross-browser detection now translates BOTH into the same actionable box. + // This assertion guards that fix. + console.log(`### :3002 SELECT * blocked with: ${blocked.errorText.replace(/\s+/g, ' ').slice(0, 160)}`); + expect(blocked.errored, 'SELECT * on :3002 should fail (origin not allowed by bucket CORS)').toBeTruthy(); + expect( + blocked.errorText, + 'console-components should show the friendly "Configure CORS" message (both browsers)', + ).toMatch(/Could not access|Configure CORS/i); + await p2.waitForTimeout(1500); // let the video capture the error box + } finally { + await ctx.close(); + } + }); +});